A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from "react";
2import type { ProfileRecord } from "../types/bluesky";
3import { BlueskyIcon } from "../components/BlueskyIcon";
4import { useAtProto } from "../providers/AtProtoProvider";
5
6export interface BlueskyProfileRendererProps {
7 record: ProfileRecord;
8 loading: boolean;
9 error?: Error;
10 did: string;
11 handle?: string;
12 avatarUrl?: string;
13}
14
15export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({
16 record,
17 loading,
18 error,
19 did,
20 handle,
21 avatarUrl,
22}) => {
23 const { blueskyAppBaseUrl } = useAtProto();
24
25 if (error)
26 return (
27 <div style={{ padding: 8, color: "crimson" }}>
28 Failed to load profile.
29 </div>
30 );
31 if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
32
33 const profileUrl = `${blueskyAppBaseUrl}/profile/${did}`;
34 const rawWebsite = record.website?.trim();
35 const websiteHref = rawWebsite
36 ? rawWebsite.match(/^https?:\/\//i)
37 ? rawWebsite
38 : `https://${rawWebsite}`
39 : undefined;
40 const websiteLabel = rawWebsite
41 ? rawWebsite.replace(/^https?:\/\//i, "")
42 : undefined;
43
44 return (
45 <div style={{ ...base.card, background: `var(--atproto-color-bg)`, borderColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
46 <div style={base.header}>
47 {avatarUrl ? (
48 <img src={avatarUrl} alt="avatar" style={base.avatarImg} />
49 ) : (
50 <div
51 style={{ ...base.avatar, background: `var(--atproto-color-bg-elevated)` }}
52 aria-label="avatar"
53 />
54 )}
55 <div style={{ flex: 1 }}>
56 <div style={{ ...base.display, color: `var(--atproto-color-text)` }}>
57 {record.displayName ?? handle ?? did}
58 </div>
59 <div style={{ ...base.handleLine, color: `var(--atproto-color-text-secondary)` }}>
60 @{handle ?? did}
61 </div>
62 {record.pronouns && (
63 <div style={{ ...base.pronouns, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }}>
64 {record.pronouns}
65 </div>
66 )}
67 </div>
68 </div>
69 {record.description && (
70 <p style={{ ...base.desc, color: `var(--atproto-color-text)` }}>
71 {record.description}
72 </p>
73 )}
74 {record.createdAt && (
75 <div style={{ ...base.meta, color: `var(--atproto-color-text-secondary)` }}>
76 Joined {new Date(record.createdAt).toLocaleDateString()}
77 </div>
78 )}
79 <div style={base.links}>
80 {websiteHref && websiteLabel && (
81 <a
82 href={websiteHref}
83 target="_blank"
84 rel="noopener noreferrer"
85 style={{ ...base.link, color: `var(--atproto-color-link)` }}
86 >
87 {websiteLabel}
88 </a>
89 )}
90 <a
91 href={profileUrl}
92 target="_blank"
93 rel="noopener noreferrer"
94 style={{ ...base.link, color: `var(--atproto-color-link)` }}
95 >
96 View on Bluesky
97 </a>
98 </div>
99 <div style={base.iconCorner} aria-hidden>
100 <BlueskyIcon size={18} />
101 </div>
102 </div>
103 );
104};
105
106const base: Record<string, React.CSSProperties> = {
107 card: {
108 borderRadius: 12,
109 padding: 16,
110 fontFamily: "system-ui, sans-serif",
111 maxWidth: 480,
112 transition:
113 "background-color 180ms ease, border-color 180ms ease, color 180ms ease",
114 position: "relative",
115 },
116 header: {
117 display: "flex",
118 gap: 12,
119 marginBottom: 8,
120 },
121 avatar: {
122 width: 64,
123 height: 64,
124 borderRadius: "50%",
125 },
126 avatarImg: {
127 width: 64,
128 height: 64,
129 borderRadius: "50%",
130 objectFit: "cover",
131 },
132 display: {
133 fontSize: 20,
134 fontWeight: 600,
135 },
136 handleLine: {
137 fontSize: 13,
138 },
139 desc: {
140 whiteSpace: "pre-wrap",
141 fontSize: 14,
142 lineHeight: 1.4,
143 },
144 meta: {
145 marginTop: 12,
146 fontSize: 12,
147 },
148 pronouns: {
149 display: "inline-flex",
150 alignItems: "center",
151 gap: 4,
152 fontSize: 12,
153 fontWeight: 500,
154 borderRadius: 999,
155 padding: "2px 8px",
156 marginTop: 6,
157 },
158 links: {
159 display: "flex",
160 flexDirection: "column",
161 gap: 8,
162 marginTop: 12,
163 },
164 link: {
165 display: "inline-flex",
166 alignItems: "center",
167 gap: 4,
168 fontSize: 12,
169 fontWeight: 600,
170 textDecoration: "none",
171 },
172 iconCorner: {
173 position: "absolute",
174 right: 12,
175 bottom: 12,
176 },
177};
178
179export default BlueskyProfileRenderer;