+53
-51
README.md
+53
-51
README.md
···
26
26
3. Use the hooks to prefetch handles, blobs, or latest records when you want to control the render flow yourself.
27
27
28
28
```tsx
29
-
import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
29
+
import { AtProtoProvider, BlueskyPost } from "atproto-ui";
30
30
31
31
export function App() {
32
-
return (
33
-
<AtProtoProvider>
34
-
<BlueskyPost did="did:plc:example" rkey="3k2aexample" />
35
-
{/* you can use handles in the components as well. */}
36
-
<LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
37
-
</AtProtoProvider>
38
-
);
32
+
return (
33
+
<AtProtoProvider>
34
+
<BlueskyPost did="did:plc:example" rkey="3k2aexample" />
35
+
{/* you can use handles in the components as well. */}
36
+
<LeafletDocument did="nekomimi.pet" rkey="3m2seagm2222c" />
37
+
</AtProtoProvider>
38
+
);
39
39
}
40
40
```
41
41
42
42
### Available building blocks
43
43
44
-
| Component / Hook | What it does |
45
-
| --- | --- |
46
-
| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
47
-
| `BlueskyProfile` | Renders a profile card for a DID/handle. Accepts `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
48
-
| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post, with quotation support, custom renderer overrides, and the same loading/fallback knobs. |
49
-
| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
50
-
| `TangledString` | Renders a Tangled string (gist-like record) with optional renderer overrides. |
51
-
| `LeafletDocument` | Displays long-form Leaflet documents with blocks, theme support, and renderer overrides. |
52
-
| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records if you want to own the markup or prefill components. |
44
+
| Component / Hook | What it does |
45
+
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
46
+
| `AtProtoProvider` | Configures PLC directory (defaults to `https://plc.directory`) and shares protocol clients via React context. |
47
+
| `BlueskyProfile` | Renders a profile card for a DID/handle. Accepts `fallback`, `loadingIndicator`, `renderer`, and `colorScheme`. |
48
+
| `BlueskyPost` / `BlueskyQuotePost` | Shows a single Bluesky post, with quotation support, custom renderer overrides, and the same loading/fallback knobs. |
49
+
| `BlueskyPostList` | Lists the latest posts with built-in pagination (defaults: 5 per page, pagination controls on). |
50
+
| `TangledString` | Renders a Tangled string (gist-like record) with optional renderer overrides. |
51
+
| `LeafletDocument` | Displays long-form Leaflet documents with blocks, theme support, and renderer overrides. |
52
+
| `useDidResolution`, `useLatestRecord`, `usePaginatedRecords`, … | Hook-level access to records if you want to own the markup or prefill components. |
53
53
54
54
All components accept a `colorScheme` of `'light' | 'dark' | 'system'` so they can blend into your design. They also accept `fallback` and `loadingIndicator` props to control what renders before or during network work, and most expose a `renderer` override when you need total control of the final markup.
55
55
···
58
58
`useLatestRecord` gives you the most recent record for any collection along with its `rkey`. You can use that key to pre-populate components like `BlueskyPost`, `LeafletDocument`, or `TangledString`.
59
59
60
60
```tsx
61
-
import { useLatestRecord, BlueskyPost } from 'atproto-ui';
62
-
import type { FeedPostRecord } from 'atproto-ui';
61
+
import { useLatestRecord, BlueskyPost } from "atproto-ui";
62
+
import type { FeedPostRecord } from "atproto-ui";
63
63
64
64
const LatestBlueskyPost: React.FC<{ did: string }> = ({ did }) => {
65
-
const { rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
65
+
const { rkey, loading, error, empty } = useLatestRecord<FeedPostRecord>(
66
+
did,
67
+
"app.bsky.feed.post",
68
+
);
66
69
67
-
if (loading) return <p>Fetching latest post…</p>;
68
-
if (error) return <p>Could not load: {error.message}</p>;
69
-
if (empty || !rkey) return <p>No posts yet.</p>;
70
+
if (loading) return <p>Fetching latest post…</p>;
71
+
if (error) return <p>Could not load: {error.message}</p>;
72
+
if (empty || !rkey) return <p>No posts yet.</p>;
70
73
71
-
return (
72
-
<BlueskyPost
73
-
did={did}
74
-
rkey={rkey}
75
-
colorScheme="system"
76
-
/>
77
-
);
74
+
return <BlueskyPost did={did} rkey={rkey} colorScheme="system" />;
78
75
};
79
76
```
80
77
···
82
79
83
80
```tsx
84
81
const LatestLeafletDocument: React.FC<{ did: string }> = ({ did }) => {
85
-
const { rkey } = useLatestRecord(did, 'pub.leaflet.document');
86
-
return rkey ? <LeafletDocument did={did} rkey={rkey} colorScheme="light" /> : null;
82
+
const { rkey } = useLatestRecord(did, "pub.leaflet.document");
83
+
return rkey ? (
84
+
<LeafletDocument did={did} rkey={rkey} colorScheme="light" />
85
+
) : null;
87
86
};
88
87
```
89
88
···
92
91
The helpers let you stitch together custom experiences without reimplementing protocol plumbing. The example below pulls a creator’s latest post and renders a minimal summary:
93
92
94
93
```tsx
95
-
import { useLatestRecord, useColorScheme, AtProtoRecord } from 'atproto-ui';
96
-
import type { FeedPostRecord } from 'atproto-ui';
94
+
import { useLatestRecord, useColorScheme, AtProtoRecord } from "atproto-ui";
95
+
import type { FeedPostRecord } from "atproto-ui";
97
96
98
97
const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
99
-
const scheme = useColorScheme('system');
100
-
const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
98
+
const scheme = useColorScheme("system");
99
+
const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(
100
+
did,
101
+
"app.bsky.feed.post",
102
+
);
101
103
102
-
if (loading) return <span>Loading…</span>;
103
-
if (error || !rkey) return <span>No post yet.</span>;
104
+
if (loading) return <span>Loading…</span>;
105
+
if (error || !rkey) return <span>No post yet.</span>;
104
106
105
-
return (
106
-
<AtProtoRecord<FeedPostRecord>
107
-
did={did}
108
-
collection="app.bsky.feed.post"
109
-
rkey={rkey}
110
-
renderer={({ record }) => (
111
-
<article data-color-scheme={scheme}>
112
-
<strong>{record?.text ?? 'Empty post'}</strong>
113
-
</article>
114
-
)}
115
-
/>
116
-
);
107
+
return (
108
+
<AtProtoRecord<FeedPostRecord>
109
+
did={did}
110
+
collection="app.bsky.feed.post"
111
+
rkey={rkey}
112
+
renderer={({ record }) => (
113
+
<article data-color-scheme={scheme}>
114
+
<strong>{record?.text ?? "Empty post"}</strong>
115
+
</article>
116
+
)}
117
+
/>
118
+
);
117
119
};
118
120
```
119
121
···
133
135
- Expand renderer coverage (e.g., Grain.social photos).
134
136
- Expand documentation with TypeScript API references and theming guidelines.
135
137
136
-
Contributions and ideas are welcome—feel free to open an issue or PR!
138
+
Contributions and ideas are welcome—feel free to open an issue or PR!
+36
-32
lib/components/BlueskyIcon.tsx
+36
-32
lib/components/BlueskyIcon.tsx
···
1
-
import React from 'react';
1
+
import React from "react";
2
2
3
3
/**
4
4
* Configuration for the `BlueskyIcon` component.
5
5
*/
6
6
export interface BlueskyIconProps {
7
-
/**
8
-
* Pixel dimensions applied to both the width and height of the SVG element.
9
-
* Defaults to `16`.
10
-
*/
11
-
size?: number;
12
-
/**
13
-
* Hex, RGB, or any valid CSS color string used to fill the icon path.
14
-
* Defaults to the standard Bluesky blue `#1185fe`.
15
-
*/
16
-
color?: string;
17
-
/**
18
-
* Accessible title that will be exposed via `aria-label` for screen readers.
19
-
* Defaults to `'Bluesky'`.
20
-
*/
21
-
title?: string;
7
+
/**
8
+
* Pixel dimensions applied to both the width and height of the SVG element.
9
+
* Defaults to `16`.
10
+
*/
11
+
size?: number;
12
+
/**
13
+
* Hex, RGB, or any valid CSS color string used to fill the icon path.
14
+
* Defaults to the standard Bluesky blue `#1185fe`.
15
+
*/
16
+
color?: string;
17
+
/**
18
+
* Accessible title that will be exposed via `aria-label` for screen readers.
19
+
* Defaults to `'Bluesky'`.
20
+
*/
21
+
title?: string;
22
22
}
23
23
24
24
/**
···
29
29
* @param title - Accessible label exposed via `aria-label`.
30
30
* @returns A JSX `<svg>` element suitable for inline usage.
31
31
*/
32
-
export const BlueskyIcon: React.FC<BlueskyIconProps> = ({ size = 16, color = '#1185fe', title = 'Bluesky' }) => (
33
-
<svg
34
-
xmlns="http://www.w3.org/2000/svg"
35
-
width={size}
36
-
height={size}
37
-
viewBox="0 0 16 16"
38
-
role="img"
39
-
aria-label={title}
40
-
focusable="false"
41
-
style={{ display: 'block' }}
42
-
>
43
-
<path
44
-
fill={color}
45
-
d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"
46
-
/>
47
-
</svg>
32
+
export const BlueskyIcon: React.FC<BlueskyIconProps> = ({
33
+
size = 16,
34
+
color = "#1185fe",
35
+
title = "Bluesky",
36
+
}) => (
37
+
<svg
38
+
xmlns="http://www.w3.org/2000/svg"
39
+
width={size}
40
+
height={size}
41
+
viewBox="0 0 16 16"
42
+
role="img"
43
+
aria-label={title}
44
+
focusable="false"
45
+
style={{ display: "block" }}
46
+
>
47
+
<path
48
+
fill={color}
49
+
d="M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.725-1.498 2.698-4.29 4.532-5.668C13.855.955 16 .186 16 2.632c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.3-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.65 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.737 0 3.12 0 2.632 0 .186 2.145.955 3.468 1.948"
50
+
/>
51
+
</svg>
48
52
);
49
53
50
54
export default BlueskyIcon;
+176
-134
lib/components/BlueskyPost.tsx
+176
-134
lib/components/BlueskyPost.tsx
···
1
-
import React, { useMemo } from 'react';
2
-
import { AtProtoRecord } from '../core/AtProtoRecord';
3
-
import { BlueskyPostRenderer } from '../renderers/BlueskyPostRenderer';
4
-
import type { FeedPostRecord, ProfileRecord } from '../types/bluesky';
5
-
import { useDidResolution } from '../hooks/useDidResolution';
6
-
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
7
-
import { useBlob } from '../hooks/useBlob';
8
-
import { BLUESKY_PROFILE_COLLECTION } from './BlueskyProfile';
9
-
import { getAvatarCid } from '../utils/profile';
10
-
import { formatDidForLabel } from '../utils/at-uri';
1
+
import React, { useMemo } from "react";
2
+
import { AtProtoRecord } from "../core/AtProtoRecord";
3
+
import { BlueskyPostRenderer } from "../renderers/BlueskyPostRenderer";
4
+
import type { FeedPostRecord, ProfileRecord } from "../types/bluesky";
5
+
import { useDidResolution } from "../hooks/useDidResolution";
6
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
7
+
import { useBlob } from "../hooks/useBlob";
8
+
import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
9
+
import { getAvatarCid } from "../utils/profile";
10
+
import { formatDidForLabel } from "../utils/at-uri";
11
11
12
12
/**
13
13
* Props for rendering a single Bluesky post with optional customization hooks.
14
14
*/
15
15
export interface BlueskyPostProps {
16
-
/**
17
-
* Decentralized identifier for the repository that owns the post.
18
-
*/
19
-
did: string;
20
-
/**
21
-
* Record key identifying the specific post within the collection.
22
-
*/
23
-
rkey: string;
24
-
/**
25
-
* Custom renderer component that receives resolved post data and status flags.
26
-
*/
27
-
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
28
-
/**
29
-
* React node shown while the post query has not yet produced data or an error.
30
-
*/
31
-
fallback?: React.ReactNode;
32
-
/**
33
-
* React node displayed while the post fetch is actively loading.
34
-
*/
35
-
loadingIndicator?: React.ReactNode;
36
-
/**
37
-
* Preferred color scheme to pass through to renderers.
38
-
*/
39
-
colorScheme?: 'light' | 'dark' | 'system';
40
-
/**
41
-
* Whether the default renderer should show the Bluesky icon.
42
-
* Defaults to `true`.
43
-
*/
44
-
showIcon?: boolean;
45
-
/**
46
-
* Placement strategy for the icon when it is rendered.
47
-
* Defaults to `'timestamp'`.
48
-
*/
49
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
16
+
/**
17
+
* Decentralized identifier for the repository that owns the post.
18
+
*/
19
+
did: string;
20
+
/**
21
+
* Record key identifying the specific post within the collection.
22
+
*/
23
+
rkey: string;
24
+
/**
25
+
* Custom renderer component that receives resolved post data and status flags.
26
+
*/
27
+
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
28
+
/**
29
+
* React node shown while the post query has not yet produced data or an error.
30
+
*/
31
+
fallback?: React.ReactNode;
32
+
/**
33
+
* React node displayed while the post fetch is actively loading.
34
+
*/
35
+
loadingIndicator?: React.ReactNode;
36
+
/**
37
+
* Preferred color scheme to pass through to renderers.
38
+
*/
39
+
colorScheme?: "light" | "dark" | "system";
40
+
/**
41
+
* Whether the default renderer should show the Bluesky icon.
42
+
* Defaults to `true`.
43
+
*/
44
+
showIcon?: boolean;
45
+
/**
46
+
* Placement strategy for the icon when it is rendered.
47
+
* Defaults to `'timestamp'`.
48
+
*/
49
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
50
50
}
51
51
52
52
/**
53
53
* Values injected by `BlueskyPost` into a downstream renderer component.
54
54
*/
55
55
export type BlueskyPostRendererInjectedProps = {
56
-
/**
57
-
* Resolved record payload for the post.
58
-
*/
59
-
record: FeedPostRecord;
60
-
/**
61
-
* `true` while network operations are in-flight.
62
-
*/
63
-
loading: boolean;
64
-
/**
65
-
* Error encountered during loading, if any.
66
-
*/
67
-
error?: Error;
68
-
/**
69
-
* The author's public handle derived from the DID.
70
-
*/
71
-
authorHandle: string;
72
-
/**
73
-
* The DID that owns the post record.
74
-
*/
75
-
authorDid: string;
76
-
/**
77
-
* Resolved URL for the author's avatar blob, if available.
78
-
*/
79
-
avatarUrl?: string;
80
-
/**
81
-
* Preferred color scheme bubbled down to children.
82
-
*/
83
-
colorScheme?: 'light' | 'dark' | 'system';
84
-
/**
85
-
* Placement strategy for the Bluesky icon.
86
-
*/
87
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
88
-
/**
89
-
* Controls whether the icon should render at all.
90
-
*/
91
-
showIcon?: boolean;
92
-
/**
93
-
* Fully qualified AT URI of the post, when resolvable.
94
-
*/
95
-
atUri?: string;
96
-
/**
97
-
* Optional override for the rendered embed contents.
98
-
*/
99
-
embed?: React.ReactNode;
56
+
/**
57
+
* Resolved record payload for the post.
58
+
*/
59
+
record: FeedPostRecord;
60
+
/**
61
+
* `true` while network operations are in-flight.
62
+
*/
63
+
loading: boolean;
64
+
/**
65
+
* Error encountered during loading, if any.
66
+
*/
67
+
error?: Error;
68
+
/**
69
+
* The author's public handle derived from the DID.
70
+
*/
71
+
authorHandle: string;
72
+
/**
73
+
* The DID that owns the post record.
74
+
*/
75
+
authorDid: string;
76
+
/**
77
+
* Resolved URL for the author's avatar blob, if available.
78
+
*/
79
+
avatarUrl?: string;
80
+
/**
81
+
* Preferred color scheme bubbled down to children.
82
+
*/
83
+
colorScheme?: "light" | "dark" | "system";
84
+
/**
85
+
* Placement strategy for the Bluesky icon.
86
+
*/
87
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
88
+
/**
89
+
* Controls whether the icon should render at all.
90
+
*/
91
+
showIcon?: boolean;
92
+
/**
93
+
* Fully qualified AT URI of the post, when resolvable.
94
+
*/
95
+
atUri?: string;
96
+
/**
97
+
* Optional override for the rendered embed contents.
98
+
*/
99
+
embed?: React.ReactNode;
100
100
};
101
101
102
102
/** NSID for the canonical Bluesky feed post collection. */
103
-
export const BLUESKY_POST_COLLECTION = 'app.bsky.feed.post';
103
+
export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post";
104
104
105
105
/**
106
106
* Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
···
116
116
* @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
117
117
* @returns A component that renders loading/fallback states and the resolved post.
118
118
*/
119
-
export const BlueskyPost: React.FC<BlueskyPostProps> = ({ did: handleOrDid, rkey, renderer, fallback, loadingIndicator, colorScheme, showIcon = true, iconPlacement = 'timestamp' }) => {
120
-
const { did: resolvedDid, handle, loading: resolvingIdentity, error: resolutionError } = useDidResolution(handleOrDid);
121
-
const repoIdentifier = resolvedDid ?? handleOrDid;
122
-
const { record: profile } = useAtProtoRecord<ProfileRecord>({ did: repoIdentifier, collection: BLUESKY_PROFILE_COLLECTION, rkey: 'self' });
123
-
const avatarCid = getAvatarCid(profile);
119
+
export const BlueskyPost: React.FC<BlueskyPostProps> = ({
120
+
did: handleOrDid,
121
+
rkey,
122
+
renderer,
123
+
fallback,
124
+
loadingIndicator,
125
+
colorScheme,
126
+
showIcon = true,
127
+
iconPlacement = "timestamp",
128
+
}) => {
129
+
const {
130
+
did: resolvedDid,
131
+
handle,
132
+
loading: resolvingIdentity,
133
+
error: resolutionError,
134
+
} = useDidResolution(handleOrDid);
135
+
const repoIdentifier = resolvedDid ?? handleOrDid;
136
+
const { record: profile } = useAtProtoRecord<ProfileRecord>({
137
+
did: repoIdentifier,
138
+
collection: BLUESKY_PROFILE_COLLECTION,
139
+
rkey: "self",
140
+
});
141
+
const avatarCid = getAvatarCid(profile);
124
142
125
-
const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = renderer ?? ((props) => <BlueskyPostRenderer {...props} />);
143
+
const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
144
+
() => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
145
+
[renderer]
146
+
);
126
147
127
-
const displayHandle = handle ?? (handleOrDid.startsWith('did:') ? undefined : handleOrDid);
128
-
const authorHandle = displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
129
-
const atUri = resolvedDid ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` : undefined;
148
+
const displayHandle =
149
+
handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
150
+
const authorHandle =
151
+
displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
152
+
const atUri = resolvedDid
153
+
? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
154
+
: undefined;
130
155
131
-
const Wrapped = useMemo(() => {
132
-
const WrappedComponent: React.FC<{ record: FeedPostRecord; loading: boolean; error?: Error }> = (props) => {
133
-
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
134
-
return (
135
-
<Comp
136
-
{...props}
137
-
authorHandle={authorHandle}
138
-
authorDid={repoIdentifier}
139
-
avatarUrl={avatarUrl}
140
-
colorScheme={colorScheme}
141
-
iconPlacement={iconPlacement}
142
-
showIcon={showIcon}
143
-
atUri={atUri}
144
-
/>
145
-
);
146
-
};
147
-
WrappedComponent.displayName = 'BlueskyPostWrappedRenderer';
148
-
return WrappedComponent;
149
-
}, [Comp, repoIdentifier, avatarCid, authorHandle, colorScheme, iconPlacement, showIcon, atUri]);
156
+
const Wrapped = useMemo(() => {
157
+
const WrappedComponent: React.FC<{
158
+
record: FeedPostRecord;
159
+
loading: boolean;
160
+
error?: Error;
161
+
}> = (props) => {
162
+
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
163
+
return (
164
+
<Comp
165
+
{...props}
166
+
authorHandle={authorHandle}
167
+
authorDid={repoIdentifier}
168
+
avatarUrl={avatarUrl}
169
+
colorScheme={colorScheme}
170
+
iconPlacement={iconPlacement}
171
+
showIcon={showIcon}
172
+
atUri={atUri}
173
+
/>
174
+
);
175
+
};
176
+
WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
177
+
return WrappedComponent;
178
+
}, [
179
+
Comp,
180
+
repoIdentifier,
181
+
avatarCid,
182
+
authorHandle,
183
+
colorScheme,
184
+
iconPlacement,
185
+
showIcon,
186
+
atUri,
187
+
]);
150
188
151
-
if (!displayHandle && resolvingIdentity) {
152
-
return <div style={{ padding: 8 }}>Resolving handle…</div>;
153
-
}
154
-
if (!displayHandle && resolutionError) {
155
-
return <div style={{ padding: 8, color: 'crimson' }}>Could not resolve handle.</div>;
156
-
}
189
+
if (!displayHandle && resolvingIdentity) {
190
+
return <div style={{ padding: 8 }}>Resolving handle…</div>;
191
+
}
192
+
if (!displayHandle && resolutionError) {
193
+
return (
194
+
<div style={{ padding: 8, color: "crimson" }}>
195
+
Could not resolve handle.
196
+
</div>
197
+
);
198
+
}
157
199
158
-
return (
159
-
<AtProtoRecord<FeedPostRecord>
160
-
did={repoIdentifier}
161
-
collection={BLUESKY_POST_COLLECTION}
162
-
rkey={rkey}
163
-
renderer={Wrapped}
164
-
fallback={fallback}
165
-
loadingIndicator={loadingIndicator}
166
-
/>
167
-
);
200
+
return (
201
+
<AtProtoRecord<FeedPostRecord>
202
+
did={repoIdentifier}
203
+
collection={BLUESKY_POST_COLLECTION}
204
+
rkey={rkey}
205
+
renderer={Wrapped}
206
+
fallback={fallback}
207
+
loadingIndicator={loadingIndicator}
208
+
/>
209
+
);
168
210
};
169
211
170
-
export default BlueskyPost;
212
+
export default BlueskyPost;
+558
-427
lib/components/BlueskyPostList.tsx
+558
-427
lib/components/BlueskyPostList.tsx
···
1
-
import React, { useMemo } from 'react';
2
-
import { usePaginatedRecords, type AuthorFeedReason, type ReplyParentInfo } from '../hooks/usePaginatedRecords';
3
-
import { useColorScheme } from '../hooks/useColorScheme';
4
-
import type { FeedPostRecord } from '../types/bluesky';
5
-
import { useDidResolution } from '../hooks/useDidResolution';
6
-
import { BlueskyIcon } from './BlueskyIcon';
7
-
import { parseAtUri } from '../utils/at-uri';
1
+
import React, { useMemo } from "react";
2
+
import {
3
+
usePaginatedRecords,
4
+
type AuthorFeedReason,
5
+
type ReplyParentInfo,
6
+
} from "../hooks/usePaginatedRecords";
7
+
import { useColorScheme } from "../hooks/useColorScheme";
8
+
import type { FeedPostRecord } from "../types/bluesky";
9
+
import { useDidResolution } from "../hooks/useDidResolution";
10
+
import { BlueskyIcon } from "./BlueskyIcon";
11
+
import { parseAtUri } from "../utils/at-uri";
8
12
9
13
/**
10
14
* Options for rendering a paginated list of Bluesky posts.
11
15
*/
12
16
export interface BlueskyPostListProps {
13
-
/**
14
-
* DID whose feed posts should be fetched.
15
-
*/
16
-
did: string;
17
-
/**
18
-
* Maximum number of records to list per page. Defaults to `5`.
19
-
*/
20
-
limit?: number;
21
-
/**
22
-
* Enables pagination controls when `true`. Defaults to `true`.
23
-
*/
24
-
enablePagination?: boolean;
25
-
/**
26
-
* Preferred color scheme passed through to styling helpers.
27
-
* Defaults to `'system'` which follows the OS preference.
28
-
*/
29
-
colorScheme?: 'light' | 'dark' | 'system';
17
+
/**
18
+
* DID whose feed posts should be fetched.
19
+
*/
20
+
did: string;
21
+
/**
22
+
* Maximum number of records to list per page. Defaults to `5`.
23
+
*/
24
+
limit?: number;
25
+
/**
26
+
* Enables pagination controls when `true`. Defaults to `true`.
27
+
*/
28
+
enablePagination?: boolean;
29
+
/**
30
+
* Preferred color scheme passed through to styling helpers.
31
+
* Defaults to `'system'` which follows the OS preference.
32
+
*/
33
+
colorScheme?: "light" | "dark" | "system";
30
34
}
31
35
32
36
/**
···
38
42
* @param colorScheme - Preferred color scheme used for styling. Default `'system'`.
39
43
* @returns A card-like list element with loading, empty, and error handling.
40
44
*/
41
-
export const BlueskyPostList: React.FC<BlueskyPostListProps> = ({ did, limit = 5, enablePagination = true, colorScheme = 'system' }) => {
42
-
const scheme = useColorScheme(colorScheme);
43
-
const palette: ListPalette = scheme === 'dark' ? darkPalette : lightPalette;
44
-
const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
45
-
const actorLabel = resolvedHandle ?? formatDid(did);
46
-
const actorPath = resolvedHandle ?? resolvedDid ?? did;
45
+
export const BlueskyPostList: React.FC<BlueskyPostListProps> = ({
46
+
did,
47
+
limit = 5,
48
+
enablePagination = true,
49
+
colorScheme = "system",
50
+
}) => {
51
+
const scheme = useColorScheme(colorScheme);
52
+
const palette: ListPalette = scheme === "dark" ? darkPalette : lightPalette;
53
+
const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
54
+
const actorLabel = resolvedHandle ?? formatDid(did);
55
+
const actorPath = resolvedHandle ?? resolvedDid ?? did;
47
56
48
-
const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({
49
-
did,
50
-
collection: 'app.bsky.feed.post',
51
-
limit,
52
-
preferAuthorFeed: true,
53
-
authorFeedActor: actorPath
54
-
});
57
+
const {
58
+
records,
59
+
loading,
60
+
error,
61
+
hasNext,
62
+
hasPrev,
63
+
loadNext,
64
+
loadPrev,
65
+
pageIndex,
66
+
pagesCount,
67
+
} = usePaginatedRecords<FeedPostRecord>({
68
+
did,
69
+
collection: "app.bsky.feed.post",
70
+
limit,
71
+
preferAuthorFeed: true,
72
+
authorFeedActor: actorPath,
73
+
});
55
74
56
-
const pageLabel = useMemo(() => {
57
-
const knownTotal = Math.max(pageIndex + 1, pagesCount);
58
-
if (!enablePagination) return undefined;
59
-
if (hasNext && knownTotal === pageIndex + 1) return `${pageIndex + 1}/…`;
60
-
return `${pageIndex + 1}/${knownTotal}`;
61
-
}, [enablePagination, hasNext, pageIndex, pagesCount]);
75
+
const pageLabel = useMemo(() => {
76
+
const knownTotal = Math.max(pageIndex + 1, pagesCount);
77
+
if (!enablePagination) return undefined;
78
+
if (hasNext && knownTotal === pageIndex + 1)
79
+
return `${pageIndex + 1}/…`;
80
+
return `${pageIndex + 1}/${knownTotal}`;
81
+
}, [enablePagination, hasNext, pageIndex, pagesCount]);
62
82
63
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load posts.</div>;
83
+
if (error)
84
+
return (
85
+
<div style={{ padding: 8, color: "crimson" }}>
86
+
Failed to load posts.
87
+
</div>
88
+
);
64
89
65
-
return (
66
-
<div style={{ ...listStyles.card, ...palette.card }}>
67
-
<div style={{ ...listStyles.header, ...palette.header }}>
68
-
<div style={listStyles.headerInfo}>
69
-
<div style={listStyles.headerIcon}>
70
-
<BlueskyIcon size={20} />
71
-
</div>
72
-
<div style={listStyles.headerText}>
73
-
<span style={listStyles.title}>Latest Posts</span>
74
-
<span style={{ ...listStyles.subtitle, ...palette.subtitle }}>@{actorLabel}</span>
75
-
</div>
76
-
</div>
77
-
{pageLabel && <span style={{ ...listStyles.pageMeta, ...palette.pageMeta }}>{pageLabel}</span>}
78
-
</div>
79
-
<div style={listStyles.items}>
80
-
{loading && records.length === 0 && <div style={{ ...listStyles.empty, ...palette.empty }}>Loading posts…</div>}
81
-
{records.map((record, idx) => (
82
-
<ListRow
83
-
key={record.rkey}
84
-
record={record.value}
85
-
rkey={record.rkey}
86
-
did={actorPath}
87
-
reason={record.reason}
88
-
replyParent={record.replyParent}
89
-
palette={palette}
90
-
hasDivider={idx < records.length - 1}
91
-
/>
92
-
))}
93
-
{!loading && records.length === 0 && <div style={{ ...listStyles.empty, ...palette.empty }}>No posts found.</div>}
94
-
</div>
95
-
{enablePagination && (
96
-
<div style={{ ...listStyles.footer, ...palette.footer }}>
97
-
<button
98
-
type="button"
99
-
style={{
100
-
...listStyles.navButton,
101
-
...palette.navButton,
102
-
cursor: hasPrev ? 'pointer' : 'not-allowed',
103
-
opacity: hasPrev ? 1 : 0.5
104
-
}}
105
-
onClick={loadPrev}
106
-
disabled={!hasPrev}
107
-
>
108
-
‹ Prev
109
-
</button>
110
-
<div style={listStyles.pageChips}>
111
-
<span style={{ ...listStyles.pageChipActive, ...palette.pageChipActive }}>{pageIndex + 1}</span>
112
-
{(hasNext || pagesCount > pageIndex + 1) && (
113
-
<span style={{ ...listStyles.pageChip, ...palette.pageChip }}>{pageIndex + 2}</span>
114
-
)}
115
-
</div>
116
-
<button
117
-
type="button"
118
-
style={{
119
-
...listStyles.navButton,
120
-
...palette.navButton,
121
-
cursor: hasNext ? 'pointer' : 'not-allowed',
122
-
opacity: hasNext ? 1 : 0.5
123
-
}}
124
-
onClick={loadNext}
125
-
disabled={!hasNext}
126
-
>
127
-
Next ›
128
-
</button>
129
-
</div>
130
-
)}
131
-
{loading && records.length > 0 && <div style={{ ...listStyles.loadingBar, ...palette.loadingBar }}>Updating…</div>}
132
-
</div>
133
-
);
90
+
return (
91
+
<div style={{ ...listStyles.card, ...palette.card }}>
92
+
<div style={{ ...listStyles.header, ...palette.header }}>
93
+
<div style={listStyles.headerInfo}>
94
+
<div style={listStyles.headerIcon}>
95
+
<BlueskyIcon size={20} />
96
+
</div>
97
+
<div style={listStyles.headerText}>
98
+
<span style={listStyles.title}>Latest Posts</span>
99
+
<span
100
+
style={{
101
+
...listStyles.subtitle,
102
+
...palette.subtitle,
103
+
}}
104
+
>
105
+
@{actorLabel}
106
+
</span>
107
+
</div>
108
+
</div>
109
+
{pageLabel && (
110
+
<span
111
+
style={{ ...listStyles.pageMeta, ...palette.pageMeta }}
112
+
>
113
+
{pageLabel}
114
+
</span>
115
+
)}
116
+
</div>
117
+
<div style={listStyles.items}>
118
+
{loading && records.length === 0 && (
119
+
<div style={{ ...listStyles.empty, ...palette.empty }}>
120
+
Loading posts…
121
+
</div>
122
+
)}
123
+
{records.map((record, idx) => (
124
+
<ListRow
125
+
key={record.rkey}
126
+
record={record.value}
127
+
rkey={record.rkey}
128
+
did={actorPath}
129
+
reason={record.reason}
130
+
replyParent={record.replyParent}
131
+
palette={palette}
132
+
hasDivider={idx < records.length - 1}
133
+
/>
134
+
))}
135
+
{!loading && records.length === 0 && (
136
+
<div style={{ ...listStyles.empty, ...palette.empty }}>
137
+
No posts found.
138
+
</div>
139
+
)}
140
+
</div>
141
+
{enablePagination && (
142
+
<div style={{ ...listStyles.footer, ...palette.footer }}>
143
+
<button
144
+
type="button"
145
+
style={{
146
+
...listStyles.navButton,
147
+
...palette.navButton,
148
+
cursor: hasPrev ? "pointer" : "not-allowed",
149
+
opacity: hasPrev ? 1 : 0.5,
150
+
}}
151
+
onClick={loadPrev}
152
+
disabled={!hasPrev}
153
+
>
154
+
‹ Prev
155
+
</button>
156
+
<div style={listStyles.pageChips}>
157
+
<span
158
+
style={{
159
+
...listStyles.pageChipActive,
160
+
...palette.pageChipActive,
161
+
}}
162
+
>
163
+
{pageIndex + 1}
164
+
</span>
165
+
{(hasNext || pagesCount > pageIndex + 1) && (
166
+
<span
167
+
style={{
168
+
...listStyles.pageChip,
169
+
...palette.pageChip,
170
+
}}
171
+
>
172
+
{pageIndex + 2}
173
+
</span>
174
+
)}
175
+
</div>
176
+
<button
177
+
type="button"
178
+
style={{
179
+
...listStyles.navButton,
180
+
...palette.navButton,
181
+
cursor: hasNext ? "pointer" : "not-allowed",
182
+
opacity: hasNext ? 1 : 0.5,
183
+
}}
184
+
onClick={loadNext}
185
+
disabled={!hasNext}
186
+
>
187
+
Next ›
188
+
</button>
189
+
</div>
190
+
)}
191
+
{loading && records.length > 0 && (
192
+
<div
193
+
style={{ ...listStyles.loadingBar, ...palette.loadingBar }}
194
+
>
195
+
Updating…
196
+
</div>
197
+
)}
198
+
</div>
199
+
);
134
200
};
135
201
136
202
interface ListRowProps {
137
-
record: FeedPostRecord;
138
-
rkey: string;
139
-
did: string;
140
-
reason?: AuthorFeedReason;
141
-
replyParent?: ReplyParentInfo;
142
-
palette: ListPalette;
143
-
hasDivider: boolean;
203
+
record: FeedPostRecord;
204
+
rkey: string;
205
+
did: string;
206
+
reason?: AuthorFeedReason;
207
+
replyParent?: ReplyParentInfo;
208
+
palette: ListPalette;
209
+
hasDivider: boolean;
144
210
}
145
211
146
-
const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, reason, replyParent, palette, hasDivider }) => {
147
-
const text = record.text?.trim() ?? '';
148
-
const relative = record.createdAt ? formatRelativeTime(record.createdAt) : undefined;
149
-
const absolute = record.createdAt ? new Date(record.createdAt).toLocaleString() : undefined;
150
-
const href = `https://bsky.app/profile/${did}/post/${rkey}`;
151
-
const repostLabel = reason?.$type === 'app.bsky.feed.defs#reasonRepost'
152
-
? `${formatActor(reason.by) ?? 'Someone'} reposted`
153
-
: undefined;
154
-
const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
155
-
const parentDid = replyParent?.author?.did ?? (parentUri ? parseAtUri(parentUri)?.did : undefined);
156
-
const { handle: resolvedReplyHandle } = useDidResolution(
157
-
replyParent?.author?.handle ? undefined : parentDid
158
-
);
159
-
const replyLabel = formatReplyTarget(parentUri, replyParent, resolvedReplyHandle);
212
+
const ListRow: React.FC<ListRowProps> = ({
213
+
record,
214
+
rkey,
215
+
did,
216
+
reason,
217
+
replyParent,
218
+
palette,
219
+
hasDivider,
220
+
}) => {
221
+
const text = record.text?.trim() ?? "";
222
+
const relative = record.createdAt
223
+
? formatRelativeTime(record.createdAt)
224
+
: undefined;
225
+
const absolute = record.createdAt
226
+
? new Date(record.createdAt).toLocaleString()
227
+
: undefined;
228
+
const href = `https://bsky.app/profile/${did}/post/${rkey}`;
229
+
const repostLabel =
230
+
reason?.$type === "app.bsky.feed.defs#reasonRepost"
231
+
? `${formatActor(reason.by) ?? "Someone"} reposted`
232
+
: undefined;
233
+
const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
234
+
const parentDid =
235
+
replyParent?.author?.did ??
236
+
(parentUri ? parseAtUri(parentUri)?.did : undefined);
237
+
const { handle: resolvedReplyHandle } = useDidResolution(
238
+
replyParent?.author?.handle ? undefined : parentDid,
239
+
);
240
+
const replyLabel = formatReplyTarget(
241
+
parentUri,
242
+
replyParent,
243
+
resolvedReplyHandle,
244
+
);
160
245
161
-
return (
162
-
<a href={href} target="_blank" rel="noopener noreferrer" style={{ ...listStyles.row, ...palette.row, borderBottom: hasDivider ? `1px solid ${palette.divider}` : 'none' }}>
163
-
{repostLabel && <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>{repostLabel}</span>}
164
-
{replyLabel && <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>{replyLabel}</span>}
165
-
{relative && (
166
-
<span style={{ ...listStyles.rowTime, ...palette.rowTime }} title={absolute}>
167
-
{relative}
168
-
</span>
169
-
)}
170
-
{text && <p style={{ ...listStyles.rowBody, ...palette.rowBody }}>{text}</p>}
171
-
{!text && <p style={{ ...listStyles.rowBody, ...palette.rowBody, fontStyle: 'italic' }}>No text content.</p>}
172
-
</a>
173
-
);
246
+
return (
247
+
<a
248
+
href={href}
249
+
target="_blank"
250
+
rel="noopener noreferrer"
251
+
style={{
252
+
...listStyles.row,
253
+
...palette.row,
254
+
borderBottom: hasDivider
255
+
? `1px solid ${palette.divider}`
256
+
: "none",
257
+
}}
258
+
>
259
+
{repostLabel && (
260
+
<span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>
261
+
{repostLabel}
262
+
</span>
263
+
)}
264
+
{replyLabel && (
265
+
<span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>
266
+
{replyLabel}
267
+
</span>
268
+
)}
269
+
{relative && (
270
+
<span
271
+
style={{ ...listStyles.rowTime, ...palette.rowTime }}
272
+
title={absolute}
273
+
>
274
+
{relative}
275
+
</span>
276
+
)}
277
+
{text && (
278
+
<p style={{ ...listStyles.rowBody, ...palette.rowBody }}>
279
+
{text}
280
+
</p>
281
+
)}
282
+
{!text && (
283
+
<p
284
+
style={{
285
+
...listStyles.rowBody,
286
+
...palette.rowBody,
287
+
fontStyle: "italic",
288
+
}}
289
+
>
290
+
No text content.
291
+
</p>
292
+
)}
293
+
</a>
294
+
);
174
295
};
175
296
176
297
function formatDid(did: string) {
177
-
return did.replace(/^did:(plc:)?/, '');
298
+
return did.replace(/^did:(plc:)?/, "");
178
299
}
179
300
180
301
function formatRelativeTime(iso: string): string {
181
-
const date = new Date(iso);
182
-
const diffSeconds = (date.getTime() - Date.now()) / 1000;
183
-
const absSeconds = Math.abs(diffSeconds);
184
-
const thresholds: Array<{ limit: number; unit: Intl.RelativeTimeFormatUnit; divisor: number }> = [
185
-
{ limit: 60, unit: 'second', divisor: 1 },
186
-
{ limit: 3600, unit: 'minute', divisor: 60 },
187
-
{ limit: 86400, unit: 'hour', divisor: 3600 },
188
-
{ limit: 604800, unit: 'day', divisor: 86400 },
189
-
{ limit: 2629800, unit: 'week', divisor: 604800 },
190
-
{ limit: 31557600, unit: 'month', divisor: 2629800 },
191
-
{ limit: Infinity, unit: 'year', divisor: 31557600 }
192
-
];
193
-
const threshold = thresholds.find(t => absSeconds < t.limit) ?? thresholds[thresholds.length - 1];
194
-
const value = diffSeconds / threshold.divisor;
195
-
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
196
-
return rtf.format(Math.round(value), threshold.unit);
302
+
const date = new Date(iso);
303
+
const diffSeconds = (date.getTime() - Date.now()) / 1000;
304
+
const absSeconds = Math.abs(diffSeconds);
305
+
const thresholds: Array<{
306
+
limit: number;
307
+
unit: Intl.RelativeTimeFormatUnit;
308
+
divisor: number;
309
+
}> = [
310
+
{ limit: 60, unit: "second", divisor: 1 },
311
+
{ limit: 3600, unit: "minute", divisor: 60 },
312
+
{ limit: 86400, unit: "hour", divisor: 3600 },
313
+
{ limit: 604800, unit: "day", divisor: 86400 },
314
+
{ limit: 2629800, unit: "week", divisor: 604800 },
315
+
{ limit: 31557600, unit: "month", divisor: 2629800 },
316
+
{ limit: Infinity, unit: "year", divisor: 31557600 },
317
+
];
318
+
const threshold =
319
+
thresholds.find((t) => absSeconds < t.limit) ??
320
+
thresholds[thresholds.length - 1];
321
+
const value = diffSeconds / threshold.divisor;
322
+
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
323
+
return rtf.format(Math.round(value), threshold.unit);
197
324
}
198
325
199
326
interface ListPalette {
200
-
card: { background: string; borderColor: string };
201
-
header: { borderBottomColor: string; color: string };
202
-
pageMeta: { color: string };
203
-
subtitle: { color: string };
204
-
empty: { color: string };
205
-
row: { color: string };
206
-
rowTime: { color: string };
207
-
rowBody: { color: string };
208
-
rowMeta: { color: string };
209
-
divider: string;
210
-
footer: { borderTopColor: string; color: string };
211
-
navButton: { color: string; background: string };
212
-
pageChip: { color: string; borderColor: string; background: string };
213
-
pageChipActive: { color: string; background: string; borderColor: string };
214
-
loadingBar: { color: string };
327
+
card: { background: string; borderColor: string };
328
+
header: { borderBottomColor: string; color: string };
329
+
pageMeta: { color: string };
330
+
subtitle: { color: string };
331
+
empty: { color: string };
332
+
row: { color: string };
333
+
rowTime: { color: string };
334
+
rowBody: { color: string };
335
+
rowMeta: { color: string };
336
+
divider: string;
337
+
footer: { borderTopColor: string; color: string };
338
+
navButton: { color: string; background: string };
339
+
pageChip: { color: string; borderColor: string; background: string };
340
+
pageChipActive: { color: string; background: string; borderColor: string };
341
+
loadingBar: { color: string };
215
342
}
216
343
217
344
const listStyles = {
218
-
card: {
219
-
borderRadius: 16,
220
-
border: '1px solid transparent',
221
-
boxShadow: '0 8px 18px -12px rgba(15, 23, 42, 0.25)',
222
-
overflow: 'hidden',
223
-
display: 'flex',
224
-
flexDirection: 'column'
225
-
} satisfies React.CSSProperties,
226
-
header: {
227
-
display: 'flex',
228
-
alignItems: 'center',
229
-
justifyContent: 'space-between',
230
-
padding: '14px 18px',
231
-
fontSize: 14,
232
-
fontWeight: 500,
233
-
borderBottom: '1px solid transparent'
234
-
} satisfies React.CSSProperties,
235
-
headerInfo: {
236
-
display: 'flex',
237
-
alignItems: 'center',
238
-
gap: 12
239
-
} satisfies React.CSSProperties,
240
-
headerIcon: {
241
-
width: 28,
242
-
height: 28,
243
-
display: 'flex',
244
-
alignItems: 'center',
245
-
justifyContent: 'center',
246
-
//background: 'rgba(17, 133, 254, 0.14)',
247
-
borderRadius: '50%'
248
-
} satisfies React.CSSProperties,
249
-
headerText: {
250
-
display: 'flex',
251
-
flexDirection: 'column',
252
-
gap: 2
253
-
} satisfies React.CSSProperties,
254
-
title: {
255
-
fontSize: 15,
256
-
fontWeight: 600
257
-
} satisfies React.CSSProperties,
258
-
subtitle: {
259
-
fontSize: 12,
260
-
fontWeight: 500
261
-
} satisfies React.CSSProperties,
262
-
pageMeta: {
263
-
fontSize: 12
264
-
} satisfies React.CSSProperties,
265
-
items: {
266
-
display: 'flex',
267
-
flexDirection: 'column'
268
-
} satisfies React.CSSProperties,
269
-
empty: {
270
-
padding: '24px 18px',
271
-
fontSize: 13,
272
-
textAlign: 'center'
273
-
} satisfies React.CSSProperties,
274
-
row: {
275
-
padding: '18px',
276
-
textDecoration: 'none',
277
-
display: 'flex',
278
-
flexDirection: 'column',
279
-
gap: 6,
280
-
transition: 'background-color 120ms ease'
281
-
} satisfies React.CSSProperties,
282
-
rowHeader: {
283
-
display: 'flex',
284
-
gap: 6,
285
-
alignItems: 'baseline',
286
-
fontSize: 13
287
-
} satisfies React.CSSProperties,
288
-
rowTime: {
289
-
fontSize: 12,
290
-
fontWeight: 500
291
-
} satisfies React.CSSProperties,
292
-
rowMeta: {
293
-
fontSize: 12,
294
-
fontWeight: 500,
295
-
letterSpacing: '0.6px'
296
-
} satisfies React.CSSProperties,
297
-
rowBody: {
298
-
margin: 0,
299
-
whiteSpace: 'pre-wrap',
300
-
fontSize: 14,
301
-
lineHeight: 1.45
302
-
} satisfies React.CSSProperties,
303
-
footer: {
304
-
display: 'flex',
305
-
alignItems: 'center',
306
-
justifyContent: 'space-between',
307
-
padding: '12px 18px',
308
-
borderTop: '1px solid transparent',
309
-
fontSize: 13
310
-
} satisfies React.CSSProperties,
311
-
navButton: {
312
-
border: 'none',
313
-
borderRadius: 999,
314
-
padding: '6px 12px',
315
-
fontSize: 13,
316
-
fontWeight: 500,
317
-
background: 'transparent',
318
-
display: 'flex',
319
-
alignItems: 'center',
320
-
gap: 4,
321
-
transition: 'background-color 120ms ease'
322
-
} satisfies React.CSSProperties,
323
-
pageChips: {
324
-
display: 'flex',
325
-
gap: 6,
326
-
alignItems: 'center'
327
-
} satisfies React.CSSProperties,
328
-
pageChip: {
329
-
padding: '4px 10px',
330
-
borderRadius: 999,
331
-
fontSize: 13,
332
-
border: '1px solid transparent'
333
-
} satisfies React.CSSProperties,
334
-
pageChipActive: {
335
-
padding: '4px 10px',
336
-
borderRadius: 999,
337
-
fontSize: 13,
338
-
fontWeight: 600,
339
-
border: '1px solid transparent'
340
-
} satisfies React.CSSProperties,
341
-
loadingBar: {
342
-
padding: '4px 18px 14px',
343
-
fontSize: 12,
344
-
textAlign: 'right',
345
-
color: '#64748b'
346
-
} satisfies React.CSSProperties
345
+
card: {
346
+
borderRadius: 16,
347
+
border: "1px solid transparent",
348
+
boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
349
+
overflow: "hidden",
350
+
display: "flex",
351
+
flexDirection: "column",
352
+
} satisfies React.CSSProperties,
353
+
header: {
354
+
display: "flex",
355
+
alignItems: "center",
356
+
justifyContent: "space-between",
357
+
padding: "14px 18px",
358
+
fontSize: 14,
359
+
fontWeight: 500,
360
+
borderBottom: "1px solid transparent",
361
+
} satisfies React.CSSProperties,
362
+
headerInfo: {
363
+
display: "flex",
364
+
alignItems: "center",
365
+
gap: 12,
366
+
} satisfies React.CSSProperties,
367
+
headerIcon: {
368
+
width: 28,
369
+
height: 28,
370
+
display: "flex",
371
+
alignItems: "center",
372
+
justifyContent: "center",
373
+
//background: 'rgba(17, 133, 254, 0.14)',
374
+
borderRadius: "50%",
375
+
} satisfies React.CSSProperties,
376
+
headerText: {
377
+
display: "flex",
378
+
flexDirection: "column",
379
+
gap: 2,
380
+
} satisfies React.CSSProperties,
381
+
title: {
382
+
fontSize: 15,
383
+
fontWeight: 600,
384
+
} satisfies React.CSSProperties,
385
+
subtitle: {
386
+
fontSize: 12,
387
+
fontWeight: 500,
388
+
} satisfies React.CSSProperties,
389
+
pageMeta: {
390
+
fontSize: 12,
391
+
} satisfies React.CSSProperties,
392
+
items: {
393
+
display: "flex",
394
+
flexDirection: "column",
395
+
} satisfies React.CSSProperties,
396
+
empty: {
397
+
padding: "24px 18px",
398
+
fontSize: 13,
399
+
textAlign: "center",
400
+
} satisfies React.CSSProperties,
401
+
row: {
402
+
padding: "18px",
403
+
textDecoration: "none",
404
+
display: "flex",
405
+
flexDirection: "column",
406
+
gap: 6,
407
+
transition: "background-color 120ms ease",
408
+
} satisfies React.CSSProperties,
409
+
rowHeader: {
410
+
display: "flex",
411
+
gap: 6,
412
+
alignItems: "baseline",
413
+
fontSize: 13,
414
+
} satisfies React.CSSProperties,
415
+
rowTime: {
416
+
fontSize: 12,
417
+
fontWeight: 500,
418
+
} satisfies React.CSSProperties,
419
+
rowMeta: {
420
+
fontSize: 12,
421
+
fontWeight: 500,
422
+
letterSpacing: "0.6px",
423
+
} satisfies React.CSSProperties,
424
+
rowBody: {
425
+
margin: 0,
426
+
whiteSpace: "pre-wrap",
427
+
fontSize: 14,
428
+
lineHeight: 1.45,
429
+
} satisfies React.CSSProperties,
430
+
footer: {
431
+
display: "flex",
432
+
alignItems: "center",
433
+
justifyContent: "space-between",
434
+
padding: "12px 18px",
435
+
borderTop: "1px solid transparent",
436
+
fontSize: 13,
437
+
} satisfies React.CSSProperties,
438
+
navButton: {
439
+
border: "none",
440
+
borderRadius: 999,
441
+
padding: "6px 12px",
442
+
fontSize: 13,
443
+
fontWeight: 500,
444
+
background: "transparent",
445
+
display: "flex",
446
+
alignItems: "center",
447
+
gap: 4,
448
+
transition: "background-color 120ms ease",
449
+
} satisfies React.CSSProperties,
450
+
pageChips: {
451
+
display: "flex",
452
+
gap: 6,
453
+
alignItems: "center",
454
+
} satisfies React.CSSProperties,
455
+
pageChip: {
456
+
padding: "4px 10px",
457
+
borderRadius: 999,
458
+
fontSize: 13,
459
+
border: "1px solid transparent",
460
+
} satisfies React.CSSProperties,
461
+
pageChipActive: {
462
+
padding: "4px 10px",
463
+
borderRadius: 999,
464
+
fontSize: 13,
465
+
fontWeight: 600,
466
+
border: "1px solid transparent",
467
+
} satisfies React.CSSProperties,
468
+
loadingBar: {
469
+
padding: "4px 18px 14px",
470
+
fontSize: 12,
471
+
textAlign: "right",
472
+
color: "#64748b",
473
+
} satisfies React.CSSProperties,
347
474
};
348
475
349
476
const lightPalette: ListPalette = {
350
-
card: {
351
-
background: '#ffffff',
352
-
borderColor: '#e2e8f0'
353
-
},
354
-
header: {
355
-
borderBottomColor: '#e2e8f0',
356
-
color: '#0f172a'
357
-
},
358
-
pageMeta: {
359
-
color: '#64748b'
360
-
},
361
-
subtitle: {
362
-
color: '#475569'
363
-
},
364
-
empty: {
365
-
color: '#64748b'
366
-
},
367
-
row: {
368
-
color: '#0f172a'
369
-
},
370
-
rowTime: {
371
-
color: '#94a3b8'
372
-
},
373
-
rowBody: {
374
-
color: '#0f172a'
375
-
},
376
-
rowMeta: {
377
-
color: '#64748b'
378
-
},
379
-
divider: '#e2e8f0',
380
-
footer: {
381
-
borderTopColor: '#e2e8f0',
382
-
color: '#0f172a'
383
-
},
384
-
navButton: {
385
-
color: '#0f172a',
386
-
background: '#f1f5f9'
387
-
},
388
-
pageChip: {
389
-
color: '#475569',
390
-
borderColor: '#e2e8f0',
391
-
background: '#ffffff'
392
-
},
393
-
pageChipActive: {
394
-
color: '#ffffff',
395
-
background: '#0f172a',
396
-
borderColor: '#0f172a'
397
-
},
398
-
loadingBar: {
399
-
color: '#64748b'
400
-
}
477
+
card: {
478
+
background: "#ffffff",
479
+
borderColor: "#e2e8f0",
480
+
},
481
+
header: {
482
+
borderBottomColor: "#e2e8f0",
483
+
color: "#0f172a",
484
+
},
485
+
pageMeta: {
486
+
color: "#64748b",
487
+
},
488
+
subtitle: {
489
+
color: "#475569",
490
+
},
491
+
empty: {
492
+
color: "#64748b",
493
+
},
494
+
row: {
495
+
color: "#0f172a",
496
+
},
497
+
rowTime: {
498
+
color: "#94a3b8",
499
+
},
500
+
rowBody: {
501
+
color: "#0f172a",
502
+
},
503
+
rowMeta: {
504
+
color: "#64748b",
505
+
},
506
+
divider: "#e2e8f0",
507
+
footer: {
508
+
borderTopColor: "#e2e8f0",
509
+
color: "#0f172a",
510
+
},
511
+
navButton: {
512
+
color: "#0f172a",
513
+
background: "#f1f5f9",
514
+
},
515
+
pageChip: {
516
+
color: "#475569",
517
+
borderColor: "#e2e8f0",
518
+
background: "#ffffff",
519
+
},
520
+
pageChipActive: {
521
+
color: "#ffffff",
522
+
background: "#0f172a",
523
+
borderColor: "#0f172a",
524
+
},
525
+
loadingBar: {
526
+
color: "#64748b",
527
+
},
401
528
};
402
529
403
530
const darkPalette: ListPalette = {
404
-
card: {
405
-
background: '#0f172a',
406
-
borderColor: '#1e293b'
407
-
},
408
-
header: {
409
-
borderBottomColor: '#1e293b',
410
-
color: '#e2e8f0'
411
-
},
412
-
pageMeta: {
413
-
color: '#94a3b8'
414
-
},
415
-
subtitle: {
416
-
color: '#94a3b8'
417
-
},
418
-
empty: {
419
-
color: '#94a3b8'
420
-
},
421
-
row: {
422
-
color: '#e2e8f0'
423
-
},
424
-
rowTime: {
425
-
color: '#94a3b8'
426
-
},
427
-
rowBody: {
428
-
color: '#e2e8f0'
429
-
},
430
-
rowMeta: {
431
-
color: '#94a3b8'
432
-
},
433
-
divider: '#1e293b',
434
-
footer: {
435
-
borderTopColor: '#1e293b',
436
-
color: '#e2e8f0'
437
-
},
438
-
navButton: {
439
-
color: '#e2e8f0',
440
-
background: '#111c31'
441
-
},
442
-
pageChip: {
443
-
color: '#cbd5f5',
444
-
borderColor: '#1e293b',
445
-
background: '#0f172a'
446
-
},
447
-
pageChipActive: {
448
-
color: '#0f172a',
449
-
background: '#38bdf8',
450
-
borderColor: '#38bdf8'
451
-
},
452
-
loadingBar: {
453
-
color: '#94a3b8'
454
-
}
531
+
card: {
532
+
background: "#0f172a",
533
+
borderColor: "#1e293b",
534
+
},
535
+
header: {
536
+
borderBottomColor: "#1e293b",
537
+
color: "#e2e8f0",
538
+
},
539
+
pageMeta: {
540
+
color: "#94a3b8",
541
+
},
542
+
subtitle: {
543
+
color: "#94a3b8",
544
+
},
545
+
empty: {
546
+
color: "#94a3b8",
547
+
},
548
+
row: {
549
+
color: "#e2e8f0",
550
+
},
551
+
rowTime: {
552
+
color: "#94a3b8",
553
+
},
554
+
rowBody: {
555
+
color: "#e2e8f0",
556
+
},
557
+
rowMeta: {
558
+
color: "#94a3b8",
559
+
},
560
+
divider: "#1e293b",
561
+
footer: {
562
+
borderTopColor: "#1e293b",
563
+
color: "#e2e8f0",
564
+
},
565
+
navButton: {
566
+
color: "#e2e8f0",
567
+
background: "#111c31",
568
+
},
569
+
pageChip: {
570
+
color: "#cbd5f5",
571
+
borderColor: "#1e293b",
572
+
background: "#0f172a",
573
+
},
574
+
pageChipActive: {
575
+
color: "#0f172a",
576
+
background: "#38bdf8",
577
+
borderColor: "#38bdf8",
578
+
},
579
+
loadingBar: {
580
+
color: "#94a3b8",
581
+
},
455
582
};
456
583
457
584
export default BlueskyPostList;
458
585
459
586
function formatActor(actor?: { handle?: string; did?: string }) {
460
-
if (!actor) return undefined;
461
-
if (actor.handle) return `@${actor.handle}`;
462
-
if (actor.did) return `@${formatDid(actor.did)}`;
463
-
return undefined;
587
+
if (!actor) return undefined;
588
+
if (actor.handle) return `@${actor.handle}`;
589
+
if (actor.did) return `@${formatDid(actor.did)}`;
590
+
return undefined;
464
591
}
465
592
466
-
function formatReplyTarget(parentUri?: string, feedParent?: ReplyParentInfo, resolvedHandle?: string) {
467
-
const directHandle = feedParent?.author?.handle;
468
-
const handle = directHandle ?? resolvedHandle;
469
-
if (handle) {
470
-
return `Replying to @${handle}`;
471
-
}
472
-
const parentDid = feedParent?.author?.did;
473
-
const targetUri = feedParent?.uri ?? parentUri;
474
-
if (!targetUri) return undefined;
475
-
const parsed = parseAtUri(targetUri);
476
-
const did = parentDid ?? parsed?.did;
477
-
if (!did) return undefined;
478
-
return `Replying to @${formatDid(did)}`;
593
+
function formatReplyTarget(
594
+
parentUri?: string,
595
+
feedParent?: ReplyParentInfo,
596
+
resolvedHandle?: string,
597
+
) {
598
+
const directHandle = feedParent?.author?.handle;
599
+
const handle = directHandle ?? resolvedHandle;
600
+
if (handle) {
601
+
return `Replying to @${handle}`;
602
+
}
603
+
const parentDid = feedParent?.author?.did;
604
+
const targetUri = feedParent?.uri ?? parentUri;
605
+
if (!targetUri) return undefined;
606
+
const parsed = parseAtUri(targetUri);
607
+
const did = parentDid ?? parsed?.did;
608
+
if (!did) return undefined;
609
+
return `Replying to @${formatDid(did)}`;
479
610
}
+112
-86
lib/components/BlueskyProfile.tsx
+112
-86
lib/components/BlueskyProfile.tsx
···
1
-
import React from 'react';
2
-
import { AtProtoRecord } from '../core/AtProtoRecord';
3
-
import { BlueskyProfileRenderer } from '../renderers/BlueskyProfileRenderer';
4
-
import type { ProfileRecord } from '../types/bluesky';
5
-
import { useBlob } from '../hooks/useBlob';
6
-
import { getAvatarCid } from '../utils/profile';
7
-
import { useDidResolution } from '../hooks/useDidResolution';
8
-
import { formatDidForLabel } from '../utils/at-uri';
1
+
import React from "react";
2
+
import { AtProtoRecord } from "../core/AtProtoRecord";
3
+
import { BlueskyProfileRenderer } from "../renderers/BlueskyProfileRenderer";
4
+
import type { ProfileRecord } from "../types/bluesky";
5
+
import { useBlob } from "../hooks/useBlob";
6
+
import { getAvatarCid } from "../utils/profile";
7
+
import { useDidResolution } from "../hooks/useDidResolution";
8
+
import { formatDidForLabel } from "../utils/at-uri";
9
9
10
10
/**
11
11
* Props used to render a Bluesky actor profile record.
12
12
*/
13
13
export interface BlueskyProfileProps {
14
-
/**
15
-
* DID of the target actor whose profile should be loaded.
16
-
*/
17
-
did: string;
18
-
/**
19
-
* Record key within the profile collection. Typically `'self'`.
20
-
*/
21
-
rkey?: string;
22
-
/**
23
-
* Optional renderer override for custom presentation.
24
-
*/
25
-
renderer?: React.ComponentType<BlueskyProfileRendererInjectedProps>;
26
-
/**
27
-
* Fallback node shown before a request begins yielding data.
28
-
*/
29
-
fallback?: React.ReactNode;
30
-
/**
31
-
* Loading indicator shown during in-flight fetches.
32
-
*/
33
-
loadingIndicator?: React.ReactNode;
34
-
/**
35
-
* Pre-resolved handle to display when available externally.
36
-
*/
37
-
handle?: string;
38
-
/**
39
-
* Preferred color scheme forwarded to renderer implementations.
40
-
*/
41
-
colorScheme?: 'light' | 'dark' | 'system';
14
+
/**
15
+
* DID of the target actor whose profile should be loaded.
16
+
*/
17
+
did: string;
18
+
/**
19
+
* Record key within the profile collection. Typically `'self'`.
20
+
*/
21
+
rkey?: string;
22
+
/**
23
+
* Optional renderer override for custom presentation.
24
+
*/
25
+
renderer?: React.ComponentType<BlueskyProfileRendererInjectedProps>;
26
+
/**
27
+
* Fallback node shown before a request begins yielding data.
28
+
*/
29
+
fallback?: React.ReactNode;
30
+
/**
31
+
* Loading indicator shown during in-flight fetches.
32
+
*/
33
+
loadingIndicator?: React.ReactNode;
34
+
/**
35
+
* Pre-resolved handle to display when available externally.
36
+
*/
37
+
handle?: string;
38
+
/**
39
+
* Preferred color scheme forwarded to renderer implementations.
40
+
*/
41
+
colorScheme?: "light" | "dark" | "system";
42
42
}
43
43
44
44
/**
45
45
* Props injected into custom profile renderer implementations.
46
46
*/
47
47
export type BlueskyProfileRendererInjectedProps = {
48
-
/**
49
-
* Loaded profile record value.
50
-
*/
51
-
record: ProfileRecord;
52
-
/**
53
-
* Indicates whether the record is currently being fetched.
54
-
*/
55
-
loading: boolean;
56
-
/**
57
-
* Any error encountered while fetching the profile.
58
-
*/
59
-
error?: Error;
60
-
/**
61
-
* DID associated with the profile.
62
-
*/
63
-
did: string;
64
-
/**
65
-
* Human-readable handle for the DID, when known.
66
-
*/
67
-
handle?: string;
68
-
/**
69
-
* Blob URL for the user's avatar, when available.
70
-
*/
71
-
avatarUrl?: string;
72
-
/**
73
-
* Preferred color scheme for theming downstream components.
74
-
*/
75
-
colorScheme?: 'light' | 'dark' | 'system';
48
+
/**
49
+
* Loaded profile record value.
50
+
*/
51
+
record: ProfileRecord;
52
+
/**
53
+
* Indicates whether the record is currently being fetched.
54
+
*/
55
+
loading: boolean;
56
+
/**
57
+
* Any error encountered while fetching the profile.
58
+
*/
59
+
error?: Error;
60
+
/**
61
+
* DID associated with the profile.
62
+
*/
63
+
did: string;
64
+
/**
65
+
* Human-readable handle for the DID, when known.
66
+
*/
67
+
handle?: string;
68
+
/**
69
+
* Blob URL for the user's avatar, when available.
70
+
*/
71
+
avatarUrl?: string;
72
+
/**
73
+
* Preferred color scheme for theming downstream components.
74
+
*/
75
+
colorScheme?: "light" | "dark" | "system";
76
76
};
77
77
78
78
/** NSID for the canonical Bluesky profile collection. */
79
-
export const BLUESKY_PROFILE_COLLECTION = 'app.bsky.actor.profile';
79
+
export const BLUESKY_PROFILE_COLLECTION = "app.bsky.actor.profile";
80
80
81
81
/**
82
82
* Fetches and renders a Bluesky actor profile, optionally injecting custom presentation
···
91
91
* @param colorScheme - Preferred color scheme forwarded to the renderer.
92
92
* @returns A rendered profile component with loading/error states handled.
93
93
*/
94
-
export const BlueskyProfile: React.FC<BlueskyProfileProps> = ({ did: handleOrDid, rkey = 'self', renderer, fallback, loadingIndicator, handle, colorScheme }) => {
95
-
const Component: React.ComponentType<BlueskyProfileRendererInjectedProps> = renderer ?? ((props) => <BlueskyProfileRenderer {...props} />);
96
-
const { did, handle: resolvedHandle } = useDidResolution(handleOrDid);
97
-
const repoIdentifier = did ?? handleOrDid;
98
-
const effectiveHandle = handle ?? resolvedHandle ?? (handleOrDid.startsWith('did:') ? formatDidForLabel(repoIdentifier) : handleOrDid);
94
+
export const BlueskyProfile: React.FC<BlueskyProfileProps> = ({
95
+
did: handleOrDid,
96
+
rkey = "self",
97
+
renderer,
98
+
fallback,
99
+
loadingIndicator,
100
+
handle,
101
+
colorScheme,
102
+
}) => {
103
+
const Component: React.ComponentType<BlueskyProfileRendererInjectedProps> =
104
+
renderer ?? ((props) => <BlueskyProfileRenderer {...props} />);
105
+
const { did, handle: resolvedHandle } = useDidResolution(handleOrDid);
106
+
const repoIdentifier = did ?? handleOrDid;
107
+
const effectiveHandle =
108
+
handle ??
109
+
resolvedHandle ??
110
+
(handleOrDid.startsWith("did:")
111
+
? formatDidForLabel(repoIdentifier)
112
+
: handleOrDid);
99
113
100
-
const Wrapped: React.FC<{ record: ProfileRecord; loading: boolean; error?: Error }> = (props) => {
101
-
const avatarCid = getAvatarCid(props.record);
102
-
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
103
-
return <Component {...props} did={repoIdentifier} handle={effectiveHandle} avatarUrl={avatarUrl} colorScheme={colorScheme} />;
104
-
};
105
-
return (
106
-
<AtProtoRecord<ProfileRecord>
107
-
did={repoIdentifier}
108
-
collection={BLUESKY_PROFILE_COLLECTION}
109
-
rkey={rkey}
110
-
renderer={Wrapped}
111
-
fallback={fallback}
112
-
loadingIndicator={loadingIndicator}
113
-
/>
114
-
);
114
+
const Wrapped: React.FC<{
115
+
record: ProfileRecord;
116
+
loading: boolean;
117
+
error?: Error;
118
+
}> = (props) => {
119
+
const avatarCid = getAvatarCid(props.record);
120
+
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
121
+
return (
122
+
<Component
123
+
{...props}
124
+
did={repoIdentifier}
125
+
handle={effectiveHandle}
126
+
avatarUrl={avatarUrl}
127
+
colorScheme={colorScheme}
128
+
/>
129
+
);
130
+
};
131
+
return (
132
+
<AtProtoRecord<ProfileRecord>
133
+
did={repoIdentifier}
134
+
collection={BLUESKY_PROFILE_COLLECTION}
135
+
rkey={rkey}
136
+
renderer={Wrapped}
137
+
fallback={fallback}
138
+
loadingIndicator={loadingIndicator}
139
+
/>
140
+
);
115
141
};
116
142
117
-
export default BlueskyProfile;
143
+
export default BlueskyProfile;
+108
-82
lib/components/BlueskyQuotePost.tsx
+108
-82
lib/components/BlueskyQuotePost.tsx
···
1
-
import React, { memo, useMemo, type NamedExoticComponent } from 'react';
2
-
import { BlueskyPost, type BlueskyPostRendererInjectedProps, BLUESKY_POST_COLLECTION } from './BlueskyPost';
3
-
import { BlueskyPostRenderer } from '../renderers/BlueskyPostRenderer';
4
-
import { parseAtUri } from '../utils/at-uri';
1
+
import React, { memo, useMemo, type NamedExoticComponent } from "react";
2
+
import {
3
+
BlueskyPost,
4
+
type BlueskyPostRendererInjectedProps,
5
+
BLUESKY_POST_COLLECTION,
6
+
} from "./BlueskyPost";
7
+
import { BlueskyPostRenderer } from "../renderers/BlueskyPostRenderer";
8
+
import { parseAtUri } from "../utils/at-uri";
5
9
6
10
/**
7
11
* Props for rendering a Bluesky post that quotes another Bluesky post.
8
12
*/
9
13
export interface BlueskyQuotePostProps {
10
-
/**
11
-
* DID of the repository that owns the parent post.
12
-
*/
13
-
did: string;
14
-
/**
15
-
* Record key of the parent post.
16
-
*/
17
-
rkey: string;
18
-
/**
19
-
* Preferred color scheme propagated to nested renders.
20
-
*/
21
-
colorScheme?: 'light' | 'dark' | 'system';
22
-
/**
23
-
* Custom renderer override applied to the parent post.
24
-
*/
25
-
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
26
-
/**
27
-
* Fallback content rendered before any request completes.
28
-
*/
29
-
fallback?: React.ReactNode;
30
-
/**
31
-
* Loading indicator rendered while the parent post is resolving.
32
-
*/
33
-
loadingIndicator?: React.ReactNode;
34
-
/**
35
-
* Controls whether the Bluesky icon is shown. Defaults to `true`.
36
-
*/
37
-
showIcon?: boolean;
38
-
/**
39
-
* Placement for the Bluesky icon. Defaults to `'timestamp'`.
40
-
*/
41
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
14
+
/**
15
+
* DID of the repository that owns the parent post.
16
+
*/
17
+
did: string;
18
+
/**
19
+
* Record key of the parent post.
20
+
*/
21
+
rkey: string;
22
+
/**
23
+
* Preferred color scheme propagated to nested renders.
24
+
*/
25
+
colorScheme?: "light" | "dark" | "system";
26
+
/**
27
+
* Custom renderer override applied to the parent post.
28
+
*/
29
+
renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
30
+
/**
31
+
* Fallback content rendered before any request completes.
32
+
*/
33
+
fallback?: React.ReactNode;
34
+
/**
35
+
* Loading indicator rendered while the parent post is resolving.
36
+
*/
37
+
loadingIndicator?: React.ReactNode;
38
+
/**
39
+
* Controls whether the Bluesky icon is shown. Defaults to `true`.
40
+
*/
41
+
showIcon?: boolean;
42
+
/**
43
+
* Placement for the Bluesky icon. Defaults to `'timestamp'`.
44
+
*/
45
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
42
46
}
43
47
44
48
/**
···
54
58
* @param iconPlacement - Placement location for the icon. Defaults to `'timestamp'`.
55
59
* @returns A `BlueskyPost` element configured with an augmented renderer.
56
60
*/
57
-
const BlueskyQuotePostComponent: React.FC<BlueskyQuotePostProps> = ({ did, rkey, colorScheme, renderer, fallback, loadingIndicator, showIcon = true, iconPlacement = 'timestamp' }) => {
58
-
const BaseRenderer = renderer ?? BlueskyPostRenderer;
59
-
const Renderer = useMemo(() => {
60
-
const QuoteRenderer: React.FC<BlueskyPostRendererInjectedProps> = (props) => {
61
-
const resolvedColorScheme = props.colorScheme ?? colorScheme;
62
-
const embedSource = props.record.embed as QuoteRecordEmbed | undefined;
63
-
const embedNode = useMemo(
64
-
() => createQuoteEmbed(embedSource, resolvedColorScheme),
65
-
[embedSource, resolvedColorScheme]
66
-
);
67
-
return <BaseRenderer {...props} embed={embedNode} />;
68
-
};
69
-
QuoteRenderer.displayName = 'BlueskyQuotePostRenderer';
70
-
const MemoizedQuoteRenderer = memo(QuoteRenderer);
71
-
MemoizedQuoteRenderer.displayName = 'BlueskyQuotePostRenderer';
72
-
return MemoizedQuoteRenderer;
73
-
}, [BaseRenderer, colorScheme]);
61
+
const BlueskyQuotePostComponent: React.FC<BlueskyQuotePostProps> = ({
62
+
did,
63
+
rkey,
64
+
colorScheme,
65
+
renderer,
66
+
fallback,
67
+
loadingIndicator,
68
+
showIcon = true,
69
+
iconPlacement = "timestamp",
70
+
}) => {
71
+
const BaseRenderer = renderer ?? BlueskyPostRenderer;
72
+
const Renderer = useMemo(() => {
73
+
const QuoteRenderer: React.FC<BlueskyPostRendererInjectedProps> = (
74
+
props,
75
+
) => {
76
+
const resolvedColorScheme = props.colorScheme ?? colorScheme;
77
+
const embedSource = props.record.embed as
78
+
| QuoteRecordEmbed
79
+
| undefined;
80
+
const embedNode = useMemo(
81
+
() => createQuoteEmbed(embedSource, resolvedColorScheme),
82
+
[embedSource, resolvedColorScheme],
83
+
);
84
+
return <BaseRenderer {...props} embed={embedNode} />;
85
+
};
86
+
QuoteRenderer.displayName = "BlueskyQuotePostRenderer";
87
+
const MemoizedQuoteRenderer = memo(QuoteRenderer);
88
+
MemoizedQuoteRenderer.displayName = "BlueskyQuotePostRenderer";
89
+
return MemoizedQuoteRenderer;
90
+
}, [BaseRenderer, colorScheme]);
74
91
75
-
return (
76
-
<BlueskyPost
77
-
did={did}
78
-
rkey={rkey}
79
-
colorScheme={colorScheme}
80
-
renderer={Renderer}
81
-
fallback={fallback}
82
-
loadingIndicator={loadingIndicator}
83
-
showIcon={showIcon}
84
-
iconPlacement={iconPlacement}
85
-
/>
86
-
);
92
+
return (
93
+
<BlueskyPost
94
+
did={did}
95
+
rkey={rkey}
96
+
colorScheme={colorScheme}
97
+
renderer={Renderer}
98
+
fallback={fallback}
99
+
loadingIndicator={loadingIndicator}
100
+
showIcon={showIcon}
101
+
iconPlacement={iconPlacement}
102
+
/>
103
+
);
87
104
};
88
105
89
-
BlueskyQuotePostComponent.displayName = 'BlueskyQuotePost';
106
+
BlueskyQuotePostComponent.displayName = "BlueskyQuotePost";
90
107
91
-
export const BlueskyQuotePost: NamedExoticComponent<BlueskyQuotePostProps> = memo(BlueskyQuotePostComponent);
92
-
BlueskyQuotePost.displayName = 'BlueskyQuotePost';
108
+
export const BlueskyQuotePost: NamedExoticComponent<BlueskyQuotePostProps> =
109
+
memo(BlueskyQuotePostComponent);
110
+
BlueskyQuotePost.displayName = "BlueskyQuotePost";
93
111
94
112
/**
95
113
* Builds the quoted post embed node when the parent record contains a record embed.
···
100
118
*/
101
119
type QuoteRecordEmbed = { $type?: string; record?: { uri?: string } };
102
120
103
-
function createQuoteEmbed(embed: QuoteRecordEmbed | undefined, colorScheme?: 'light' | 'dark' | 'system') {
104
-
if (!embed || embed.$type !== 'app.bsky.embed.record') return null;
105
-
const quoted = embed.record;
106
-
const quotedUri = quoted?.uri;
107
-
const parsed = parseAtUri(quotedUri);
108
-
if (!parsed || parsed.collection !== BLUESKY_POST_COLLECTION) return null;
109
-
return (
110
-
<div style={quoteWrapperStyle}>
111
-
<BlueskyPost did={parsed.did} rkey={parsed.rkey} colorScheme={colorScheme} showIcon={false} />
112
-
</div>
113
-
);
121
+
function createQuoteEmbed(
122
+
embed: QuoteRecordEmbed | undefined,
123
+
colorScheme?: "light" | "dark" | "system",
124
+
) {
125
+
if (!embed || embed.$type !== "app.bsky.embed.record") return null;
126
+
const quoted = embed.record;
127
+
const quotedUri = quoted?.uri;
128
+
const parsed = parseAtUri(quotedUri);
129
+
if (!parsed || parsed.collection !== BLUESKY_POST_COLLECTION) return null;
130
+
return (
131
+
<div style={quoteWrapperStyle}>
132
+
<BlueskyPost
133
+
did={parsed.did}
134
+
rkey={parsed.rkey}
135
+
colorScheme={colorScheme}
136
+
showIcon={false}
137
+
/>
138
+
</div>
139
+
);
114
140
}
115
141
116
142
const quoteWrapperStyle: React.CSSProperties = {
117
-
display: 'flex',
118
-
flexDirection: 'column',
119
-
gap: 8
143
+
display: "flex",
144
+
flexDirection: "column",
145
+
gap: 8,
120
146
};
121
147
122
148
export default BlueskyQuotePost;
+96
-83
lib/components/ColorSchemeToggle.tsx
+96
-83
lib/components/ColorSchemeToggle.tsx
···
1
-
import React from 'react';
2
-
import type { ColorSchemePreference } from '../hooks/useColorScheme';
1
+
import React from "react";
2
+
import type { ColorSchemePreference } from "../hooks/useColorScheme";
3
3
4
4
/**
5
5
* Props for the `ColorSchemeToggle` segmented control.
6
6
*/
7
7
export interface ColorSchemeToggleProps {
8
-
/**
9
-
* Current color scheme preference selection.
10
-
*/
11
-
value: ColorSchemePreference;
12
-
/**
13
-
* Change handler invoked when the user selects a new scheme.
14
-
*/
15
-
onChange: (value: ColorSchemePreference) => void;
16
-
/**
17
-
* Theme used to style the control itself; defaults to `'light'`.
18
-
*/
19
-
scheme?: 'light' | 'dark';
8
+
/**
9
+
* Current color scheme preference selection.
10
+
*/
11
+
value: ColorSchemePreference;
12
+
/**
13
+
* Change handler invoked when the user selects a new scheme.
14
+
*/
15
+
onChange: (value: ColorSchemePreference) => void;
16
+
/**
17
+
* Theme used to style the control itself; defaults to `'light'`.
18
+
*/
19
+
scheme?: "light" | "dark";
20
20
}
21
21
22
-
const options: Array<{ label: string; value: ColorSchemePreference; description: string }> = [
23
-
{ label: 'System', value: 'system', description: 'Follow OS preference' },
24
-
{ label: 'Light', value: 'light', description: 'Always light mode' },
25
-
{ label: 'Dark', value: 'dark', description: 'Always dark mode' }
22
+
const options: Array<{
23
+
label: string;
24
+
value: ColorSchemePreference;
25
+
description: string;
26
+
}> = [
27
+
{ label: "System", value: "system", description: "Follow OS preference" },
28
+
{ label: "Light", value: "light", description: "Always light mode" },
29
+
{ label: "Dark", value: "dark", description: "Always dark mode" },
26
30
];
27
31
28
32
/**
···
33
37
* @param scheme - Theme used to style the control itself. Defaults to `'light'`.
34
38
* @returns A fully keyboard-accessible toggle rendered as a radio group.
35
39
*/
36
-
export const ColorSchemeToggle: React.FC<ColorSchemeToggleProps> = ({ value, onChange, scheme = 'light' }) => {
37
-
const palette = scheme === 'dark' ? darkTheme : lightTheme;
40
+
export const ColorSchemeToggle: React.FC<ColorSchemeToggleProps> = ({
41
+
value,
42
+
onChange,
43
+
scheme = "light",
44
+
}) => {
45
+
const palette = scheme === "dark" ? darkTheme : lightTheme;
38
46
39
-
return (
40
-
<div aria-label="Color scheme" role="radiogroup" style={{ ...containerStyle, ...palette.container }}>
41
-
{options.map(option => {
42
-
const isActive = option.value === value;
43
-
const activeStyles = isActive ? palette.active : undefined;
44
-
return (
45
-
<button
46
-
key={option.value}
47
-
role="radio"
48
-
aria-checked={isActive}
49
-
type="button"
50
-
onClick={() => onChange(option.value)}
51
-
style={{
52
-
...buttonStyle,
53
-
...palette.button,
54
-
...(activeStyles ?? {})
55
-
}}
56
-
title={option.description}
57
-
>
58
-
{option.label}
59
-
</button>
60
-
);
61
-
})}
62
-
</div>
63
-
);
47
+
return (
48
+
<div
49
+
aria-label="Color scheme"
50
+
role="radiogroup"
51
+
style={{ ...containerStyle, ...palette.container }}
52
+
>
53
+
{options.map((option) => {
54
+
const isActive = option.value === value;
55
+
const activeStyles = isActive ? palette.active : undefined;
56
+
return (
57
+
<button
58
+
key={option.value}
59
+
role="radio"
60
+
aria-checked={isActive}
61
+
type="button"
62
+
onClick={() => onChange(option.value)}
63
+
style={{
64
+
...buttonStyle,
65
+
...palette.button,
66
+
...(activeStyles ?? {}),
67
+
}}
68
+
title={option.description}
69
+
>
70
+
{option.label}
71
+
</button>
72
+
);
73
+
})}
74
+
</div>
75
+
);
64
76
};
65
77
66
78
const containerStyle: React.CSSProperties = {
67
-
display: 'inline-flex',
68
-
borderRadius: 999,
69
-
padding: 4,
70
-
gap: 4,
71
-
border: '1px solid transparent',
72
-
background: '#f8fafc'
79
+
display: "inline-flex",
80
+
borderRadius: 999,
81
+
padding: 4,
82
+
gap: 4,
83
+
border: "1px solid transparent",
84
+
background: "#f8fafc",
73
85
};
74
86
75
87
const buttonStyle: React.CSSProperties = {
76
-
border: '1px solid transparent',
77
-
borderRadius: 999,
78
-
padding: '4px 12px',
79
-
fontSize: 12,
80
-
fontWeight: 500,
81
-
cursor: 'pointer',
82
-
background: 'transparent',
83
-
transition: 'background-color 160ms ease, border-color 160ms ease, color 160ms ease'
88
+
border: "1px solid transparent",
89
+
borderRadius: 999,
90
+
padding: "4px 12px",
91
+
fontSize: 12,
92
+
fontWeight: 500,
93
+
cursor: "pointer",
94
+
background: "transparent",
95
+
transition:
96
+
"background-color 160ms ease, border-color 160ms ease, color 160ms ease",
84
97
};
85
98
86
99
const lightTheme = {
87
-
container: {
88
-
borderColor: '#e2e8f0',
89
-
background: 'rgba(241, 245, 249, 0.8)'
90
-
},
91
-
button: {
92
-
color: '#334155'
93
-
},
94
-
active: {
95
-
background: '#2563eb',
96
-
borderColor: '#2563eb',
97
-
color: '#f8fafc'
98
-
}
100
+
container: {
101
+
borderColor: "#e2e8f0",
102
+
background: "rgba(241, 245, 249, 0.8)",
103
+
},
104
+
button: {
105
+
color: "#334155",
106
+
},
107
+
active: {
108
+
background: "#2563eb",
109
+
borderColor: "#2563eb",
110
+
color: "#f8fafc",
111
+
},
99
112
} satisfies Record<string, React.CSSProperties>;
100
113
101
114
const darkTheme = {
102
-
container: {
103
-
borderColor: '#2e3540ff',
104
-
background: 'rgba(30, 38, 49, 0.6)'
105
-
},
106
-
button: {
107
-
color: '#e2e8f0'
108
-
},
109
-
active: {
110
-
background: '#38bdf8',
111
-
borderColor: '#38bdf8',
112
-
color: '#020617'
113
-
}
115
+
container: {
116
+
borderColor: "#2e3540ff",
117
+
background: "rgba(30, 38, 49, 0.6)",
118
+
},
119
+
button: {
120
+
color: "#e2e8f0",
121
+
},
122
+
active: {
123
+
background: "#38bdf8",
124
+
borderColor: "#38bdf8",
125
+
color: "#020617",
126
+
},
114
127
} satisfies Record<string, React.CSSProperties>;
115
128
116
129
export default ColorSchemeToggle;
+115
-75
lib/components/LeafletDocument.tsx
+115
-75
lib/components/LeafletDocument.tsx
···
1
-
import React, { useMemo } from 'react';
2
-
import { AtProtoRecord } from '../core/AtProtoRecord';
3
-
import { LeafletDocumentRenderer, type LeafletDocumentRendererProps } from '../renderers/LeafletDocumentRenderer';
4
-
import type { LeafletDocumentRecord, LeafletPublicationRecord } from '../types/leaflet';
5
-
import type { ColorSchemePreference } from '../hooks/useColorScheme';
6
-
import { parseAtUri, toBlueskyPostUrl, leafletRkeyUrl, normalizeLeafletBasePath } from '../utils/at-uri';
7
-
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
1
+
import React, { useMemo } from "react";
2
+
import { AtProtoRecord } from "../core/AtProtoRecord";
3
+
import {
4
+
LeafletDocumentRenderer,
5
+
type LeafletDocumentRendererProps,
6
+
} from "../renderers/LeafletDocumentRenderer";
7
+
import type {
8
+
LeafletDocumentRecord,
9
+
LeafletPublicationRecord,
10
+
} from "../types/leaflet";
11
+
import type { ColorSchemePreference } from "../hooks/useColorScheme";
12
+
import {
13
+
parseAtUri,
14
+
toBlueskyPostUrl,
15
+
leafletRkeyUrl,
16
+
normalizeLeafletBasePath,
17
+
} from "../utils/at-uri";
18
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
8
19
9
20
/**
10
21
* Props for rendering a Leaflet document record.
11
22
*/
12
23
export interface LeafletDocumentProps {
13
-
/**
14
-
* DID of the Leaflet publisher.
15
-
*/
16
-
did: string;
17
-
/**
18
-
* Record key of the document within the Leaflet collection.
19
-
*/
20
-
rkey: string;
21
-
/**
22
-
* Optional custom renderer for advanced layouts.
23
-
*/
24
-
renderer?: React.ComponentType<LeafletDocumentRendererInjectedProps>;
25
-
/**
26
-
* React node rendered before data begins loading.
27
-
*/
28
-
fallback?: React.ReactNode;
29
-
/**
30
-
* Indicator rendered while data is being fetched from the PDS.
31
-
*/
32
-
loadingIndicator?: React.ReactNode;
33
-
/**
34
-
* Preferred color scheme to forward to the renderer.
35
-
*/
36
-
colorScheme?: ColorSchemePreference;
24
+
/**
25
+
* DID of the Leaflet publisher.
26
+
*/
27
+
did: string;
28
+
/**
29
+
* Record key of the document within the Leaflet collection.
30
+
*/
31
+
rkey: string;
32
+
/**
33
+
* Optional custom renderer for advanced layouts.
34
+
*/
35
+
renderer?: React.ComponentType<LeafletDocumentRendererInjectedProps>;
36
+
/**
37
+
* React node rendered before data begins loading.
38
+
*/
39
+
fallback?: React.ReactNode;
40
+
/**
41
+
* Indicator rendered while data is being fetched from the PDS.
42
+
*/
43
+
loadingIndicator?: React.ReactNode;
44
+
/**
45
+
* Preferred color scheme to forward to the renderer.
46
+
*/
47
+
colorScheme?: ColorSchemePreference;
37
48
}
38
49
39
50
/**
···
42
53
export type LeafletDocumentRendererInjectedProps = LeafletDocumentRendererProps;
43
54
44
55
/** NSID for Leaflet document records. */
45
-
export const LEAFLET_DOCUMENT_COLLECTION = 'pub.leaflet.document';
56
+
export const LEAFLET_DOCUMENT_COLLECTION = "pub.leaflet.document";
46
57
47
58
/**
48
59
* Loads a Leaflet document along with its associated publication record and renders it
···
56
67
* @param colorScheme - Preferred color scheme forwarded to the renderer.
57
68
* @returns A JSX subtree that renders a Leaflet document with contextual metadata.
58
69
*/
59
-
export const LeafletDocument: React.FC<LeafletDocumentProps> = ({ did, rkey, renderer, fallback, loadingIndicator, colorScheme }) => {
60
-
const Comp: React.ComponentType<LeafletDocumentRendererInjectedProps> = renderer ?? ((props) => <LeafletDocumentRenderer {...props} />);
70
+
export const LeafletDocument: React.FC<LeafletDocumentProps> = ({
71
+
did,
72
+
rkey,
73
+
renderer,
74
+
fallback,
75
+
loadingIndicator,
76
+
colorScheme,
77
+
}) => {
78
+
const Comp: React.ComponentType<LeafletDocumentRendererInjectedProps> =
79
+
renderer ?? ((props) => <LeafletDocumentRenderer {...props} />);
61
80
62
-
const Wrapped: React.FC<{ record: LeafletDocumentRecord; loading: boolean; error?: Error }> = (props) => {
63
-
const publicationUri = useMemo(() => parseAtUri(props.record.publication), [props.record.publication]);
64
-
const { record: publicationRecord } = useAtProtoRecord<LeafletPublicationRecord>({
65
-
did: publicationUri?.did,
66
-
collection: publicationUri?.collection ?? 'pub.leaflet.publication',
67
-
rkey: publicationUri?.rkey ?? ''
68
-
});
69
-
const publicationBaseUrl = normalizeLeafletBasePath(publicationRecord?.base_path);
70
-
const canonicalUrl = resolveCanonicalUrl(props.record, did, rkey, publicationRecord?.base_path);
71
-
return (
72
-
<Comp
73
-
{...props}
74
-
colorScheme={colorScheme}
75
-
did={did}
76
-
rkey={rkey}
77
-
canonicalUrl={canonicalUrl}
78
-
publicationBaseUrl={publicationBaseUrl}
79
-
publicationRecord={publicationRecord}
80
-
/>
81
-
);
82
-
};
81
+
const Wrapped: React.FC<{
82
+
record: LeafletDocumentRecord;
83
+
loading: boolean;
84
+
error?: Error;
85
+
}> = (props) => {
86
+
const publicationUri = useMemo(
87
+
() => parseAtUri(props.record.publication),
88
+
[props.record.publication],
89
+
);
90
+
const { record: publicationRecord } =
91
+
useAtProtoRecord<LeafletPublicationRecord>({
92
+
did: publicationUri?.did,
93
+
collection:
94
+
publicationUri?.collection ?? "pub.leaflet.publication",
95
+
rkey: publicationUri?.rkey ?? "",
96
+
});
97
+
const publicationBaseUrl = normalizeLeafletBasePath(
98
+
publicationRecord?.base_path,
99
+
);
100
+
const canonicalUrl = resolveCanonicalUrl(
101
+
props.record,
102
+
did,
103
+
rkey,
104
+
publicationRecord?.base_path,
105
+
);
106
+
return (
107
+
<Comp
108
+
{...props}
109
+
colorScheme={colorScheme}
110
+
did={did}
111
+
rkey={rkey}
112
+
canonicalUrl={canonicalUrl}
113
+
publicationBaseUrl={publicationBaseUrl}
114
+
publicationRecord={publicationRecord}
115
+
/>
116
+
);
117
+
};
83
118
84
-
return (
85
-
<AtProtoRecord<LeafletDocumentRecord>
86
-
did={did}
87
-
collection={LEAFLET_DOCUMENT_COLLECTION}
88
-
rkey={rkey}
89
-
renderer={Wrapped}
90
-
fallback={fallback}
91
-
loadingIndicator={loadingIndicator}
92
-
/>
93
-
);
119
+
return (
120
+
<AtProtoRecord<LeafletDocumentRecord>
121
+
did={did}
122
+
collection={LEAFLET_DOCUMENT_COLLECTION}
123
+
rkey={rkey}
124
+
renderer={Wrapped}
125
+
fallback={fallback}
126
+
loadingIndicator={loadingIndicator}
127
+
/>
128
+
);
94
129
};
95
130
96
131
/**
···
102
137
* @param publicationBasePath - Optional base path configured by the publication.
103
138
* @returns A URL to use for canonical links.
104
139
*/
105
-
function resolveCanonicalUrl(record: LeafletDocumentRecord, did: string, rkey: string, publicationBasePath?: string): string {
106
-
const publicationUrl = leafletRkeyUrl(publicationBasePath, rkey);
107
-
if (publicationUrl) return publicationUrl;
108
-
const postUri = record.postRef?.uri;
109
-
if (postUri) {
110
-
const parsed = parseAtUri(postUri);
111
-
const href = parsed ? toBlueskyPostUrl(parsed) : undefined;
112
-
if (href) return href;
113
-
}
114
-
return `at://${encodeURIComponent(did)}/${LEAFLET_DOCUMENT_COLLECTION}/${encodeURIComponent(rkey)}`;
140
+
function resolveCanonicalUrl(
141
+
record: LeafletDocumentRecord,
142
+
did: string,
143
+
rkey: string,
144
+
publicationBasePath?: string,
145
+
): string {
146
+
const publicationUrl = leafletRkeyUrl(publicationBasePath, rkey);
147
+
if (publicationUrl) return publicationUrl;
148
+
const postUri = record.postRef?.uri;
149
+
if (postUri) {
150
+
const parsed = parseAtUri(postUri);
151
+
const href = parsed ? toBlueskyPostUrl(parsed) : undefined;
152
+
if (href) return href;
153
+
}
154
+
return `at://${encodeURIComponent(did)}/${LEAFLET_DOCUMENT_COLLECTION}/${encodeURIComponent(rkey)}`;
115
155
}
116
156
117
157
export default LeafletDocument;
+22
-10
lib/components/TangledString.tsx
+22
-10
lib/components/TangledString.tsx
···
1
-
import React from 'react';
2
-
import { AtProtoRecord } from '../core/AtProtoRecord';
3
-
import { TangledStringRenderer } from '../renderers/TangledStringRenderer';
4
-
import type { TangledStringRecord } from '../renderers/TangledStringRenderer';
1
+
import React from "react";
2
+
import { AtProtoRecord } from "../core/AtProtoRecord";
3
+
import { TangledStringRenderer } from "../renderers/TangledStringRenderer";
4
+
import type { TangledStringRecord } from "../renderers/TangledStringRenderer";
5
5
6
6
/**
7
7
* Props for rendering Tangled String records.
···
18
18
/** Indicator node shown while data is loading. */
19
19
loadingIndicator?: React.ReactNode;
20
20
/** Preferred color scheme for theming. */
21
-
colorScheme?: 'light' | 'dark' | 'system';
21
+
colorScheme?: "light" | "dark" | "system";
22
22
}
23
23
24
24
/**
···
32
32
/** Fetch error, if any. */
33
33
error?: Error;
34
34
/** Preferred color scheme for downstream components. */
35
-
colorScheme?: 'light' | 'dark' | 'system';
35
+
colorScheme?: "light" | "dark" | "system";
36
36
/** DID associated with the record. */
37
37
did: string;
38
38
/** Record key for the string. */
···
42
42
};
43
43
44
44
/** NSID for Tangled String records. */
45
-
export const TANGLED_COLLECTION = 'sh.tangled.string';
45
+
export const TANGLED_COLLECTION = "sh.tangled.string";
46
46
47
47
/**
48
48
* Resolves a Tangled String record and renders it with optional overrides while computing a canonical link.
···
55
55
* @param colorScheme - Preferred color scheme for theming the renderer.
56
56
* @returns A JSX subtree representing the Tangled String record with loading states handled.
57
57
*/
58
-
export const TangledString: React.FC<TangledStringProps> = ({ did, rkey, renderer, fallback, loadingIndicator, colorScheme }) => {
59
-
const Comp: React.ComponentType<TangledStringRendererInjectedProps> = renderer ?? ((props) => <TangledStringRenderer {...props} />);
60
-
const Wrapped: React.FC<{ record: TangledStringRecord; loading: boolean; error?: Error }> = (props) => (
58
+
export const TangledString: React.FC<TangledStringProps> = ({
59
+
did,
60
+
rkey,
61
+
renderer,
62
+
fallback,
63
+
loadingIndicator,
64
+
colorScheme,
65
+
}) => {
66
+
const Comp: React.ComponentType<TangledStringRendererInjectedProps> =
67
+
renderer ?? ((props) => <TangledStringRenderer {...props} />);
68
+
const Wrapped: React.FC<{
69
+
record: TangledStringRecord;
70
+
loading: boolean;
71
+
error?: Error;
72
+
}> = (props) => (
61
73
<Comp
62
74
{...props}
63
75
colorScheme={colorScheme}
+35
-9
lib/core/AtProtoRecord.tsx
+35
-9
lib/core/AtProtoRecord.tsx
···
1
-
import React from 'react';
2
-
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
1
+
import React from "react";
2
+
import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
3
3
4
4
interface AtProtoRecordRenderProps<T> {
5
-
renderer?: React.ComponentType<{ record: T; loading: boolean; error?: Error }>;
5
+
renderer?: React.ComponentType<{
6
+
record: T;
7
+
loading: boolean;
8
+
error?: Error;
9
+
}>;
6
10
fallback?: React.ReactNode;
7
11
loadingIndicator?: React.ReactNode;
8
12
}
···
21
25
rkey?: string;
22
26
};
23
27
24
-
export type AtProtoRecordProps<T = unknown> = AtProtoRecordFetchProps<T> | AtProtoRecordProvidedRecordProps<T>;
28
+
export type AtProtoRecordProps<T = unknown> =
29
+
| AtProtoRecordFetchProps<T>
30
+
| AtProtoRecordProvidedRecordProps<T>;
25
31
26
32
export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) {
27
-
const { renderer: Renderer, fallback = null, loadingIndicator = 'Loading…' } = props;
28
-
const hasProvidedRecord = 'record' in props;
33
+
const {
34
+
renderer: Renderer,
35
+
fallback = null,
36
+
loadingIndicator = "Loading…",
37
+
} = props;
38
+
const hasProvidedRecord = "record" in props;
29
39
const providedRecord = hasProvidedRecord ? props.record : undefined;
30
40
31
-
const { record: fetchedRecord, error, loading } = useAtProtoRecord<T>({
41
+
const {
42
+
record: fetchedRecord,
43
+
error,
44
+
loading,
45
+
} = useAtProtoRecord<T>({
32
46
did: hasProvidedRecord ? undefined : props.did,
33
47
collection: hasProvidedRecord ? undefined : props.collection,
34
48
rkey: hasProvidedRecord ? undefined : props.rkey,
···
39
53
40
54
if (error && !record) return <>{fallback}</>;
41
55
if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
42
-
if (Renderer) return <Renderer record={record} loading={isLoading} error={error} />;
43
-
return <pre style={{ fontSize: 12, padding: 8, background: '#f5f5f5', overflow: 'auto' }}>{JSON.stringify(record, null, 2)}</pre>;
56
+
if (Renderer)
57
+
return <Renderer record={record} loading={isLoading} error={error} />;
58
+
return (
59
+
<pre
60
+
style={{
61
+
fontSize: 12,
62
+
padding: 8,
63
+
background: "#f5f5f5",
64
+
overflow: "auto",
65
+
}}
66
+
>
67
+
{JSON.stringify(record, null, 2)}
68
+
</pre>
69
+
);
44
70
}
+113
-67
lib/hooks/useAtProtoRecord.ts
+113
-67
lib/hooks/useAtProtoRecord.ts
···
1
-
import { useEffect, useState } from 'react';
2
-
import { useDidResolution } from './useDidResolution';
3
-
import { usePdsEndpoint } from './usePdsEndpoint';
4
-
import { createAtprotoClient } from '../utils/atproto-client';
1
+
import { useEffect, useState } from "react";
2
+
import { useDidResolution } from "./useDidResolution";
3
+
import { usePdsEndpoint } from "./usePdsEndpoint";
4
+
import { createAtprotoClient } from "../utils/atproto-client";
5
5
6
6
/**
7
7
* Identifier trio required to address an AT Protocol record.
8
8
*/
9
9
export interface AtProtoRecordKey {
10
-
/** Repository DID (or handle prior to resolution) containing the record. */
11
-
did?: string;
12
-
/** NSID collection in which the record resides. */
13
-
collection?: string;
14
-
/** Record key string uniquely identifying the record within the collection. */
15
-
rkey?: string;
10
+
/** Repository DID (or handle prior to resolution) containing the record. */
11
+
did?: string;
12
+
/** NSID collection in which the record resides. */
13
+
collection?: string;
14
+
/** Record key string uniquely identifying the record within the collection. */
15
+
rkey?: string;
16
16
}
17
17
18
18
/**
19
19
* Loading state returned by {@link useAtProtoRecord}.
20
20
*/
21
21
export interface AtProtoRecordState<T = unknown> {
22
-
/** Resolved record value when fetch succeeds. */
23
-
record?: T;
24
-
/** Error thrown while loading, if any. */
25
-
error?: Error;
26
-
/** Indicates whether the hook is in a loading state. */
27
-
loading: boolean;
22
+
/** Resolved record value when fetch succeeds. */
23
+
record?: T;
24
+
/** Error thrown while loading, if any. */
25
+
error?: Error;
26
+
/** Indicates whether the hook is in a loading state. */
27
+
loading: boolean;
28
28
}
29
29
30
30
/**
···
35
35
* @param rkey - Record key identifying the record within the collection.
36
36
* @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag.
37
37
*/
38
-
export function useAtProtoRecord<T = unknown>({ did: handleOrDid, collection, rkey }: AtProtoRecordKey): AtProtoRecordState<T> {
39
-
const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
40
-
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
41
-
const [state, setState] = useState<AtProtoRecordState<T>>({ loading: !!(handleOrDid && collection && rkey) });
38
+
export function useAtProtoRecord<T = unknown>({
39
+
did: handleOrDid,
40
+
collection,
41
+
rkey,
42
+
}: AtProtoRecordKey): AtProtoRecordState<T> {
43
+
const {
44
+
did,
45
+
error: didError,
46
+
loading: resolvingDid,
47
+
} = useDidResolution(handleOrDid);
48
+
const {
49
+
endpoint,
50
+
error: endpointError,
51
+
loading: resolvingEndpoint,
52
+
} = usePdsEndpoint(did);
53
+
const [state, setState] = useState<AtProtoRecordState<T>>({
54
+
loading: !!(handleOrDid && collection && rkey),
55
+
});
42
56
43
-
useEffect(() => {
44
-
let cancelled = false;
57
+
useEffect(() => {
58
+
let cancelled = false;
45
59
46
-
const assignState = (next: Partial<AtProtoRecordState<T>>) => {
47
-
if (cancelled) return;
48
-
setState(prev => ({ ...prev, ...next }));
49
-
};
60
+
const assignState = (next: Partial<AtProtoRecordState<T>>) => {
61
+
if (cancelled) return;
62
+
setState((prev) => ({ ...prev, ...next }));
63
+
};
50
64
51
-
if (!handleOrDid || !collection || !rkey) {
52
-
assignState({ loading: false, record: undefined, error: undefined });
53
-
return () => { cancelled = true; };
54
-
}
65
+
if (!handleOrDid || !collection || !rkey) {
66
+
assignState({
67
+
loading: false,
68
+
record: undefined,
69
+
error: undefined,
70
+
});
71
+
return () => {
72
+
cancelled = true;
73
+
};
74
+
}
55
75
56
-
if (didError) {
57
-
assignState({ loading: false, error: didError });
58
-
return () => { cancelled = true; };
59
-
}
76
+
if (didError) {
77
+
assignState({ loading: false, error: didError });
78
+
return () => {
79
+
cancelled = true;
80
+
};
81
+
}
60
82
61
-
if (endpointError) {
62
-
assignState({ loading: false, error: endpointError });
63
-
return () => { cancelled = true; };
64
-
}
83
+
if (endpointError) {
84
+
assignState({ loading: false, error: endpointError });
85
+
return () => {
86
+
cancelled = true;
87
+
};
88
+
}
65
89
66
-
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
67
-
assignState({ loading: true, error: undefined });
68
-
return () => { cancelled = true; };
69
-
}
90
+
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
91
+
assignState({ loading: true, error: undefined });
92
+
return () => {
93
+
cancelled = true;
94
+
};
95
+
}
70
96
71
-
assignState({ loading: true, error: undefined, record: undefined });
97
+
assignState({ loading: true, error: undefined, record: undefined });
72
98
73
-
(async () => {
74
-
try {
75
-
const { rpc } = await createAtprotoClient({ service: endpoint });
76
-
const res = await (rpc as unknown as {
77
-
get: (
78
-
nsid: string,
79
-
opts: { params: { repo: string; collection: string; rkey: string } }
80
-
) => Promise<{ ok: boolean; data: { value: T } }>;
81
-
}).get('com.atproto.repo.getRecord', {
82
-
params: { repo: did, collection, rkey }
83
-
});
84
-
if (!res.ok) throw new Error('Failed to load record');
85
-
const record = (res.data as { value: T }).value;
86
-
assignState({ record, loading: false });
87
-
} catch (e) {
88
-
const err = e instanceof Error ? e : new Error(String(e));
89
-
assignState({ error: err, loading: false });
90
-
}
91
-
})();
99
+
(async () => {
100
+
try {
101
+
const { rpc } = await createAtprotoClient({
102
+
service: endpoint,
103
+
});
104
+
const res = await (
105
+
rpc as unknown as {
106
+
get: (
107
+
nsid: string,
108
+
opts: {
109
+
params: {
110
+
repo: string;
111
+
collection: string;
112
+
rkey: string;
113
+
};
114
+
},
115
+
) => Promise<{ ok: boolean; data: { value: T } }>;
116
+
}
117
+
).get("com.atproto.repo.getRecord", {
118
+
params: { repo: did, collection, rkey },
119
+
});
120
+
if (!res.ok) throw new Error("Failed to load record");
121
+
const record = (res.data as { value: T }).value;
122
+
assignState({ record, loading: false });
123
+
} catch (e) {
124
+
const err = e instanceof Error ? e : new Error(String(e));
125
+
assignState({ error: err, loading: false });
126
+
}
127
+
})();
92
128
93
-
return () => {
94
-
cancelled = true;
95
-
};
96
-
}, [handleOrDid, did, endpoint, collection, rkey, resolvingDid, resolvingEndpoint, didError, endpointError]);
129
+
return () => {
130
+
cancelled = true;
131
+
};
132
+
}, [
133
+
handleOrDid,
134
+
did,
135
+
endpoint,
136
+
collection,
137
+
rkey,
138
+
resolvingDid,
139
+
resolvingEndpoint,
140
+
didError,
141
+
endpointError,
142
+
]);
97
143
98
-
return state;
144
+
return state;
99
145
}
+148
-109
lib/hooks/useBlob.ts
+148
-109
lib/hooks/useBlob.ts
···
1
-
import { useEffect, useRef, useState } from 'react';
2
-
import { useDidResolution } from './useDidResolution';
3
-
import { usePdsEndpoint } from './usePdsEndpoint';
4
-
import { useAtProto } from '../providers/AtProtoProvider';
1
+
import { useEffect, useRef, useState } from "react";
2
+
import { useDidResolution } from "./useDidResolution";
3
+
import { usePdsEndpoint } from "./usePdsEndpoint";
4
+
import { useAtProto } from "../providers/AtProtoProvider";
5
5
6
6
/**
7
7
* Status returned by {@link useBlob} containing blob URL and metadata flags.
8
8
*/
9
9
export interface UseBlobState {
10
-
/** Object URL pointing to the fetched blob, when available. */
11
-
url?: string;
12
-
/** Indicates whether a fetch is in progress. */
13
-
loading: boolean;
14
-
/** Error encountered while fetching the blob. */
15
-
error?: Error;
10
+
/** Object URL pointing to the fetched blob, when available. */
11
+
url?: string;
12
+
/** Indicates whether a fetch is in progress. */
13
+
loading: boolean;
14
+
/** Error encountered while fetching the blob. */
15
+
error?: Error;
16
16
}
17
17
18
18
/**
···
22
22
* @param cid - Content identifier for the desired blob.
23
23
* @returns {UseBlobState} Object containing the object URL, loading flag, and any error.
24
24
*/
25
-
export function useBlob(handleOrDid: string | undefined, cid: string | undefined): UseBlobState {
26
-
const { did, error: didError, loading: didLoading } = useDidResolution(handleOrDid);
27
-
const { endpoint, error: endpointError, loading: endpointLoading } = usePdsEndpoint(did);
28
-
const { blobCache } = useAtProto();
29
-
const [state, setState] = useState<UseBlobState>({ loading: !!(handleOrDid && cid) });
30
-
const objectUrlRef = useRef<string | undefined>(undefined);
25
+
export function useBlob(
26
+
handleOrDid: string | undefined,
27
+
cid: string | undefined,
28
+
): UseBlobState {
29
+
const {
30
+
did,
31
+
error: didError,
32
+
loading: didLoading,
33
+
} = useDidResolution(handleOrDid);
34
+
const {
35
+
endpoint,
36
+
error: endpointError,
37
+
loading: endpointLoading,
38
+
} = usePdsEndpoint(did);
39
+
const { blobCache } = useAtProto();
40
+
const [state, setState] = useState<UseBlobState>({
41
+
loading: !!(handleOrDid && cid),
42
+
});
43
+
const objectUrlRef = useRef<string | undefined>(undefined);
31
44
32
-
useEffect(() => () => {
33
-
if (objectUrlRef.current) {
34
-
URL.revokeObjectURL(objectUrlRef.current);
35
-
objectUrlRef.current = undefined;
36
-
}
37
-
}, []);
45
+
useEffect(
46
+
() => () => {
47
+
if (objectUrlRef.current) {
48
+
URL.revokeObjectURL(objectUrlRef.current);
49
+
objectUrlRef.current = undefined;
50
+
}
51
+
},
52
+
[],
53
+
);
38
54
39
-
useEffect(() => {
40
-
let cancelled = false;
55
+
useEffect(() => {
56
+
let cancelled = false;
41
57
42
-
const clearObjectUrl = () => {
43
-
if (objectUrlRef.current) {
44
-
URL.revokeObjectURL(objectUrlRef.current);
45
-
objectUrlRef.current = undefined;
46
-
}
47
-
};
58
+
const clearObjectUrl = () => {
59
+
if (objectUrlRef.current) {
60
+
URL.revokeObjectURL(objectUrlRef.current);
61
+
objectUrlRef.current = undefined;
62
+
}
63
+
};
48
64
49
-
if (!handleOrDid || !cid) {
50
-
clearObjectUrl();
51
-
setState({ loading: false });
52
-
return () => {
53
-
cancelled = true;
54
-
};
55
-
}
65
+
if (!handleOrDid || !cid) {
66
+
clearObjectUrl();
67
+
setState({ loading: false });
68
+
return () => {
69
+
cancelled = true;
70
+
};
71
+
}
56
72
57
-
if (didError) {
58
-
clearObjectUrl();
59
-
setState({ loading: false, error: didError });
60
-
return () => {
61
-
cancelled = true;
62
-
};
63
-
}
73
+
if (didError) {
74
+
clearObjectUrl();
75
+
setState({ loading: false, error: didError });
76
+
return () => {
77
+
cancelled = true;
78
+
};
79
+
}
64
80
65
-
if (endpointError) {
66
-
clearObjectUrl();
67
-
setState({ loading: false, error: endpointError });
68
-
return () => {
69
-
cancelled = true;
70
-
};
71
-
}
81
+
if (endpointError) {
82
+
clearObjectUrl();
83
+
setState({ loading: false, error: endpointError });
84
+
return () => {
85
+
cancelled = true;
86
+
};
87
+
}
72
88
73
-
if (didLoading || endpointLoading || !did || !endpoint) {
74
-
setState(prev => ({ ...prev, loading: true, error: undefined }));
75
-
return () => {
76
-
cancelled = true;
77
-
};
78
-
}
89
+
if (didLoading || endpointLoading || !did || !endpoint) {
90
+
setState((prev) => ({ ...prev, loading: true, error: undefined }));
91
+
return () => {
92
+
cancelled = true;
93
+
};
94
+
}
79
95
80
-
const cachedBlob = blobCache.get(did, cid);
81
-
if (cachedBlob) {
82
-
const nextUrl = URL.createObjectURL(cachedBlob);
83
-
const prevUrl = objectUrlRef.current;
84
-
objectUrlRef.current = nextUrl;
85
-
if (prevUrl) URL.revokeObjectURL(prevUrl);
86
-
setState({ url: nextUrl, loading: false });
87
-
return () => {
88
-
cancelled = true;
89
-
};
90
-
}
96
+
const cachedBlob = blobCache.get(did, cid);
97
+
if (cachedBlob) {
98
+
const nextUrl = URL.createObjectURL(cachedBlob);
99
+
const prevUrl = objectUrlRef.current;
100
+
objectUrlRef.current = nextUrl;
101
+
if (prevUrl) URL.revokeObjectURL(prevUrl);
102
+
setState({ url: nextUrl, loading: false });
103
+
return () => {
104
+
cancelled = true;
105
+
};
106
+
}
91
107
92
-
let controller: AbortController | undefined;
93
-
let release: (() => void) | undefined;
108
+
let controller: AbortController | undefined;
109
+
let release: (() => void) | undefined;
94
110
95
-
(async () => {
96
-
try {
97
-
setState(prev => ({ ...prev, loading: true, error: undefined }));
98
-
const ensureResult = blobCache.ensure(did, cid, () => {
99
-
controller = new AbortController();
100
-
const promise = (async () => {
101
-
const res = await fetch(
102
-
`${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
103
-
{ signal: controller?.signal }
104
-
);
105
-
if (!res.ok) throw new Error(`Blob fetch failed (${res.status})`);
106
-
return res.blob();
107
-
})();
108
-
return { promise, abort: () => controller?.abort() };
109
-
});
110
-
release = ensureResult.release;
111
-
const blob = await ensureResult.promise;
112
-
const nextUrl = URL.createObjectURL(blob);
113
-
const prevUrl = objectUrlRef.current;
114
-
objectUrlRef.current = nextUrl;
115
-
if (prevUrl) URL.revokeObjectURL(prevUrl);
116
-
if (!cancelled) setState({ url: nextUrl, loading: false });
117
-
} catch (e) {
118
-
const aborted = (controller && controller.signal.aborted) || (e instanceof DOMException && e.name === 'AbortError');
119
-
if (aborted) return;
120
-
clearObjectUrl();
121
-
if (!cancelled) setState({ loading: false, error: e as Error });
122
-
}
123
-
})();
111
+
(async () => {
112
+
try {
113
+
setState((prev) => ({
114
+
...prev,
115
+
loading: true,
116
+
error: undefined,
117
+
}));
118
+
const ensureResult = blobCache.ensure(did, cid, () => {
119
+
controller = new AbortController();
120
+
const promise = (async () => {
121
+
const res = await fetch(
122
+
`${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
123
+
{ signal: controller?.signal },
124
+
);
125
+
if (!res.ok)
126
+
throw new Error(
127
+
`Blob fetch failed (${res.status})`,
128
+
);
129
+
return res.blob();
130
+
})();
131
+
return { promise, abort: () => controller?.abort() };
132
+
});
133
+
release = ensureResult.release;
134
+
const blob = await ensureResult.promise;
135
+
const nextUrl = URL.createObjectURL(blob);
136
+
const prevUrl = objectUrlRef.current;
137
+
objectUrlRef.current = nextUrl;
138
+
if (prevUrl) URL.revokeObjectURL(prevUrl);
139
+
if (!cancelled) setState({ url: nextUrl, loading: false });
140
+
} catch (e) {
141
+
const aborted =
142
+
(controller && controller.signal.aborted) ||
143
+
(e instanceof DOMException && e.name === "AbortError");
144
+
if (aborted) return;
145
+
clearObjectUrl();
146
+
if (!cancelled) setState({ loading: false, error: e as Error });
147
+
}
148
+
})();
124
149
125
-
return () => {
126
-
cancelled = true;
127
-
release?.();
128
-
if (controller && controller.signal.aborted && objectUrlRef.current) {
129
-
URL.revokeObjectURL(objectUrlRef.current);
130
-
objectUrlRef.current = undefined;
131
-
}
132
-
};
133
-
}, [handleOrDid, cid, did, endpoint, didLoading, endpointLoading, didError, endpointError, blobCache]);
150
+
return () => {
151
+
cancelled = true;
152
+
release?.();
153
+
if (
154
+
controller &&
155
+
controller.signal.aborted &&
156
+
objectUrlRef.current
157
+
) {
158
+
URL.revokeObjectURL(objectUrlRef.current);
159
+
objectUrlRef.current = undefined;
160
+
}
161
+
};
162
+
}, [
163
+
handleOrDid,
164
+
cid,
165
+
did,
166
+
endpoint,
167
+
didLoading,
168
+
endpointLoading,
169
+
didError,
170
+
endpointError,
171
+
blobCache,
172
+
]);
134
173
135
-
return state;
174
+
return state;
136
175
}
+53
-44
lib/hooks/useBlueskyProfile.ts
+53
-44
lib/hooks/useBlueskyProfile.ts
···
1
-
import { useEffect, useState } from 'react';
2
-
import { usePdsEndpoint } from './usePdsEndpoint';
3
-
import { createAtprotoClient } from '../utils/atproto-client';
1
+
import { useEffect, useState } from "react";
2
+
import { usePdsEndpoint } from "./usePdsEndpoint";
3
+
import { createAtprotoClient } from "../utils/atproto-client";
4
4
5
5
/**
6
6
* Minimal profile fields returned by the Bluesky actor profile endpoint.
7
7
*/
8
8
export interface BlueskyProfileData {
9
-
/** Actor DID. */
10
-
did: string;
11
-
/** Actor handle. */
12
-
handle: string;
13
-
/** Display name configured by the actor. */
14
-
displayName?: string;
15
-
/** Profile description/bio. */
16
-
description?: string;
17
-
/** Avatar blob (CID reference). */
18
-
avatar?: string;
19
-
/** Banner image blob (CID reference). */
20
-
banner?: string;
21
-
/** Creation timestamp for the profile. */
22
-
createdAt?: string;
9
+
/** Actor DID. */
10
+
did: string;
11
+
/** Actor handle. */
12
+
handle: string;
13
+
/** Display name configured by the actor. */
14
+
displayName?: string;
15
+
/** Profile description/bio. */
16
+
description?: string;
17
+
/** Avatar blob (CID reference). */
18
+
avatar?: string;
19
+
/** Banner image blob (CID reference). */
20
+
banner?: string;
21
+
/** Creation timestamp for the profile. */
22
+
createdAt?: string;
23
23
}
24
24
25
25
/**
···
29
29
* @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error.
30
30
*/
31
31
export function useBlueskyProfile(did: string | undefined) {
32
-
const { endpoint } = usePdsEndpoint(did);
33
-
const [data, setData] = useState<BlueskyProfileData | undefined>();
34
-
const [loading, setLoading] = useState<boolean>(!!did);
35
-
const [error, setError] = useState<Error | undefined>();
32
+
const { endpoint } = usePdsEndpoint(did);
33
+
const [data, setData] = useState<BlueskyProfileData | undefined>();
34
+
const [loading, setLoading] = useState<boolean>(!!did);
35
+
const [error, setError] = useState<Error | undefined>();
36
36
37
-
useEffect(() => {
38
-
let cancelled = false;
39
-
async function run() {
40
-
if (!did || !endpoint) return;
41
-
setLoading(true);
42
-
try {
43
-
const { rpc } = await createAtprotoClient({ service: endpoint });
44
-
const client = rpc as unknown as {
45
-
get: (nsid: string, options: { params: { actor: string } }) => Promise<{ ok: boolean; data: unknown }>;
46
-
};
47
-
const res = await client.get('app.bsky.actor.getProfile', { params: { actor: did } });
48
-
if (!res.ok) throw new Error('Profile request failed');
49
-
if (!cancelled) setData(res.data as BlueskyProfileData);
50
-
} catch (e) {
51
-
if (!cancelled) setError(e as Error);
52
-
} finally {
53
-
if (!cancelled) setLoading(false);
54
-
}
55
-
}
56
-
run();
57
-
return () => { cancelled = true; };
58
-
}, [did, endpoint]);
37
+
useEffect(() => {
38
+
let cancelled = false;
39
+
async function run() {
40
+
if (!did || !endpoint) return;
41
+
setLoading(true);
42
+
try {
43
+
const { rpc } = await createAtprotoClient({
44
+
service: endpoint,
45
+
});
46
+
const client = rpc as unknown as {
47
+
get: (
48
+
nsid: string,
49
+
options: { params: { actor: string } },
50
+
) => Promise<{ ok: boolean; data: unknown }>;
51
+
};
52
+
const res = await client.get("app.bsky.actor.getProfile", {
53
+
params: { actor: did },
54
+
});
55
+
if (!res.ok) throw new Error("Profile request failed");
56
+
if (!cancelled) setData(res.data as BlueskyProfileData);
57
+
} catch (e) {
58
+
if (!cancelled) setError(e as Error);
59
+
} finally {
60
+
if (!cancelled) setLoading(false);
61
+
}
62
+
}
63
+
run();
64
+
return () => {
65
+
cancelled = true;
66
+
};
67
+
}, [did, endpoint]);
59
68
60
-
return { data, loading, error };
69
+
return { data, loading, error };
61
70
}
+27
-17
lib/hooks/useColorScheme.ts
+27
-17
lib/hooks/useColorScheme.ts
···
1
-
import { useEffect, useState } from 'react';
1
+
import { useEffect, useState } from "react";
2
2
3
3
/**
4
4
* Possible user-facing color scheme preferences.
5
5
*/
6
-
export type ColorSchemePreference = 'light' | 'dark' | 'system';
6
+
export type ColorSchemePreference = "light" | "dark" | "system";
7
7
8
-
const MEDIA_QUERY = '(prefers-color-scheme: dark)';
8
+
const MEDIA_QUERY = "(prefers-color-scheme: dark)";
9
9
10
10
/**
11
11
* Resolves a persisted preference into an explicit light/dark value.
···
13
13
* @param pref - Stored preference value (`light`, `dark`, or `system`).
14
14
* @returns Explicit light/dark scheme suitable for rendering.
15
15
*/
16
-
function resolveScheme(pref: ColorSchemePreference): 'light' | 'dark' {
17
-
if (pref === 'light' || pref === 'dark') return pref;
18
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
19
-
return 'light';
16
+
function resolveScheme(pref: ColorSchemePreference): "light" | "dark" {
17
+
if (pref === "light" || pref === "dark") return pref;
18
+
if (
19
+
typeof window === "undefined" ||
20
+
typeof window.matchMedia !== "function"
21
+
) {
22
+
return "light";
20
23
}
21
-
return window.matchMedia(MEDIA_QUERY).matches ? 'dark' : 'light';
24
+
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light";
22
25
}
23
26
24
27
/**
···
27
30
* @param preference - User preference; defaults to following the OS setting.
28
31
* @returns {'light' | 'dark'} Explicit scheme that should be used for rendering.
29
32
*/
30
-
export function useColorScheme(preference: ColorSchemePreference = 'system'): 'light' | 'dark' {
31
-
const [scheme, setScheme] = useState<'light' | 'dark'>(() => resolveScheme(preference));
33
+
export function useColorScheme(
34
+
preference: ColorSchemePreference = "system",
35
+
): "light" | "dark" {
36
+
const [scheme, setScheme] = useState<"light" | "dark">(() =>
37
+
resolveScheme(preference),
38
+
);
32
39
33
40
useEffect(() => {
34
-
if (preference === 'light' || preference === 'dark') {
41
+
if (preference === "light" || preference === "dark") {
35
42
setScheme(preference);
36
43
return;
37
44
}
38
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
39
-
setScheme('light');
45
+
if (
46
+
typeof window === "undefined" ||
47
+
typeof window.matchMedia !== "function"
48
+
) {
49
+
setScheme("light");
40
50
return;
41
51
}
42
52
const media = window.matchMedia(MEDIA_QUERY);
43
53
const update = (event: MediaQueryListEvent | MediaQueryList) => {
44
-
setScheme(event.matches ? 'dark' : 'light');
54
+
setScheme(event.matches ? "dark" : "light");
45
55
};
46
56
update(media);
47
-
if (typeof media.addEventListener === 'function') {
48
-
media.addEventListener('change', update);
49
-
return () => media.removeEventListener('change', update);
57
+
if (typeof media.addEventListener === "function") {
58
+
media.addEventListener("change", update);
59
+
return () => media.removeEventListener("change", update);
50
60
}
51
61
media.addListener(update);
52
62
return () => media.removeListener(update);
+32
-13
lib/hooks/useDidResolution.ts
+32
-13
lib/hooks/useDidResolution.ts
···
1
-
import { useEffect, useMemo, useState } from 'react';
2
-
import { useAtProto } from '../providers/AtProtoProvider';
1
+
import { useEffect, useMemo, useState } from "react";
2
+
import { useAtProto } from "../providers/AtProtoProvider";
3
3
4
4
/**
5
5
* Resolves a handle to its DID, or returns the DID immediately when provided.
···
30
30
};
31
31
if (!normalizedInput) {
32
32
reset();
33
-
return () => { cancelled = true; };
33
+
return () => {
34
+
cancelled = true;
35
+
};
34
36
}
35
37
36
-
const isDid = normalizedInput.startsWith('did:');
37
-
const normalizedHandle = !isDid ? normalizedInput.toLowerCase() : undefined;
38
+
const isDid = normalizedInput.startsWith("did:");
39
+
const normalizedHandle = !isDid
40
+
? normalizedInput.toLowerCase()
41
+
: undefined;
38
42
const cached = isDid
39
43
? didCache.getByDid(normalizedInput)
40
44
: didCache.getByHandle(normalizedHandle);
41
45
42
46
const initialDid = cached?.did ?? (isDid ? normalizedInput : undefined);
43
-
const initialHandle = cached?.handle ?? (!isDid ? normalizedHandle : undefined);
47
+
const initialHandle =
48
+
cached?.handle ?? (!isDid ? normalizedHandle : undefined);
44
49
45
50
setError(undefined);
46
51
setDid(initialDid);
47
52
setHandle(initialHandle);
48
53
49
54
const needsHandleResolution = !isDid && !cached?.did;
50
-
const needsDocResolution = isDid && (!cached?.doc || cached.handle === undefined);
55
+
const needsDocResolution =
56
+
isDid && (!cached?.doc || cached.handle === undefined);
51
57
52
58
if (!needsHandleResolution && !needsDocResolution) {
53
59
setLoading(false);
54
-
return () => { cancelled = true; };
60
+
return () => {
61
+
cancelled = true;
62
+
};
55
63
}
56
64
57
65
setLoading(true);
···
60
68
try {
61
69
let snapshot = cached;
62
70
if (!isDid && normalizedHandle && needsHandleResolution) {
63
-
snapshot = await didCache.ensureHandle(resolver, normalizedHandle);
71
+
snapshot = await didCache.ensureHandle(
72
+
resolver,
73
+
normalizedHandle,
74
+
);
64
75
}
65
76
66
77
if (isDid) {
67
-
snapshot = await didCache.ensureDidDoc(resolver, normalizedInput);
78
+
snapshot = await didCache.ensureDidDoc(
79
+
resolver,
80
+
normalizedInput,
81
+
);
68
82
}
69
83
70
84
if (!cancelled) {
71
-
const resolvedDid = snapshot?.did ?? (isDid ? normalizedInput : undefined);
72
-
const resolvedHandle = snapshot?.handle ?? (!isDid ? normalizedHandle : undefined);
85
+
const resolvedDid =
86
+
snapshot?.did ?? (isDid ? normalizedInput : undefined);
87
+
const resolvedHandle =
88
+
snapshot?.handle ??
89
+
(!isDid ? normalizedHandle : undefined);
73
90
setDid(resolvedDid);
74
91
setHandle(resolvedHandle);
75
92
setError(undefined);
···
83
100
}
84
101
})();
85
102
86
-
return () => { cancelled = true; };
103
+
return () => {
104
+
cancelled = true;
105
+
};
87
106
}, [normalizedInput, resolver, didCache]);
88
107
89
108
return { did, handle, error, loading };
+138
-73
lib/hooks/useLatestRecord.ts
+138
-73
lib/hooks/useLatestRecord.ts
···
1
-
import { useEffect, useState } from 'react';
2
-
import { useDidResolution } from './useDidResolution';
3
-
import { usePdsEndpoint } from './usePdsEndpoint';
4
-
import { createAtprotoClient } from '../utils/atproto-client';
1
+
import { useEffect, useState } from "react";
2
+
import { useDidResolution } from "./useDidResolution";
3
+
import { usePdsEndpoint } from "./usePdsEndpoint";
4
+
import { createAtprotoClient } from "../utils/atproto-client";
5
5
6
6
/**
7
7
* Shape of the state returned by {@link useLatestRecord}.
8
8
*/
9
9
export interface LatestRecordState<T = unknown> {
10
-
/** Latest record value if one exists. */
11
-
record?: T;
12
-
/** Record key for the fetched record, when derivable. */
13
-
rkey?: string;
14
-
/** Error encountered while fetching. */
15
-
error?: Error;
16
-
/** Indicates whether a fetch is in progress. */
17
-
loading: boolean;
18
-
/** `true` when the collection has zero records. */
19
-
empty: boolean;
10
+
/** Latest record value if one exists. */
11
+
record?: T;
12
+
/** Record key for the fetched record, when derivable. */
13
+
rkey?: string;
14
+
/** Error encountered while fetching. */
15
+
error?: Error;
16
+
/** Indicates whether a fetch is in progress. */
17
+
loading: boolean;
18
+
/** `true` when the collection has zero records. */
19
+
empty: boolean;
20
20
}
21
21
22
22
/**
···
26
26
* @param collection - NSID of the collection to query.
27
27
* @returns {LatestRecordState<T>} Object reporting the latest record value, derived rkey, loading status, emptiness, and any error.
28
28
*/
29
-
export function useLatestRecord<T = unknown>(handleOrDid: string | undefined, collection: string): LatestRecordState<T> {
30
-
const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
31
-
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
32
-
const [state, setState] = useState<LatestRecordState<T>>({ loading: !!handleOrDid, empty: false });
29
+
export function useLatestRecord<T = unknown>(
30
+
handleOrDid: string | undefined,
31
+
collection: string,
32
+
): LatestRecordState<T> {
33
+
const {
34
+
did,
35
+
error: didError,
36
+
loading: resolvingDid,
37
+
} = useDidResolution(handleOrDid);
38
+
const {
39
+
endpoint,
40
+
error: endpointError,
41
+
loading: resolvingEndpoint,
42
+
} = usePdsEndpoint(did);
43
+
const [state, setState] = useState<LatestRecordState<T>>({
44
+
loading: !!handleOrDid,
45
+
empty: false,
46
+
});
33
47
34
-
useEffect(() => {
35
-
let cancelled = false;
48
+
useEffect(() => {
49
+
let cancelled = false;
36
50
37
-
const assign = (next: Partial<LatestRecordState<T>>) => {
38
-
if (cancelled) return;
39
-
setState(prev => ({ ...prev, ...next }));
40
-
};
51
+
const assign = (next: Partial<LatestRecordState<T>>) => {
52
+
if (cancelled) return;
53
+
setState((prev) => ({ ...prev, ...next }));
54
+
};
41
55
42
-
if (!handleOrDid) {
43
-
assign({ loading: false, record: undefined, rkey: undefined, error: undefined, empty: false });
44
-
return () => { cancelled = true; };
45
-
}
56
+
if (!handleOrDid) {
57
+
assign({
58
+
loading: false,
59
+
record: undefined,
60
+
rkey: undefined,
61
+
error: undefined,
62
+
empty: false,
63
+
});
64
+
return () => {
65
+
cancelled = true;
66
+
};
67
+
}
46
68
47
-
if (didError) {
48
-
assign({ loading: false, error: didError, empty: false });
49
-
return () => { cancelled = true; };
50
-
}
69
+
if (didError) {
70
+
assign({ loading: false, error: didError, empty: false });
71
+
return () => {
72
+
cancelled = true;
73
+
};
74
+
}
51
75
52
-
if (endpointError) {
53
-
assign({ loading: false, error: endpointError, empty: false });
54
-
return () => { cancelled = true; };
55
-
}
76
+
if (endpointError) {
77
+
assign({ loading: false, error: endpointError, empty: false });
78
+
return () => {
79
+
cancelled = true;
80
+
};
81
+
}
56
82
57
-
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
58
-
assign({ loading: true, error: undefined });
59
-
return () => { cancelled = true; };
60
-
}
83
+
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
84
+
assign({ loading: true, error: undefined });
85
+
return () => {
86
+
cancelled = true;
87
+
};
88
+
}
61
89
62
-
assign({ loading: true, error: undefined, empty: false });
90
+
assign({ loading: true, error: undefined, empty: false });
63
91
64
-
(async () => {
65
-
try {
66
-
const { rpc } = await createAtprotoClient({ service: endpoint });
67
-
const res = await (rpc as unknown as {
68
-
get: (
69
-
nsid: string,
70
-
opts: { params: Record<string, string | number | boolean> }
71
-
) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }> } }>;
72
-
}).get('com.atproto.repo.listRecords', {
73
-
params: { repo: did, collection, limit: 1, reverse: false }
74
-
});
75
-
if (!res.ok) throw new Error('Failed to list records');
76
-
const list = res.data.records;
77
-
if (list.length === 0) {
78
-
assign({ loading: false, empty: true, record: undefined, rkey: undefined });
79
-
return;
80
-
}
81
-
const first = list[0];
82
-
const derivedRkey = first.rkey ?? extractRkey(first.uri);
83
-
assign({ record: first.value, rkey: derivedRkey, loading: false, empty: false });
84
-
} catch (e) {
85
-
assign({ error: e as Error, loading: false, empty: false });
86
-
}
87
-
})();
92
+
(async () => {
93
+
try {
94
+
const { rpc } = await createAtprotoClient({
95
+
service: endpoint,
96
+
});
97
+
const res = await (
98
+
rpc as unknown as {
99
+
get: (
100
+
nsid: string,
101
+
opts: {
102
+
params: Record<
103
+
string,
104
+
string | number | boolean
105
+
>;
106
+
},
107
+
) => Promise<{
108
+
ok: boolean;
109
+
data: {
110
+
records: Array<{
111
+
uri: string;
112
+
rkey?: string;
113
+
value: T;
114
+
}>;
115
+
};
116
+
}>;
117
+
}
118
+
).get("com.atproto.repo.listRecords", {
119
+
params: { repo: did, collection, limit: 1, reverse: false },
120
+
});
121
+
if (!res.ok) throw new Error("Failed to list records");
122
+
const list = res.data.records;
123
+
if (list.length === 0) {
124
+
assign({
125
+
loading: false,
126
+
empty: true,
127
+
record: undefined,
128
+
rkey: undefined,
129
+
});
130
+
return;
131
+
}
132
+
const first = list[0];
133
+
const derivedRkey = first.rkey ?? extractRkey(first.uri);
134
+
assign({
135
+
record: first.value,
136
+
rkey: derivedRkey,
137
+
loading: false,
138
+
empty: false,
139
+
});
140
+
} catch (e) {
141
+
assign({ error: e as Error, loading: false, empty: false });
142
+
}
143
+
})();
88
144
89
-
return () => {
90
-
cancelled = true;
91
-
};
92
-
}, [handleOrDid, did, endpoint, collection, resolvingDid, resolvingEndpoint, didError, endpointError]);
145
+
return () => {
146
+
cancelled = true;
147
+
};
148
+
}, [
149
+
handleOrDid,
150
+
did,
151
+
endpoint,
152
+
collection,
153
+
resolvingDid,
154
+
resolvingEndpoint,
155
+
didError,
156
+
endpointError,
157
+
]);
93
158
94
-
return state;
159
+
return state;
95
160
}
96
161
97
162
function extractRkey(uri: string): string | undefined {
98
-
if (!uri) return undefined;
99
-
const parts = uri.split('/');
100
-
return parts[parts.length - 1];
163
+
if (!uri) return undefined;
164
+
const parts = uri.split("/");
165
+
return parts[parts.length - 1];
101
166
}
+412
-318
lib/hooks/usePaginatedRecords.ts
+412
-318
lib/hooks/usePaginatedRecords.ts
···
1
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
-
import { useDidResolution } from './useDidResolution';
3
-
import { usePdsEndpoint } from './usePdsEndpoint';
4
-
import { createAtprotoClient } from '../utils/atproto-client';
1
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+
import { useDidResolution } from "./useDidResolution";
3
+
import { usePdsEndpoint } from "./usePdsEndpoint";
4
+
import { createAtprotoClient } from "../utils/atproto-client";
5
5
6
6
/**
7
7
* Record envelope returned by paginated AT Protocol queries.
8
8
*/
9
9
export interface PaginatedRecord<T> {
10
-
/** Fully qualified AT URI for the record. */
11
-
uri: string;
12
-
/** Record key extracted from the URI or provided by the API. */
13
-
rkey: string;
14
-
/** Raw record value. */
15
-
value: T;
16
-
/** Optional feed metadata (for example, repost context). */
17
-
reason?: AuthorFeedReason;
18
-
/** Optional reply context derived from feed metadata. */
19
-
replyParent?: ReplyParentInfo;
10
+
/** Fully qualified AT URI for the record. */
11
+
uri: string;
12
+
/** Record key extracted from the URI or provided by the API. */
13
+
rkey: string;
14
+
/** Raw record value. */
15
+
value: T;
16
+
/** Optional feed metadata (for example, repost context). */
17
+
reason?: AuthorFeedReason;
18
+
/** Optional reply context derived from feed metadata. */
19
+
replyParent?: ReplyParentInfo;
20
20
}
21
21
22
22
interface PageData<T> {
23
-
records: PaginatedRecord<T>[];
24
-
cursor?: string;
23
+
records: PaginatedRecord<T>[];
24
+
cursor?: string;
25
25
}
26
26
27
27
/**
28
28
* Options accepted by {@link usePaginatedRecords}.
29
29
*/
30
30
export interface UsePaginatedRecordsOptions {
31
-
/** DID or handle whose repository should be queried. */
32
-
did?: string;
33
-
/** NSID collection containing the target records. */
34
-
collection: string;
35
-
/** Maximum page size to request; defaults to `5`. */
36
-
limit?: number;
37
-
/** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
38
-
preferAuthorFeed?: boolean;
39
-
/** Optional filter applied when fetching from the appview author feed. */
40
-
authorFeedFilter?: AuthorFeedFilter;
41
-
/** Whether to include pinned posts when fetching from the author feed. */
42
-
authorFeedIncludePins?: boolean;
43
-
/** Override for the appview service base URL used to query the author feed. */
44
-
authorFeedService?: string;
45
-
/** Optional explicit actor identifier for the author feed request. */
46
-
authorFeedActor?: string;
31
+
/** DID or handle whose repository should be queried. */
32
+
did?: string;
33
+
/** NSID collection containing the target records. */
34
+
collection: string;
35
+
/** Maximum page size to request; defaults to `5`. */
36
+
limit?: number;
37
+
/** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
38
+
preferAuthorFeed?: boolean;
39
+
/** Optional filter applied when fetching from the appview author feed. */
40
+
authorFeedFilter?: AuthorFeedFilter;
41
+
/** Whether to include pinned posts when fetching from the author feed. */
42
+
authorFeedIncludePins?: boolean;
43
+
/** Override for the appview service base URL used to query the author feed. */
44
+
authorFeedService?: string;
45
+
/** Optional explicit actor identifier for the author feed request. */
46
+
authorFeedActor?: string;
47
47
}
48
48
49
49
/**
50
50
* Result returned from {@link usePaginatedRecords} describing records and pagination state.
51
51
*/
52
52
export interface UsePaginatedRecordsResult<T> {
53
-
/** Records for the active page. */
54
-
records: PaginatedRecord<T>[];
55
-
/** Indicates whether a page load is in progress. */
56
-
loading: boolean;
57
-
/** Error produced during the latest fetch, if any. */
58
-
error?: Error;
59
-
/** `true` when another page can be fetched forward. */
60
-
hasNext: boolean;
61
-
/** `true` when a previous page exists in memory. */
62
-
hasPrev: boolean;
63
-
/** Requests the next page (if available). */
64
-
loadNext: () => void;
65
-
/** Returns to the previous page when possible. */
66
-
loadPrev: () => void;
67
-
/** Index of the currently displayed page. */
68
-
pageIndex: number;
69
-
/** Number of pages fetched so far (or inferred total when known). */
70
-
pagesCount: number;
53
+
/** Records for the active page. */
54
+
records: PaginatedRecord<T>[];
55
+
/** Indicates whether a page load is in progress. */
56
+
loading: boolean;
57
+
/** Error produced during the latest fetch, if any. */
58
+
error?: Error;
59
+
/** `true` when another page can be fetched forward. */
60
+
hasNext: boolean;
61
+
/** `true` when a previous page exists in memory. */
62
+
hasPrev: boolean;
63
+
/** Requests the next page (if available). */
64
+
loadNext: () => void;
65
+
/** Returns to the previous page when possible. */
66
+
loadPrev: () => void;
67
+
/** Index of the currently displayed page. */
68
+
pageIndex: number;
69
+
/** Number of pages fetched so far (or inferred total when known). */
70
+
pagesCount: number;
71
71
}
72
72
73
-
const DEFAULT_APPVIEW_SERVICE = 'https://public.api.bsky.app';
73
+
const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
74
74
75
75
export type AuthorFeedFilter =
76
-
| 'posts_with_replies'
77
-
| 'posts_no_replies'
78
-
| 'posts_with_media'
79
-
| 'posts_and_author_threads'
80
-
| 'posts_with_video';
76
+
| "posts_with_replies"
77
+
| "posts_no_replies"
78
+
| "posts_with_media"
79
+
| "posts_and_author_threads"
80
+
| "posts_with_video";
81
81
82
82
export interface AuthorFeedReason {
83
-
$type?: string;
84
-
by?: {
85
-
handle?: string;
86
-
did?: string;
87
-
};
88
-
indexedAt?: string;
83
+
$type?: string;
84
+
by?: {
85
+
handle?: string;
86
+
did?: string;
87
+
};
88
+
indexedAt?: string;
89
89
}
90
90
91
91
export interface ReplyParentInfo {
92
-
uri?: string;
93
-
author?: {
94
-
handle?: string;
95
-
did?: string;
96
-
};
92
+
uri?: string;
93
+
author?: {
94
+
handle?: string;
95
+
did?: string;
96
+
};
97
97
}
98
98
99
99
/**
···
105
105
* @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
106
106
*/
107
107
export function usePaginatedRecords<T>({
108
-
did: handleOrDid,
109
-
collection,
110
-
limit = 5,
111
-
preferAuthorFeed = false,
112
-
authorFeedFilter,
113
-
authorFeedIncludePins,
114
-
authorFeedService,
115
-
authorFeedActor
108
+
did: handleOrDid,
109
+
collection,
110
+
limit = 5,
111
+
preferAuthorFeed = false,
112
+
authorFeedFilter,
113
+
authorFeedIncludePins,
114
+
authorFeedService,
115
+
authorFeedActor,
116
116
}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
117
-
const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
118
-
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
119
-
const [pages, setPages] = useState<PageData<T>[]>([]);
120
-
const [pageIndex, setPageIndex] = useState(0);
121
-
const [loading, setLoading] = useState(false);
122
-
const [error, setError] = useState<Error | undefined>(undefined);
123
-
const inFlight = useRef<Set<string>>(new Set());
124
-
const requestSeq = useRef(0);
125
-
const identityRef = useRef<string | undefined>(undefined);
126
-
const feedDisabledRef = useRef(false);
117
+
const {
118
+
did,
119
+
handle,
120
+
error: didError,
121
+
loading: resolvingDid,
122
+
} = useDidResolution(handleOrDid);
123
+
const {
124
+
endpoint,
125
+
error: endpointError,
126
+
loading: resolvingEndpoint,
127
+
} = usePdsEndpoint(did);
128
+
const [pages, setPages] = useState<PageData<T>[]>([]);
129
+
const [pageIndex, setPageIndex] = useState(0);
130
+
const [loading, setLoading] = useState(false);
131
+
const [error, setError] = useState<Error | undefined>(undefined);
132
+
const inFlight = useRef<Set<string>>(new Set());
133
+
const requestSeq = useRef(0);
134
+
const identityRef = useRef<string | undefined>(undefined);
135
+
const feedDisabledRef = useRef(false);
127
136
128
-
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
129
-
const normalizedInput = useMemo(() => {
130
-
if (!handleOrDid) return undefined;
131
-
const trimmed = handleOrDid.trim();
132
-
return trimmed || undefined;
133
-
}, [handleOrDid]);
137
+
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
138
+
const normalizedInput = useMemo(() => {
139
+
if (!handleOrDid) return undefined;
140
+
const trimmed = handleOrDid.trim();
141
+
return trimmed || undefined;
142
+
}, [handleOrDid]);
134
143
135
-
const actorIdentifier = useMemo(() => {
136
-
const explicit = authorFeedActor?.trim();
137
-
if (explicit) return explicit;
138
-
if (handle) return handle;
139
-
if (normalizedInput) return normalizedInput;
140
-
if (did) return did;
141
-
return undefined;
142
-
}, [authorFeedActor, handle, normalizedInput, did]);
144
+
const actorIdentifier = useMemo(() => {
145
+
const explicit = authorFeedActor?.trim();
146
+
if (explicit) return explicit;
147
+
if (handle) return handle;
148
+
if (normalizedInput) return normalizedInput;
149
+
if (did) return did;
150
+
return undefined;
151
+
}, [authorFeedActor, handle, normalizedInput, did]);
143
152
144
-
const resetState = useCallback(() => {
145
-
setPages([]);
146
-
setPageIndex(0);
147
-
setError(undefined);
148
-
inFlight.current.clear();
149
-
requestSeq.current += 1;
150
-
feedDisabledRef.current = false;
151
-
}, []);
153
+
const resetState = useCallback(() => {
154
+
setPages([]);
155
+
setPageIndex(0);
156
+
setError(undefined);
157
+
inFlight.current.clear();
158
+
requestSeq.current += 1;
159
+
feedDisabledRef.current = false;
160
+
}, []);
152
161
153
-
const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
154
-
if (!did || !endpoint) return;
155
-
const currentIdentity = `${did}::${endpoint}`;
156
-
if (identityKey !== currentIdentity) return;
157
-
const token = requestSeq.current;
158
-
const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`;
159
-
if (inFlight.current.has(key)) return;
160
-
inFlight.current.add(key);
161
-
if (mode === 'active') {
162
-
setLoading(true);
163
-
setError(undefined);
164
-
}
165
-
try {
166
-
let nextCursor: string | undefined;
167
-
let mapped: PaginatedRecord<T>[] | undefined;
162
+
const fetchPage = useCallback(
163
+
async (
164
+
identityKey: string,
165
+
cursor: string | undefined,
166
+
targetIndex: number,
167
+
mode: "active" | "prefetch",
168
+
) => {
169
+
if (!did || !endpoint) return;
170
+
const currentIdentity = `${did}::${endpoint}`;
171
+
if (identityKey !== currentIdentity) return;
172
+
const token = requestSeq.current;
173
+
const key = `${identityKey}:${targetIndex}:${cursor ?? "start"}`;
174
+
if (inFlight.current.has(key)) return;
175
+
inFlight.current.add(key);
176
+
if (mode === "active") {
177
+
setLoading(true);
178
+
setError(undefined);
179
+
}
180
+
try {
181
+
let nextCursor: string | undefined;
182
+
let mapped: PaginatedRecord<T>[] | undefined;
168
183
169
-
const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier;
170
-
if (shouldUseAuthorFeed) {
171
-
try {
172
-
const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
173
-
const res = await (rpc as unknown as {
174
-
get: (
175
-
nsid: string,
176
-
opts: { params: Record<string, string | number | boolean | undefined> }
177
-
) => Promise<{
178
-
ok: boolean;
179
-
data: {
180
-
feed?: Array<{
181
-
post?: {
182
-
uri?: string;
183
-
record?: T;
184
-
reply?: {
185
-
parent?: {
186
-
uri?: string;
187
-
author?: { handle?: string; did?: string };
188
-
};
189
-
};
190
-
};
191
-
reason?: AuthorFeedReason;
192
-
}>;
193
-
cursor?: string;
194
-
};
195
-
}>;
196
-
}).get('app.bsky.feed.getAuthorFeed', {
197
-
params: {
198
-
actor: actorIdentifier,
199
-
limit,
200
-
cursor,
201
-
filter: authorFeedFilter,
202
-
includePins: authorFeedIncludePins
203
-
}
204
-
});
205
-
if (!res.ok) throw new Error('Failed to fetch author feed');
206
-
const { feed, cursor: feedCursor } = res.data;
207
-
mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => {
208
-
const post = item?.post;
209
-
if (!post || typeof post.uri !== 'string' || !post.record) return acc;
210
-
acc.push({
211
-
uri: post.uri,
212
-
rkey: extractRkey(post.uri),
213
-
value: post.record as T,
214
-
reason: item?.reason,
215
-
replyParent: post.reply?.parent
216
-
});
217
-
return acc;
218
-
}, []);
219
-
nextCursor = feedCursor;
220
-
} catch (err) {
221
-
feedDisabledRef.current = true;
222
-
}
223
-
}
184
+
const shouldUseAuthorFeed =
185
+
preferAuthorFeed &&
186
+
collection === "app.bsky.feed.post" &&
187
+
!feedDisabledRef.current &&
188
+
!!actorIdentifier;
189
+
if (shouldUseAuthorFeed) {
190
+
try {
191
+
const { rpc } = await createAtprotoClient({
192
+
service:
193
+
authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
194
+
});
195
+
const res = await (
196
+
rpc as unknown as {
197
+
get: (
198
+
nsid: string,
199
+
opts: {
200
+
params: Record<
201
+
string,
202
+
| string
203
+
| number
204
+
| boolean
205
+
| undefined
206
+
>;
207
+
},
208
+
) => Promise<{
209
+
ok: boolean;
210
+
data: {
211
+
feed?: Array<{
212
+
post?: {
213
+
uri?: string;
214
+
record?: T;
215
+
reply?: {
216
+
parent?: {
217
+
uri?: string;
218
+
author?: {
219
+
handle?: string;
220
+
did?: string;
221
+
};
222
+
};
223
+
};
224
+
};
225
+
reason?: AuthorFeedReason;
226
+
}>;
227
+
cursor?: string;
228
+
};
229
+
}>;
230
+
}
231
+
).get("app.bsky.feed.getAuthorFeed", {
232
+
params: {
233
+
actor: actorIdentifier,
234
+
limit,
235
+
cursor,
236
+
filter: authorFeedFilter,
237
+
includePins: authorFeedIncludePins,
238
+
},
239
+
});
240
+
if (!res.ok)
241
+
throw new Error("Failed to fetch author feed");
242
+
const { feed, cursor: feedCursor } = res.data;
243
+
mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>(
244
+
(acc, item) => {
245
+
const post = item?.post;
246
+
if (
247
+
!post ||
248
+
typeof post.uri !== "string" ||
249
+
!post.record
250
+
)
251
+
return acc;
252
+
acc.push({
253
+
uri: post.uri,
254
+
rkey: extractRkey(post.uri),
255
+
value: post.record as T,
256
+
reason: item?.reason,
257
+
replyParent: post.reply?.parent,
258
+
});
259
+
return acc;
260
+
},
261
+
[],
262
+
);
263
+
nextCursor = feedCursor;
264
+
} catch (err) {
265
+
console.log(err);
266
+
feedDisabledRef.current = true;
267
+
}
268
+
}
224
269
225
-
if (!mapped) {
226
-
const { rpc } = await createAtprotoClient({ service: endpoint });
227
-
const res = await (rpc as unknown as {
228
-
get: (
229
-
nsid: string,
230
-
opts: { params: Record<string, string | number | boolean | undefined> }
231
-
) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
232
-
}).get('com.atproto.repo.listRecords', {
233
-
params: {
234
-
repo: did,
235
-
collection,
236
-
limit,
237
-
cursor,
238
-
reverse: false
239
-
}
240
-
});
241
-
if (!res.ok) throw new Error('Failed to list records');
242
-
const { records, cursor: repoCursor } = res.data;
243
-
mapped = records.map((item) => ({
244
-
uri: item.uri,
245
-
rkey: item.rkey ?? extractRkey(item.uri),
246
-
value: item.value
247
-
}));
248
-
nextCursor = repoCursor;
249
-
}
270
+
if (!mapped) {
271
+
const { rpc } = await createAtprotoClient({
272
+
service: endpoint,
273
+
});
274
+
const res = await (
275
+
rpc as unknown as {
276
+
get: (
277
+
nsid: string,
278
+
opts: {
279
+
params: Record<
280
+
string,
281
+
string | number | boolean | undefined
282
+
>;
283
+
},
284
+
) => Promise<{
285
+
ok: boolean;
286
+
data: {
287
+
records: Array<{
288
+
uri: string;
289
+
rkey?: string;
290
+
value: T;
291
+
}>;
292
+
cursor?: string;
293
+
};
294
+
}>;
295
+
}
296
+
).get("com.atproto.repo.listRecords", {
297
+
params: {
298
+
repo: did,
299
+
collection,
300
+
limit,
301
+
cursor,
302
+
reverse: false,
303
+
},
304
+
});
305
+
if (!res.ok) throw new Error("Failed to list records");
306
+
const { records, cursor: repoCursor } = res.data;
307
+
mapped = records.map((item) => ({
308
+
uri: item.uri,
309
+
rkey: item.rkey ?? extractRkey(item.uri),
310
+
value: item.value,
311
+
}));
312
+
nextCursor = repoCursor;
313
+
}
250
314
251
-
if (token !== requestSeq.current || identityKey !== identityRef.current) {
252
-
return nextCursor;
253
-
}
254
-
if (mode === 'active') setPageIndex(targetIndex);
255
-
setPages(prev => {
256
-
const next = [...prev];
257
-
next[targetIndex] = { records: mapped!, cursor: nextCursor };
258
-
return next;
259
-
});
260
-
return nextCursor;
261
-
} catch (e) {
262
-
if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
263
-
setError(e as Error);
264
-
}
265
-
} finally {
266
-
if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
267
-
setLoading(false);
268
-
}
269
-
inFlight.current.delete(key);
270
-
}
271
-
return undefined;
272
-
}, [
273
-
did,
274
-
endpoint,
275
-
collection,
276
-
limit,
277
-
preferAuthorFeed,
278
-
actorIdentifier,
279
-
authorFeedService,
280
-
authorFeedFilter,
281
-
authorFeedIncludePins
282
-
]);
315
+
if (
316
+
token !== requestSeq.current ||
317
+
identityKey !== identityRef.current
318
+
) {
319
+
return nextCursor;
320
+
}
321
+
if (mode === "active") setPageIndex(targetIndex);
322
+
setPages((prev) => {
323
+
const next = [...prev];
324
+
next[targetIndex] = {
325
+
records: mapped!,
326
+
cursor: nextCursor,
327
+
};
328
+
return next;
329
+
});
330
+
return nextCursor;
331
+
} catch (e) {
332
+
if (
333
+
mode === "active" &&
334
+
token === requestSeq.current &&
335
+
identityKey === identityRef.current
336
+
) {
337
+
setError(e as Error);
338
+
}
339
+
} finally {
340
+
if (
341
+
mode === "active" &&
342
+
token === requestSeq.current &&
343
+
identityKey === identityRef.current
344
+
) {
345
+
setLoading(false);
346
+
}
347
+
inFlight.current.delete(key);
348
+
}
349
+
return undefined;
350
+
},
351
+
[
352
+
did,
353
+
endpoint,
354
+
collection,
355
+
limit,
356
+
preferAuthorFeed,
357
+
actorIdentifier,
358
+
authorFeedService,
359
+
authorFeedFilter,
360
+
authorFeedIncludePins,
361
+
],
362
+
);
283
363
284
-
useEffect(() => {
285
-
if (!handleOrDid) {
286
-
identityRef.current = undefined;
287
-
resetState();
288
-
setLoading(false);
289
-
setError(undefined);
290
-
return;
291
-
}
364
+
useEffect(() => {
365
+
if (!handleOrDid) {
366
+
identityRef.current = undefined;
367
+
resetState();
368
+
setLoading(false);
369
+
setError(undefined);
370
+
return;
371
+
}
292
372
293
-
if (didError) {
294
-
identityRef.current = undefined;
295
-
resetState();
296
-
setLoading(false);
297
-
setError(didError);
298
-
return;
299
-
}
373
+
if (didError) {
374
+
identityRef.current = undefined;
375
+
resetState();
376
+
setLoading(false);
377
+
setError(didError);
378
+
return;
379
+
}
300
380
301
-
if (endpointError) {
302
-
identityRef.current = undefined;
303
-
resetState();
304
-
setLoading(false);
305
-
setError(endpointError);
306
-
return;
307
-
}
381
+
if (endpointError) {
382
+
identityRef.current = undefined;
383
+
resetState();
384
+
setLoading(false);
385
+
setError(endpointError);
386
+
return;
387
+
}
308
388
309
-
if (resolvingDid || resolvingEndpoint || !identity) {
310
-
if (identityRef.current !== identity) {
311
-
identityRef.current = identity;
312
-
resetState();
313
-
}
314
-
setLoading(!!handleOrDid);
315
-
setError(undefined);
316
-
return;
317
-
}
389
+
if (resolvingDid || resolvingEndpoint || !identity) {
390
+
if (identityRef.current !== identity) {
391
+
identityRef.current = identity;
392
+
resetState();
393
+
}
394
+
setLoading(!!handleOrDid);
395
+
setError(undefined);
396
+
return;
397
+
}
318
398
319
-
if (identityRef.current !== identity) {
320
-
identityRef.current = identity;
321
-
resetState();
322
-
}
399
+
if (identityRef.current !== identity) {
400
+
identityRef.current = identity;
401
+
resetState();
402
+
}
323
403
324
-
fetchPage(identity, undefined, 0, 'active').catch(() => {
325
-
/* error handled in state */
326
-
});
327
-
}, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
404
+
fetchPage(identity, undefined, 0, "active").catch(() => {
405
+
/* error handled in state */
406
+
});
407
+
}, [
408
+
handleOrDid,
409
+
identity,
410
+
fetchPage,
411
+
resetState,
412
+
resolvingDid,
413
+
resolvingEndpoint,
414
+
didError,
415
+
endpointError,
416
+
]);
328
417
329
-
const currentPage = pages[pageIndex];
330
-
const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
331
-
const hasPrev = pageIndex > 0;
418
+
const currentPage = pages[pageIndex];
419
+
const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
420
+
const hasPrev = pageIndex > 0;
332
421
333
-
const loadNext = useCallback(() => {
334
-
const identityKey = identityRef.current;
335
-
if (!identityKey) return;
336
-
const page = pages[pageIndex];
337
-
if (!page?.cursor && !pages[pageIndex + 1]) return;
338
-
if (pages[pageIndex + 1]) {
339
-
setPageIndex(pageIndex + 1);
340
-
return;
341
-
}
342
-
fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => {
343
-
/* handled via error state */
344
-
});
345
-
}, [fetchPage, pageIndex, pages]);
422
+
const loadNext = useCallback(() => {
423
+
const identityKey = identityRef.current;
424
+
if (!identityKey) return;
425
+
const page = pages[pageIndex];
426
+
if (!page?.cursor && !pages[pageIndex + 1]) return;
427
+
if (pages[pageIndex + 1]) {
428
+
setPageIndex(pageIndex + 1);
429
+
return;
430
+
}
431
+
fetchPage(identityKey, page.cursor, pageIndex + 1, "active").catch(
432
+
() => {
433
+
/* handled via error state */
434
+
},
435
+
);
436
+
}, [fetchPage, pageIndex, pages]);
346
437
347
-
const loadPrev = useCallback(() => {
348
-
if (pageIndex === 0) return;
349
-
setPageIndex(pageIndex - 1);
350
-
}, [pageIndex]);
438
+
const loadPrev = useCallback(() => {
439
+
if (pageIndex === 0) return;
440
+
setPageIndex(pageIndex - 1);
441
+
}, [pageIndex]);
351
442
352
-
const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
443
+
const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
353
444
354
-
const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
445
+
const effectiveError =
446
+
error ??
447
+
(endpointError as Error | undefined) ??
448
+
(didError as Error | undefined);
355
449
356
-
useEffect(() => {
357
-
const cursor = pages[pageIndex]?.cursor;
358
-
if (!cursor) return;
359
-
if (pages[pageIndex + 1]) return;
360
-
const identityKey = identityRef.current;
361
-
if (!identityKey) return;
362
-
fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => {
363
-
/* ignore prefetch errors */
364
-
});
365
-
}, [fetchPage, pageIndex, pages]);
450
+
useEffect(() => {
451
+
const cursor = pages[pageIndex]?.cursor;
452
+
if (!cursor) return;
453
+
if (pages[pageIndex + 1]) return;
454
+
const identityKey = identityRef.current;
455
+
if (!identityKey) return;
456
+
fetchPage(identityKey, cursor, pageIndex + 1, "prefetch").catch(() => {
457
+
/* ignore prefetch errors */
458
+
});
459
+
}, [fetchPage, pageIndex, pages]);
366
460
367
-
return {
368
-
records,
369
-
loading,
370
-
error: effectiveError,
371
-
hasNext,
372
-
hasPrev,
373
-
loadNext,
374
-
loadPrev,
375
-
pageIndex,
376
-
pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
377
-
};
461
+
return {
462
+
records,
463
+
loading,
464
+
error: effectiveError,
465
+
hasNext,
466
+
hasPrev,
467
+
loadNext,
468
+
loadPrev,
469
+
pageIndex,
470
+
pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0),
471
+
};
378
472
}
379
473
380
474
function extractRkey(uri: string): string {
381
-
const parts = uri.split('/');
382
-
return parts[parts.length - 1];
475
+
const parts = uri.split("/");
476
+
return parts[parts.length - 1];
383
477
}
+15
-8
lib/hooks/usePdsEndpoint.ts
+15
-8
lib/hooks/usePdsEndpoint.ts
···
1
-
import { useEffect, useState } from 'react';
2
-
import { useAtProto } from '../providers/AtProtoProvider';
1
+
import { useEffect, useState } from "react";
2
+
import { useAtProto } from "../providers/AtProtoProvider";
3
3
4
4
/**
5
5
* Resolves the PDS service endpoint for a given DID and tracks loading state.
···
19
19
setEndpoint(undefined);
20
20
setError(undefined);
21
21
setLoading(false);
22
-
return () => { cancelled = true; };
22
+
return () => {
23
+
cancelled = true;
24
+
};
23
25
}
24
26
25
27
const cached = didCache.getByDid(did);
···
27
29
setEndpoint(cached.pdsEndpoint);
28
30
setError(undefined);
29
31
setLoading(false);
30
-
return () => { cancelled = true; };
32
+
return () => {
33
+
cancelled = true;
34
+
};
31
35
}
32
36
33
37
setEndpoint(undefined);
34
38
setLoading(true);
35
39
setError(undefined);
36
-
didCache.ensurePdsEndpoint(resolver, did)
37
-
.then(snapshot => {
40
+
didCache
41
+
.ensurePdsEndpoint(resolver, did)
42
+
.then((snapshot) => {
38
43
if (cancelled) return;
39
44
setEndpoint(snapshot.pdsEndpoint);
40
45
})
41
-
.catch(e => {
46
+
.catch((e) => {
42
47
if (cancelled) return;
43
48
setError(e as Error);
44
49
})
45
50
.finally(() => {
46
51
if (!cancelled) setLoading(false);
47
52
});
48
-
return () => { cancelled = true; };
53
+
return () => {
54
+
cancelled = true;
55
+
};
49
56
}, [did, resolver, didCache]);
50
57
51
58
return { endpoint, error, loading };
+27
-27
lib/index.ts
+27
-27
lib/index.ts
···
1
1
// Master exporter for the AT React component library.
2
2
3
3
// Providers & core primitives
4
-
export * from './providers/AtProtoProvider';
5
-
export * from './core/AtProtoRecord';
4
+
export * from "./providers/AtProtoProvider";
5
+
export * from "./core/AtProtoRecord";
6
6
7
7
// Components
8
-
export * from './components/BlueskyIcon';
9
-
export * from './components/BlueskyPost';
10
-
export * from './components/BlueskyPostList';
11
-
export * from './components/BlueskyProfile';
12
-
export * from './components/BlueskyQuotePost';
13
-
export * from './components/ColorSchemeToggle';
14
-
export * from './components/LeafletDocument';
15
-
export * from './components/TangledString';
8
+
export * from "./components/BlueskyIcon";
9
+
export * from "./components/BlueskyPost";
10
+
export * from "./components/BlueskyPostList";
11
+
export * from "./components/BlueskyProfile";
12
+
export * from "./components/BlueskyQuotePost";
13
+
export * from "./components/ColorSchemeToggle";
14
+
export * from "./components/LeafletDocument";
15
+
export * from "./components/TangledString";
16
16
17
17
// Hooks
18
-
export * from './hooks/useAtProtoRecord';
19
-
export * from './hooks/useBlob';
20
-
export * from './hooks/useBlueskyProfile';
21
-
export * from './hooks/useColorScheme';
22
-
export * from './hooks/useDidResolution';
23
-
export * from './hooks/useLatestRecord';
24
-
export * from './hooks/usePaginatedRecords';
25
-
export * from './hooks/usePdsEndpoint';
18
+
export * from "./hooks/useAtProtoRecord";
19
+
export * from "./hooks/useBlob";
20
+
export * from "./hooks/useBlueskyProfile";
21
+
export * from "./hooks/useColorScheme";
22
+
export * from "./hooks/useDidResolution";
23
+
export * from "./hooks/useLatestRecord";
24
+
export * from "./hooks/usePaginatedRecords";
25
+
export * from "./hooks/usePdsEndpoint";
26
26
27
27
// Renderers
28
-
export * from './renderers/BlueskyPostRenderer';
29
-
export * from './renderers/BlueskyProfileRenderer';
30
-
export * from './renderers/LeafletDocumentRenderer';
31
-
export * from './renderers/TangledStringRenderer';
28
+
export * from "./renderers/BlueskyPostRenderer";
29
+
export * from "./renderers/BlueskyProfileRenderer";
30
+
export * from "./renderers/LeafletDocumentRenderer";
31
+
export * from "./renderers/TangledStringRenderer";
32
32
33
33
// Types
34
-
export * from './types/bluesky';
35
-
export * from './types/leaflet';
34
+
export * from "./types/bluesky";
35
+
export * from "./types/leaflet";
36
36
37
37
// Utilities
38
-
export * from './utils/at-uri';
39
-
export * from './utils/atproto-client';
40
-
export * from './utils/profile';
38
+
export * from "./utils/at-uri";
39
+
export * from "./utils/atproto-client";
40
+
export * from "./utils/profile";
+46
-17
lib/providers/AtProtoProvider.tsx
+46
-17
lib/providers/AtProtoProvider.tsx
···
1
1
/* eslint-disable react-refresh/only-export-components */
2
-
import React, { createContext, useContext, useMemo, useRef } from 'react';
3
-
import { ServiceResolver, normalizeBaseUrl } from '../utils/atproto-client';
4
-
import { BlobCache, DidCache } from '../utils/cache';
2
+
import React, { createContext, useContext, useMemo, useRef } from "react";
3
+
import { ServiceResolver, normalizeBaseUrl } from "../utils/atproto-client";
4
+
import { BlobCache, DidCache } from "../utils/cache";
5
5
6
6
export interface AtProtoProviderProps {
7
7
children: React.ReactNode;
···
15
15
blobCache: BlobCache;
16
16
}
17
17
18
-
const AtProtoContext = createContext<AtProtoContextValue | undefined>(undefined);
18
+
const AtProtoContext = createContext<AtProtoContextValue | undefined>(
19
+
undefined,
20
+
);
19
21
20
-
export function AtProtoProvider({ children, plcDirectory }: AtProtoProviderProps) {
21
-
const normalizedPlc = useMemo(() => normalizeBaseUrl(plcDirectory && plcDirectory.trim() ? plcDirectory : 'https://plc.directory'), [plcDirectory]);
22
-
const resolver = useMemo(() => new ServiceResolver({ plcDirectory: normalizedPlc }), [normalizedPlc]);
23
-
const cachesRef = useRef<{ didCache: DidCache; blobCache: BlobCache } | null>(null);
22
+
export function AtProtoProvider({
23
+
children,
24
+
plcDirectory,
25
+
}: AtProtoProviderProps) {
26
+
const normalizedPlc = useMemo(
27
+
() =>
28
+
normalizeBaseUrl(
29
+
plcDirectory && plcDirectory.trim()
30
+
? plcDirectory
31
+
: "https://plc.directory",
32
+
),
33
+
[plcDirectory],
34
+
);
35
+
const resolver = useMemo(
36
+
() => new ServiceResolver({ plcDirectory: normalizedPlc }),
37
+
[normalizedPlc],
38
+
);
39
+
const cachesRef = useRef<{
40
+
didCache: DidCache;
41
+
blobCache: BlobCache;
42
+
} | null>(null);
24
43
if (!cachesRef.current) {
25
-
cachesRef.current = { didCache: new DidCache(), blobCache: new BlobCache() };
44
+
cachesRef.current = {
45
+
didCache: new DidCache(),
46
+
blobCache: new BlobCache(),
47
+
};
26
48
}
27
-
const value = useMemo<AtProtoContextValue>(() => ({
28
-
resolver,
29
-
plcDirectory: normalizedPlc,
30
-
didCache: cachesRef.current!.didCache,
31
-
blobCache: cachesRef.current!.blobCache,
32
-
}), [resolver, normalizedPlc]);
33
-
return <AtProtoContext.Provider value={value}>{children}</AtProtoContext.Provider>;
49
+
const value = useMemo<AtProtoContextValue>(
50
+
() => ({
51
+
resolver,
52
+
plcDirectory: normalizedPlc,
53
+
didCache: cachesRef.current!.didCache,
54
+
blobCache: cachesRef.current!.blobCache,
55
+
}),
56
+
[resolver, normalizedPlc],
57
+
);
58
+
return (
59
+
<AtProtoContext.Provider value={value}>
60
+
{children}
61
+
</AtProtoContext.Provider>
62
+
);
34
63
}
35
64
36
65
export function useAtProto() {
37
66
const ctx = useContext(AtProtoContext);
38
-
if (!ctx) throw new Error('useAtProto must be used within AtProtoProvider');
67
+
if (!ctx) throw new Error("useAtProto must be used within AtProtoProvider");
39
68
return ctx;
40
69
}
+564
-428
lib/renderers/BlueskyPostRenderer.tsx
+564
-428
lib/renderers/BlueskyPostRenderer.tsx
···
1
-
import React from 'react';
2
-
import type { FeedPostRecord } from '../types/bluesky';
3
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
4
-
import { parseAtUri, toBlueskyPostUrl, formatDidForLabel, type ParsedAtUri } from '../utils/at-uri';
5
-
import { useDidResolution } from '../hooks/useDidResolution';
6
-
import { useBlob } from '../hooks/useBlob';
7
-
import { BlueskyIcon } from '../components/BlueskyIcon';
1
+
import React from "react";
2
+
import type { FeedPostRecord } from "../types/bluesky";
3
+
import {
4
+
useColorScheme,
5
+
type ColorSchemePreference,
6
+
} from "../hooks/useColorScheme";
7
+
import {
8
+
parseAtUri,
9
+
toBlueskyPostUrl,
10
+
formatDidForLabel,
11
+
type ParsedAtUri,
12
+
} from "../utils/at-uri";
13
+
import { useDidResolution } from "../hooks/useDidResolution";
14
+
import { useBlob } from "../hooks/useBlob";
15
+
import { BlueskyIcon } from "../components/BlueskyIcon";
8
16
9
17
export interface BlueskyPostRendererProps {
10
-
record: FeedPostRecord;
11
-
loading: boolean;
12
-
error?: Error;
13
-
// Optionally pass in actor display info if pre-fetched
14
-
authorHandle?: string;
15
-
authorDisplayName?: string;
16
-
avatarUrl?: string;
17
-
colorScheme?: ColorSchemePreference;
18
-
authorDid?: string;
19
-
embed?: React.ReactNode;
20
-
iconPlacement?: 'cardBottomRight' | 'timestamp' | 'linkInline';
21
-
showIcon?: boolean;
22
-
atUri?: string;
18
+
record: FeedPostRecord;
19
+
loading: boolean;
20
+
error?: Error;
21
+
// Optionally pass in actor display info if pre-fetched
22
+
authorHandle?: string;
23
+
authorDisplayName?: string;
24
+
avatarUrl?: string;
25
+
colorScheme?: ColorSchemePreference;
26
+
authorDid?: string;
27
+
embed?: React.ReactNode;
28
+
iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
29
+
showIcon?: boolean;
30
+
atUri?: string;
23
31
}
24
32
25
-
export const BlueskyPostRenderer: React.FC<BlueskyPostRendererProps> = ({ record, loading, error, authorDisplayName, authorHandle, avatarUrl, colorScheme = 'system', authorDid, embed, iconPlacement = 'timestamp', showIcon = true, atUri }) => {
26
-
const scheme = useColorScheme(colorScheme);
27
-
const replyParentUri = record.reply?.parent?.uri;
28
-
const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined;
29
-
const { handle: parentHandle, loading: parentHandleLoading } = useDidResolution(replyTarget?.did);
33
+
export const BlueskyPostRenderer: React.FC<BlueskyPostRendererProps> = ({
34
+
record,
35
+
loading,
36
+
error,
37
+
authorDisplayName,
38
+
authorHandle,
39
+
avatarUrl,
40
+
colorScheme = "system",
41
+
authorDid,
42
+
embed,
43
+
iconPlacement = "timestamp",
44
+
showIcon = true,
45
+
atUri,
46
+
}) => {
47
+
const scheme = useColorScheme(colorScheme);
48
+
const replyParentUri = record.reply?.parent?.uri;
49
+
const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined;
50
+
const { handle: parentHandle, loading: parentHandleLoading } =
51
+
useDidResolution(replyTarget?.did);
30
52
31
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load post.</div>;
32
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
53
+
if (error)
54
+
return (
55
+
<div style={{ padding: 8, color: "crimson" }}>
56
+
Failed to load post.
57
+
</div>
58
+
);
59
+
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
33
60
34
-
const palette = scheme === 'dark' ? themeStyles.dark : themeStyles.light;
61
+
const palette = scheme === "dark" ? themeStyles.dark : themeStyles.light;
35
62
36
-
const text = record.text;
37
-
const createdDate = new Date(record.createdAt);
38
-
const created = createdDate.toLocaleString(undefined, {
39
-
dateStyle: 'medium',
40
-
timeStyle: 'short'
41
-
});
42
-
const primaryName = authorDisplayName || authorHandle || '…';
43
-
const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined;
44
-
const replyLabel = replyTarget ? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading) : undefined;
63
+
const text = record.text;
64
+
const createdDate = new Date(record.createdAt);
65
+
const created = createdDate.toLocaleString(undefined, {
66
+
dateStyle: "medium",
67
+
timeStyle: "short",
68
+
});
69
+
const primaryName = authorDisplayName || authorHandle || "…";
70
+
const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined;
71
+
const replyLabel = replyTarget
72
+
? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading)
73
+
: undefined;
45
74
46
-
const makeIcon = () => (showIcon ? <BlueskyIcon size={16} /> : null);
47
-
const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid, scheme);
48
-
const parsedSelf = atUri ? parseAtUri(atUri) : undefined;
49
-
const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined;
50
-
const cardPadding = typeof baseStyles.card.padding === 'number' ? baseStyles.card.padding : 12;
51
-
const cardStyle: React.CSSProperties = {
52
-
...baseStyles.card,
53
-
...palette.card,
54
-
...(iconPlacement === 'cardBottomRight' && showIcon ? { paddingBottom: cardPadding + 16 } : {})
55
-
};
75
+
const makeIcon = () => (showIcon ? <BlueskyIcon size={16} /> : null);
76
+
const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid, scheme);
77
+
const parsedSelf = atUri ? parseAtUri(atUri) : undefined;
78
+
const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined;
79
+
const cardPadding =
80
+
typeof baseStyles.card.padding === "number"
81
+
? baseStyles.card.padding
82
+
: 12;
83
+
const cardStyle: React.CSSProperties = {
84
+
...baseStyles.card,
85
+
...palette.card,
86
+
...(iconPlacement === "cardBottomRight" && showIcon
87
+
? { paddingBottom: cardPadding + 16 }
88
+
: {}),
89
+
};
56
90
57
-
return (
58
-
<article style={cardStyle} aria-busy={loading}>
59
-
<header style={baseStyles.header}>
60
-
{avatarUrl ? (
61
-
<img src={avatarUrl} alt="avatar" style={baseStyles.avatarImg} />
62
-
) : (
63
-
<div style={{ ...baseStyles.avatarPlaceholder, ...palette.avatarPlaceholder }} aria-hidden />
64
-
)}
65
-
<div style={{ display: 'flex', flexDirection: 'column' }}>
66
-
<strong style={{ fontSize: 14 }}>{primaryName}</strong>
67
-
{authorDisplayName && authorHandle && <span style={{ ...baseStyles.handle, ...palette.handle }}>@{authorHandle}</span>}
68
-
</div>
69
-
{iconPlacement === 'timestamp' && showIcon && (
70
-
<div style={baseStyles.headerIcon}>{makeIcon()}</div>
71
-
)}
72
-
</header>
73
-
{replyHref && replyLabel && (
74
-
<div style={{ ...baseStyles.replyLine, ...palette.replyLine }}>
75
-
Replying to{' '}
76
-
<a href={replyHref} target="_blank" rel="noopener noreferrer" style={{ ...baseStyles.replyLink, ...palette.replyLink }}>
77
-
{replyLabel}
78
-
</a>
79
-
</div>
80
-
)}
81
-
<div style={baseStyles.body}>
82
-
<p style={{ ...baseStyles.text, ...palette.text }}>{text}</p>
83
-
{record.facets && record.facets.length > 0 && (
84
-
<div style={baseStyles.facets}>
85
-
{record.facets.map((_, idx) => (
86
-
<span key={idx} style={{ ...baseStyles.facetTag, ...palette.facetTag }}>facet</span>
87
-
))}
88
-
</div>
89
-
)}
90
-
<div style={baseStyles.timestampRow}>
91
-
<time style={{ ...baseStyles.time, ...palette.time }} dateTime={record.createdAt}>{created}</time>
92
-
{postUrl && (
93
-
<span style={baseStyles.linkWithIcon}>
94
-
<a href={postUrl} target="_blank" rel="noopener noreferrer" style={{ ...baseStyles.postLink, ...palette.postLink }}>
95
-
View on Bluesky
96
-
</a>
97
-
{iconPlacement === 'linkInline' && showIcon && (
98
-
<span style={baseStyles.inlineIcon} aria-hidden>
99
-
{makeIcon()}
100
-
</span>
101
-
)}
102
-
</span>
103
-
)}
104
-
</div>
105
-
{resolvedEmbed && (
106
-
<div style={{ ...baseStyles.embedContainer, ...palette.embedContainer }}>
107
-
{resolvedEmbed}
108
-
</div>
109
-
)}
110
-
</div>
111
-
{iconPlacement === 'cardBottomRight' && showIcon && (
112
-
<div style={baseStyles.iconCorner} aria-hidden>
113
-
{makeIcon()}
114
-
</div>
115
-
)}
116
-
</article>
117
-
);
91
+
return (
92
+
<article style={cardStyle} aria-busy={loading}>
93
+
<header style={baseStyles.header}>
94
+
{avatarUrl ? (
95
+
<img
96
+
src={avatarUrl}
97
+
alt="avatar"
98
+
style={baseStyles.avatarImg}
99
+
/>
100
+
) : (
101
+
<div
102
+
style={{
103
+
...baseStyles.avatarPlaceholder,
104
+
...palette.avatarPlaceholder,
105
+
}}
106
+
aria-hidden
107
+
/>
108
+
)}
109
+
<div style={{ display: "flex", flexDirection: "column" }}>
110
+
<strong style={{ fontSize: 14 }}>{primaryName}</strong>
111
+
{authorDisplayName && authorHandle && (
112
+
<span
113
+
style={{ ...baseStyles.handle, ...palette.handle }}
114
+
>
115
+
@{authorHandle}
116
+
</span>
117
+
)}
118
+
</div>
119
+
{iconPlacement === "timestamp" && showIcon && (
120
+
<div style={baseStyles.headerIcon}>{makeIcon()}</div>
121
+
)}
122
+
</header>
123
+
{replyHref && replyLabel && (
124
+
<div style={{ ...baseStyles.replyLine, ...palette.replyLine }}>
125
+
Replying to{" "}
126
+
<a
127
+
href={replyHref}
128
+
target="_blank"
129
+
rel="noopener noreferrer"
130
+
style={{
131
+
...baseStyles.replyLink,
132
+
...palette.replyLink,
133
+
}}
134
+
>
135
+
{replyLabel}
136
+
</a>
137
+
</div>
138
+
)}
139
+
<div style={baseStyles.body}>
140
+
<p style={{ ...baseStyles.text, ...palette.text }}>{text}</p>
141
+
{record.facets && record.facets.length > 0 && (
142
+
<div style={baseStyles.facets}>
143
+
{record.facets.map((_, idx) => (
144
+
<span
145
+
key={idx}
146
+
style={{
147
+
...baseStyles.facetTag,
148
+
...palette.facetTag,
149
+
}}
150
+
>
151
+
facet
152
+
</span>
153
+
))}
154
+
</div>
155
+
)}
156
+
<div style={baseStyles.timestampRow}>
157
+
<time
158
+
style={{ ...baseStyles.time, ...palette.time }}
159
+
dateTime={record.createdAt}
160
+
>
161
+
{created}
162
+
</time>
163
+
{postUrl && (
164
+
<span style={baseStyles.linkWithIcon}>
165
+
<a
166
+
href={postUrl}
167
+
target="_blank"
168
+
rel="noopener noreferrer"
169
+
style={{
170
+
...baseStyles.postLink,
171
+
...palette.postLink,
172
+
}}
173
+
>
174
+
View on Bluesky
175
+
</a>
176
+
{iconPlacement === "linkInline" && showIcon && (
177
+
<span style={baseStyles.inlineIcon} aria-hidden>
178
+
{makeIcon()}
179
+
</span>
180
+
)}
181
+
</span>
182
+
)}
183
+
</div>
184
+
{resolvedEmbed && (
185
+
<div
186
+
style={{
187
+
...baseStyles.embedContainer,
188
+
...palette.embedContainer,
189
+
}}
190
+
>
191
+
{resolvedEmbed}
192
+
</div>
193
+
)}
194
+
</div>
195
+
{iconPlacement === "cardBottomRight" && showIcon && (
196
+
<div style={baseStyles.iconCorner} aria-hidden>
197
+
{makeIcon()}
198
+
</div>
199
+
)}
200
+
</article>
201
+
);
118
202
};
119
203
120
204
const baseStyles: Record<string, React.CSSProperties> = {
121
-
card: {
122
-
borderRadius: 12,
123
-
padding: 12,
124
-
fontFamily: 'system-ui, sans-serif',
125
-
display: 'flex',
126
-
flexDirection: 'column',
127
-
gap: 8,
128
-
maxWidth: 600,
129
-
transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease',
130
-
position: 'relative'
131
-
},
132
-
header: {
133
-
display: 'flex',
134
-
alignItems: 'center',
135
-
gap: 8
136
-
},
137
-
headerIcon: {
138
-
marginLeft: 'auto',
139
-
display: 'flex',
140
-
alignItems: 'center'
141
-
},
142
-
avatarPlaceholder: {
143
-
width: 40,
144
-
height: 40,
145
-
borderRadius: '50%'
146
-
},
147
-
avatarImg: {
148
-
width: 40,
149
-
height: 40,
150
-
borderRadius: '50%',
151
-
objectFit: 'cover'
152
-
},
153
-
handle: {
154
-
fontSize: 12
155
-
},
156
-
time: {
157
-
fontSize: 11
158
-
},
159
-
timestampIcon: {
160
-
display: 'flex',
161
-
alignItems: 'center',
162
-
justifyContent: 'center'
163
-
},
164
-
body: {
165
-
fontSize: 14,
166
-
lineHeight: 1.4
167
-
},
168
-
text: {
169
-
margin: 0,
170
-
whiteSpace: 'pre-wrap',
171
-
overflowWrap: 'anywhere'
172
-
},
173
-
facets: {
174
-
marginTop: 8,
175
-
display: 'flex',
176
-
gap: 4
177
-
},
178
-
embedContainer: {
179
-
marginTop: 12,
180
-
padding: 8,
181
-
borderRadius: 12,
182
-
display: 'flex',
183
-
flexDirection: 'column',
184
-
gap: 8
185
-
},
186
-
timestampRow: {
187
-
display: 'flex',
188
-
justifyContent: 'flex-end',
189
-
alignItems: 'center',
190
-
gap: 12,
191
-
marginTop: 12,
192
-
flexWrap: 'wrap'
193
-
},
194
-
linkWithIcon: {
195
-
display: 'inline-flex',
196
-
alignItems: 'center',
197
-
gap: 6
198
-
},
199
-
postLink: {
200
-
fontSize: 11,
201
-
textDecoration: 'none',
202
-
fontWeight: 600
203
-
},
204
-
inlineIcon: {
205
-
display: 'inline-flex',
206
-
alignItems: 'center'
207
-
},
208
-
facetTag: {
209
-
padding: '2px 6px',
210
-
borderRadius: 4,
211
-
fontSize: 11
212
-
},
213
-
replyLine: {
214
-
fontSize: 12
215
-
},
216
-
replyLink: {
217
-
textDecoration: 'none',
218
-
fontWeight: 500
219
-
},
220
-
iconCorner: {
221
-
position: 'absolute',
222
-
right: 12,
223
-
bottom: 12,
224
-
display: 'flex',
225
-
alignItems: 'center',
226
-
justifyContent: 'flex-end'
227
-
}
205
+
card: {
206
+
borderRadius: 12,
207
+
padding: 12,
208
+
fontFamily: "system-ui, sans-serif",
209
+
display: "flex",
210
+
flexDirection: "column",
211
+
gap: 8,
212
+
maxWidth: 600,
213
+
transition:
214
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
215
+
position: "relative",
216
+
},
217
+
header: {
218
+
display: "flex",
219
+
alignItems: "center",
220
+
gap: 8,
221
+
},
222
+
headerIcon: {
223
+
marginLeft: "auto",
224
+
display: "flex",
225
+
alignItems: "center",
226
+
},
227
+
avatarPlaceholder: {
228
+
width: 40,
229
+
height: 40,
230
+
borderRadius: "50%",
231
+
},
232
+
avatarImg: {
233
+
width: 40,
234
+
height: 40,
235
+
borderRadius: "50%",
236
+
objectFit: "cover",
237
+
},
238
+
handle: {
239
+
fontSize: 12,
240
+
},
241
+
time: {
242
+
fontSize: 11,
243
+
},
244
+
timestampIcon: {
245
+
display: "flex",
246
+
alignItems: "center",
247
+
justifyContent: "center",
248
+
},
249
+
body: {
250
+
fontSize: 14,
251
+
lineHeight: 1.4,
252
+
},
253
+
text: {
254
+
margin: 0,
255
+
whiteSpace: "pre-wrap",
256
+
overflowWrap: "anywhere",
257
+
},
258
+
facets: {
259
+
marginTop: 8,
260
+
display: "flex",
261
+
gap: 4,
262
+
},
263
+
embedContainer: {
264
+
marginTop: 12,
265
+
padding: 8,
266
+
borderRadius: 12,
267
+
display: "flex",
268
+
flexDirection: "column",
269
+
gap: 8,
270
+
},
271
+
timestampRow: {
272
+
display: "flex",
273
+
justifyContent: "flex-end",
274
+
alignItems: "center",
275
+
gap: 12,
276
+
marginTop: 12,
277
+
flexWrap: "wrap",
278
+
},
279
+
linkWithIcon: {
280
+
display: "inline-flex",
281
+
alignItems: "center",
282
+
gap: 6,
283
+
},
284
+
postLink: {
285
+
fontSize: 11,
286
+
textDecoration: "none",
287
+
fontWeight: 600,
288
+
},
289
+
inlineIcon: {
290
+
display: "inline-flex",
291
+
alignItems: "center",
292
+
},
293
+
facetTag: {
294
+
padding: "2px 6px",
295
+
borderRadius: 4,
296
+
fontSize: 11,
297
+
},
298
+
replyLine: {
299
+
fontSize: 12,
300
+
},
301
+
replyLink: {
302
+
textDecoration: "none",
303
+
fontWeight: 500,
304
+
},
305
+
iconCorner: {
306
+
position: "absolute",
307
+
right: 12,
308
+
bottom: 12,
309
+
display: "flex",
310
+
alignItems: "center",
311
+
justifyContent: "flex-end",
312
+
},
228
313
};
229
314
230
315
const themeStyles = {
231
-
light: {
232
-
card: {
233
-
border: '1px solid #e2e8f0',
234
-
background: '#ffffff',
235
-
color: '#0f172a'
236
-
},
237
-
avatarPlaceholder: {
238
-
background: '#cbd5e1'
239
-
},
240
-
handle: {
241
-
color: '#64748b'
242
-
},
243
-
time: {
244
-
color: '#94a3b8'
245
-
},
246
-
text: {
247
-
color: '#0f172a'
248
-
},
249
-
facetTag: {
250
-
background: '#f1f5f9',
251
-
color: '#475569'
252
-
},
253
-
replyLine: {
254
-
color: '#475569'
255
-
},
256
-
replyLink: {
257
-
color: '#2563eb'
258
-
},
259
-
embedContainer: {
260
-
border: '1px solid #e2e8f0',
261
-
borderRadius: 12,
262
-
background: '#f8fafc'
263
-
},
264
-
postLink: {
265
-
color: '#2563eb'
266
-
}
267
-
},
268
-
dark: {
269
-
card: {
270
-
border: '1px solid #1e293b',
271
-
background: '#0f172a',
272
-
color: '#e2e8f0'
273
-
},
274
-
avatarPlaceholder: {
275
-
background: '#1e293b'
276
-
},
277
-
handle: {
278
-
color: '#cbd5f5'
279
-
},
280
-
time: {
281
-
color: '#94a3ff'
282
-
},
283
-
text: {
284
-
color: '#e2e8f0'
285
-
},
286
-
facetTag: {
287
-
background: '#1e293b',
288
-
color: '#e0f2fe'
289
-
},
290
-
replyLine: {
291
-
color: '#cbd5f5'
292
-
},
293
-
replyLink: {
294
-
color: '#38bdf8'
295
-
},
296
-
embedContainer: {
297
-
border: '1px solid #1e293b',
298
-
borderRadius: 12,
299
-
background: '#0b1120'
300
-
},
301
-
postLink: {
302
-
color: '#38bdf8'
303
-
}
304
-
}
305
-
} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
316
+
light: {
317
+
card: {
318
+
border: "1px solid #e2e8f0",
319
+
background: "#ffffff",
320
+
color: "#0f172a",
321
+
},
322
+
avatarPlaceholder: {
323
+
background: "#cbd5e1",
324
+
},
325
+
handle: {
326
+
color: "#64748b",
327
+
},
328
+
time: {
329
+
color: "#94a3b8",
330
+
},
331
+
text: {
332
+
color: "#0f172a",
333
+
},
334
+
facetTag: {
335
+
background: "#f1f5f9",
336
+
color: "#475569",
337
+
},
338
+
replyLine: {
339
+
color: "#475569",
340
+
},
341
+
replyLink: {
342
+
color: "#2563eb",
343
+
},
344
+
embedContainer: {
345
+
border: "1px solid #e2e8f0",
346
+
borderRadius: 12,
347
+
background: "#f8fafc",
348
+
},
349
+
postLink: {
350
+
color: "#2563eb",
351
+
},
352
+
},
353
+
dark: {
354
+
card: {
355
+
border: "1px solid #1e293b",
356
+
background: "#0f172a",
357
+
color: "#e2e8f0",
358
+
},
359
+
avatarPlaceholder: {
360
+
background: "#1e293b",
361
+
},
362
+
handle: {
363
+
color: "#cbd5f5",
364
+
},
365
+
time: {
366
+
color: "#94a3ff",
367
+
},
368
+
text: {
369
+
color: "#e2e8f0",
370
+
},
371
+
facetTag: {
372
+
background: "#1e293b",
373
+
color: "#e0f2fe",
374
+
},
375
+
replyLine: {
376
+
color: "#cbd5f5",
377
+
},
378
+
replyLink: {
379
+
color: "#38bdf8",
380
+
},
381
+
embedContainer: {
382
+
border: "1px solid #1e293b",
383
+
borderRadius: 12,
384
+
background: "#0b1120",
385
+
},
386
+
postLink: {
387
+
color: "#38bdf8",
388
+
},
389
+
},
390
+
} satisfies Record<"light" | "dark", Record<string, React.CSSProperties>>;
306
391
307
-
function formatReplyLabel(target: ParsedAtUri, resolvedHandle?: string, loading?: boolean): string {
308
-
if (resolvedHandle) return `@${resolvedHandle}`;
309
-
if (loading) return '…';
310
-
return `@${formatDidForLabel(target.did)}`;
392
+
function formatReplyLabel(
393
+
target: ParsedAtUri,
394
+
resolvedHandle?: string,
395
+
loading?: boolean,
396
+
): string {
397
+
if (resolvedHandle) return `@${resolvedHandle}`;
398
+
if (loading) return "…";
399
+
return `@${formatDidForLabel(target.did)}`;
311
400
}
312
401
313
-
function createAutoEmbed(record: FeedPostRecord, authorDid: string | undefined, scheme: 'light' | 'dark'): React.ReactNode {
314
-
const embed = record.embed as { $type?: string } | undefined;
315
-
if (!embed) return null;
316
-
if (embed.$type === 'app.bsky.embed.images') {
317
-
return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} scheme={scheme} />;
318
-
}
319
-
if (embed.$type === 'app.bsky.embed.recordWithMedia') {
320
-
const media = (embed as RecordWithMediaEmbed).media;
321
-
if (media?.$type === 'app.bsky.embed.images') {
322
-
return <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} scheme={scheme} />;
323
-
}
324
-
}
325
-
return null;
402
+
function createAutoEmbed(
403
+
record: FeedPostRecord,
404
+
authorDid: string | undefined,
405
+
scheme: "light" | "dark",
406
+
): React.ReactNode {
407
+
const embed = record.embed as { $type?: string } | undefined;
408
+
if (!embed) return null;
409
+
if (embed.$type === "app.bsky.embed.images") {
410
+
return (
411
+
<ImagesEmbed
412
+
embed={embed as ImagesEmbedType}
413
+
did={authorDid}
414
+
scheme={scheme}
415
+
/>
416
+
);
417
+
}
418
+
if (embed.$type === "app.bsky.embed.recordWithMedia") {
419
+
const media = (embed as RecordWithMediaEmbed).media;
420
+
if (media?.$type === "app.bsky.embed.images") {
421
+
return (
422
+
<ImagesEmbed
423
+
embed={media as ImagesEmbedType}
424
+
did={authorDid}
425
+
scheme={scheme}
426
+
/>
427
+
);
428
+
}
429
+
}
430
+
return null;
326
431
}
327
432
328
433
type ImagesEmbedType = {
329
-
$type: 'app.bsky.embed.images';
330
-
images: Array<{
331
-
alt?: string;
332
-
mime?: string;
333
-
size?: number;
334
-
image?: {
335
-
$type?: string;
336
-
ref?: { $link?: string };
337
-
cid?: string;
338
-
};
339
-
aspectRatio?: {
340
-
width: number;
341
-
height: number;
342
-
};
343
-
}>;
434
+
$type: "app.bsky.embed.images";
435
+
images: Array<{
436
+
alt?: string;
437
+
mime?: string;
438
+
size?: number;
439
+
image?: {
440
+
$type?: string;
441
+
ref?: { $link?: string };
442
+
cid?: string;
443
+
};
444
+
aspectRatio?: {
445
+
width: number;
446
+
height: number;
447
+
};
448
+
}>;
344
449
};
345
450
346
451
type RecordWithMediaEmbed = {
347
-
$type: 'app.bsky.embed.recordWithMedia';
348
-
record?: unknown;
349
-
media?: { $type?: string };
452
+
$type: "app.bsky.embed.recordWithMedia";
453
+
record?: unknown;
454
+
media?: { $type?: string };
350
455
};
351
456
352
457
interface ImagesEmbedProps {
353
-
embed: ImagesEmbedType;
354
-
did?: string;
355
-
scheme: 'light' | 'dark';
458
+
embed: ImagesEmbedType;
459
+
did?: string;
460
+
scheme: "light" | "dark";
356
461
}
357
462
358
463
const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did, scheme }) => {
359
-
if (!embed.images || embed.images.length === 0) return null;
360
-
const palette = scheme === 'dark' ? imagesPalette.dark : imagesPalette.light;
361
-
const columns = embed.images.length > 1 ? 'repeat(auto-fit, minmax(160px, 1fr))' : '1fr';
362
-
return (
363
-
<div style={{ ...imagesBase.container, ...palette.container, gridTemplateColumns: columns }}>
364
-
{embed.images.map((image, idx) => (
365
-
<PostImage key={idx} image={image} did={did} scheme={scheme} />
366
-
))}
367
-
</div>
368
-
);
464
+
if (!embed.images || embed.images.length === 0) return null;
465
+
const palette =
466
+
scheme === "dark" ? imagesPalette.dark : imagesPalette.light;
467
+
const columns =
468
+
embed.images.length > 1
469
+
? "repeat(auto-fit, minmax(160px, 1fr))"
470
+
: "1fr";
471
+
return (
472
+
<div
473
+
style={{
474
+
...imagesBase.container,
475
+
...palette.container,
476
+
gridTemplateColumns: columns,
477
+
}}
478
+
>
479
+
{embed.images.map((image, idx) => (
480
+
<PostImage key={idx} image={image} did={did} scheme={scheme} />
481
+
))}
482
+
</div>
483
+
);
369
484
};
370
485
371
486
interface PostImageProps {
372
-
image: ImagesEmbedType['images'][number];
373
-
did?: string;
374
-
scheme: 'light' | 'dark';
487
+
image: ImagesEmbedType["images"][number];
488
+
did?: string;
489
+
scheme: "light" | "dark";
375
490
}
376
491
377
492
const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => {
378
-
const cid = image.image?.ref?.$link ?? image.image?.cid;
379
-
const { url, loading, error } = useBlob(did, cid);
380
-
const alt = image.alt?.trim() || 'Bluesky attachment';
381
-
const palette = scheme === 'dark' ? imagesPalette.dark : imagesPalette.light;
382
-
const aspect = image.aspectRatio && image.aspectRatio.height > 0
383
-
? `${image.aspectRatio.width} / ${image.aspectRatio.height}`
384
-
: undefined;
493
+
const cid = image.image?.ref?.$link ?? image.image?.cid;
494
+
const { url, loading, error } = useBlob(did, cid);
495
+
const alt = image.alt?.trim() || "Bluesky attachment";
496
+
const palette =
497
+
scheme === "dark" ? imagesPalette.dark : imagesPalette.light;
498
+
const aspect =
499
+
image.aspectRatio && image.aspectRatio.height > 0
500
+
? `${image.aspectRatio.width} / ${image.aspectRatio.height}`
501
+
: undefined;
385
502
386
-
return (
387
-
<figure style={{ ...imagesBase.item, ...palette.item }}>
388
-
<div style={{ ...imagesBase.media, ...palette.media, aspectRatio: aspect }}>
389
-
{url ? (
390
-
<img src={url} alt={alt} style={imagesBase.img} />
391
-
) : (
392
-
<div style={{ ...imagesBase.placeholder, ...palette.placeholder }}>
393
-
{loading ? 'Loading image…' : error ? 'Image failed to load' : 'Image unavailable'}
394
-
</div>
395
-
)}
396
-
</div>
397
-
{image.alt && image.alt.trim().length > 0 && (
398
-
<figcaption style={{ ...imagesBase.caption, ...palette.caption }}>{image.alt}</figcaption>
399
-
)}
400
-
</figure>
401
-
);
503
+
return (
504
+
<figure style={{ ...imagesBase.item, ...palette.item }}>
505
+
<div
506
+
style={{
507
+
...imagesBase.media,
508
+
...palette.media,
509
+
aspectRatio: aspect,
510
+
}}
511
+
>
512
+
{url ? (
513
+
<img src={url} alt={alt} style={imagesBase.img} />
514
+
) : (
515
+
<div
516
+
style={{
517
+
...imagesBase.placeholder,
518
+
...palette.placeholder,
519
+
}}
520
+
>
521
+
{loading
522
+
? "Loading image…"
523
+
: error
524
+
? "Image failed to load"
525
+
: "Image unavailable"}
526
+
</div>
527
+
)}
528
+
</div>
529
+
{image.alt && image.alt.trim().length > 0 && (
530
+
<figcaption
531
+
style={{ ...imagesBase.caption, ...palette.caption }}
532
+
>
533
+
{image.alt}
534
+
</figcaption>
535
+
)}
536
+
</figure>
537
+
);
402
538
};
403
539
404
540
const imagesBase = {
405
-
container: {
406
-
display: 'grid',
407
-
gap: 8,
408
-
width: '100%'
409
-
} satisfies React.CSSProperties,
410
-
item: {
411
-
margin: 0,
412
-
display: 'flex',
413
-
flexDirection: 'column',
414
-
gap: 4
415
-
} satisfies React.CSSProperties,
416
-
media: {
417
-
position: 'relative',
418
-
width: '100%',
419
-
borderRadius: 12,
420
-
overflow: 'hidden'
421
-
} satisfies React.CSSProperties,
422
-
img: {
423
-
width: '100%',
424
-
height: '100%',
425
-
objectFit: 'cover'
426
-
} satisfies React.CSSProperties,
427
-
placeholder: {
428
-
display: 'flex',
429
-
alignItems: 'center',
430
-
justifyContent: 'center',
431
-
width: '100%',
432
-
height: '100%'
433
-
} satisfies React.CSSProperties,
434
-
caption: {
435
-
fontSize: 12,
436
-
lineHeight: 1.3
437
-
} satisfies React.CSSProperties
541
+
container: {
542
+
display: "grid",
543
+
gap: 8,
544
+
width: "100%",
545
+
} satisfies React.CSSProperties,
546
+
item: {
547
+
margin: 0,
548
+
display: "flex",
549
+
flexDirection: "column",
550
+
gap: 4,
551
+
} satisfies React.CSSProperties,
552
+
media: {
553
+
position: "relative",
554
+
width: "100%",
555
+
borderRadius: 12,
556
+
overflow: "hidden",
557
+
} satisfies React.CSSProperties,
558
+
img: {
559
+
width: "100%",
560
+
height: "100%",
561
+
objectFit: "cover",
562
+
} satisfies React.CSSProperties,
563
+
placeholder: {
564
+
display: "flex",
565
+
alignItems: "center",
566
+
justifyContent: "center",
567
+
width: "100%",
568
+
height: "100%",
569
+
} satisfies React.CSSProperties,
570
+
caption: {
571
+
fontSize: 12,
572
+
lineHeight: 1.3,
573
+
} satisfies React.CSSProperties,
438
574
};
439
575
440
576
const imagesPalette = {
441
-
light: {
442
-
container: {
443
-
padding: 0
444
-
} satisfies React.CSSProperties,
445
-
item: {},
446
-
media: {
447
-
background: '#e2e8f0'
448
-
} satisfies React.CSSProperties,
449
-
placeholder: {
450
-
color: '#475569'
451
-
} satisfies React.CSSProperties,
452
-
caption: {
453
-
color: '#475569'
454
-
} satisfies React.CSSProperties
455
-
},
456
-
dark: {
457
-
container: {
458
-
padding: 0
459
-
} satisfies React.CSSProperties,
460
-
item: {},
461
-
media: {
462
-
background: '#1e293b'
463
-
} satisfies React.CSSProperties,
464
-
placeholder: {
465
-
color: '#cbd5f5'
466
-
} satisfies React.CSSProperties,
467
-
caption: {
468
-
color: '#94a3b8'
469
-
} satisfies React.CSSProperties
470
-
}
577
+
light: {
578
+
container: {
579
+
padding: 0,
580
+
} satisfies React.CSSProperties,
581
+
item: {},
582
+
media: {
583
+
background: "#e2e8f0",
584
+
} satisfies React.CSSProperties,
585
+
placeholder: {
586
+
color: "#475569",
587
+
} satisfies React.CSSProperties,
588
+
caption: {
589
+
color: "#475569",
590
+
} satisfies React.CSSProperties,
591
+
},
592
+
dark: {
593
+
container: {
594
+
padding: 0,
595
+
} satisfies React.CSSProperties,
596
+
item: {},
597
+
media: {
598
+
background: "#1e293b",
599
+
} satisfies React.CSSProperties,
600
+
placeholder: {
601
+
color: "#cbd5f5",
602
+
} satisfies React.CSSProperties,
603
+
caption: {
604
+
color: "#94a3b8",
605
+
} satisfies React.CSSProperties,
606
+
},
471
607
} as const;
472
608
473
-
export default BlueskyPostRenderer;
609
+
export default BlueskyPostRenderer;
+232
-176
lib/renderers/BlueskyProfileRenderer.tsx
+232
-176
lib/renderers/BlueskyProfileRenderer.tsx
···
1
-
import React from 'react';
2
-
import type { ProfileRecord } from '../types/bluesky';
3
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
4
-
import { BlueskyIcon } from '../components/BlueskyIcon';
1
+
import React from "react";
2
+
import type { ProfileRecord } from "../types/bluesky";
3
+
import {
4
+
useColorScheme,
5
+
type ColorSchemePreference,
6
+
} from "../hooks/useColorScheme";
7
+
import { BlueskyIcon } from "../components/BlueskyIcon";
5
8
6
9
export interface BlueskyProfileRendererProps {
7
-
record: ProfileRecord;
8
-
loading: boolean;
9
-
error?: Error;
10
-
did: string;
11
-
handle?: string;
12
-
avatarUrl?: string;
13
-
colorScheme?: ColorSchemePreference;
10
+
record: ProfileRecord;
11
+
loading: boolean;
12
+
error?: Error;
13
+
did: string;
14
+
handle?: string;
15
+
avatarUrl?: string;
16
+
colorScheme?: ColorSchemePreference;
14
17
}
15
18
16
-
export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({ record, loading, error, did, handle, avatarUrl, colorScheme = 'system' }) => {
17
-
const scheme = useColorScheme(colorScheme);
19
+
export const BlueskyProfileRenderer: React.FC<BlueskyProfileRendererProps> = ({
20
+
record,
21
+
loading,
22
+
error,
23
+
did,
24
+
handle,
25
+
avatarUrl,
26
+
colorScheme = "system",
27
+
}) => {
28
+
const scheme = useColorScheme(colorScheme);
18
29
19
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load profile.</div>;
20
-
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
30
+
if (error)
31
+
return (
32
+
<div style={{ padding: 8, color: "crimson" }}>
33
+
Failed to load profile.
34
+
</div>
35
+
);
36
+
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
21
37
22
-
const palette = scheme === 'dark' ? theme.dark : theme.light;
23
-
const profileUrl = `https://bsky.app/profile/${encodeURIComponent(did)}`;
24
-
const rawWebsite = record.website?.trim();
25
-
const websiteHref = rawWebsite ? (rawWebsite.match(/^https?:\/\//i) ? rawWebsite : `https://${rawWebsite}`) : undefined;
26
-
const websiteLabel = rawWebsite ? rawWebsite.replace(/^https?:\/\//i, '') : undefined;
38
+
const palette = scheme === "dark" ? theme.dark : theme.light;
39
+
const profileUrl = `https://bsky.app/profile/${encodeURIComponent(did)}`;
40
+
const rawWebsite = record.website?.trim();
41
+
const websiteHref = rawWebsite
42
+
? rawWebsite.match(/^https?:\/\//i)
43
+
? rawWebsite
44
+
: `https://${rawWebsite}`
45
+
: undefined;
46
+
const websiteLabel = rawWebsite
47
+
? rawWebsite.replace(/^https?:\/\//i, "")
48
+
: undefined;
27
49
28
-
return (
29
-
<div style={{ ...base.card, ...palette.card }}>
30
-
<div style={base.header}>
31
-
{avatarUrl ? <img src={avatarUrl} alt="avatar" style={base.avatarImg} /> : <div style={{ ...base.avatar, ...palette.avatar }} aria-label="avatar" />}
32
-
<div style={{ flex: 1 }}>
33
-
<div style={{ ...base.display, ...palette.display }}>{record.displayName ?? handle ?? did}</div>
34
-
<div style={{ ...base.handleLine, ...palette.handleLine }}>@{handle ?? did}</div>
35
-
{record.pronouns && <div style={{ ...base.pronouns, ...palette.pronouns }}>{record.pronouns}</div>}
36
-
</div>
37
-
</div>
38
-
{record.description && <p style={{ ...base.desc, ...palette.desc }}>{record.description}</p>}
39
-
{record.createdAt && <div style={{ ...base.meta, ...palette.meta }}>Joined {new Date(record.createdAt).toLocaleDateString()}</div>}
40
-
<div style={base.links}>
41
-
{websiteHref && websiteLabel && (
42
-
<a href={websiteHref} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}>
43
-
{websiteLabel}
44
-
</a>
45
-
)}
46
-
<a href={profileUrl} target="_blank" rel="noopener noreferrer" style={{ ...base.link, ...palette.link }}>
47
-
View on Bluesky
48
-
</a>
49
-
</div>
50
-
<div style={base.iconCorner} aria-hidden>
51
-
<BlueskyIcon size={18} />
52
-
</div>
53
-
</div>
54
-
);
50
+
return (
51
+
<div style={{ ...base.card, ...palette.card }}>
52
+
<div style={base.header}>
53
+
{avatarUrl ? (
54
+
<img src={avatarUrl} alt="avatar" style={base.avatarImg} />
55
+
) : (
56
+
<div
57
+
style={{ ...base.avatar, ...palette.avatar }}
58
+
aria-label="avatar"
59
+
/>
60
+
)}
61
+
<div style={{ flex: 1 }}>
62
+
<div style={{ ...base.display, ...palette.display }}>
63
+
{record.displayName ?? handle ?? did}
64
+
</div>
65
+
<div style={{ ...base.handleLine, ...palette.handleLine }}>
66
+
@{handle ?? did}
67
+
</div>
68
+
{record.pronouns && (
69
+
<div style={{ ...base.pronouns, ...palette.pronouns }}>
70
+
{record.pronouns}
71
+
</div>
72
+
)}
73
+
</div>
74
+
</div>
75
+
{record.description && (
76
+
<p style={{ ...base.desc, ...palette.desc }}>
77
+
{record.description}
78
+
</p>
79
+
)}
80
+
{record.createdAt && (
81
+
<div style={{ ...base.meta, ...palette.meta }}>
82
+
Joined {new Date(record.createdAt).toLocaleDateString()}
83
+
</div>
84
+
)}
85
+
<div style={base.links}>
86
+
{websiteHref && websiteLabel && (
87
+
<a
88
+
href={websiteHref}
89
+
target="_blank"
90
+
rel="noopener noreferrer"
91
+
style={{ ...base.link, ...palette.link }}
92
+
>
93
+
{websiteLabel}
94
+
</a>
95
+
)}
96
+
<a
97
+
href={profileUrl}
98
+
target="_blank"
99
+
rel="noopener noreferrer"
100
+
style={{ ...base.link, ...palette.link }}
101
+
>
102
+
View on Bluesky
103
+
</a>
104
+
</div>
105
+
<div style={base.iconCorner} aria-hidden>
106
+
<BlueskyIcon size={18} />
107
+
</div>
108
+
</div>
109
+
);
55
110
};
56
111
57
112
const base: Record<string, React.CSSProperties> = {
58
-
card: {
59
-
borderRadius: 12,
60
-
padding: 16,
61
-
fontFamily: 'system-ui, sans-serif',
62
-
maxWidth: 480,
63
-
transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease',
64
-
position: 'relative'
65
-
},
66
-
header: {
67
-
display: 'flex',
68
-
gap: 12,
69
-
marginBottom: 8
70
-
},
71
-
avatar: {
72
-
width: 64,
73
-
height: 64,
74
-
borderRadius: '50%'
75
-
},
76
-
avatarImg: {
77
-
width: 64,
78
-
height: 64,
79
-
borderRadius: '50%',
80
-
objectFit: 'cover'
81
-
},
82
-
display: {
83
-
fontSize: 20,
84
-
fontWeight: 600
85
-
},
86
-
handleLine: {
87
-
fontSize: 13
88
-
},
89
-
desc: {
90
-
whiteSpace: 'pre-wrap',
91
-
fontSize: 14,
92
-
lineHeight: 1.4
93
-
},
94
-
meta: {
95
-
marginTop: 12,
96
-
fontSize: 12
97
-
},
98
-
pronouns: {
99
-
display: 'inline-flex',
100
-
alignItems: 'center',
101
-
gap: 4,
102
-
fontSize: 12,
103
-
fontWeight: 500,
104
-
borderRadius: 999,
105
-
padding: '2px 8px',
106
-
marginTop: 6
107
-
},
108
-
links: {
109
-
display: 'flex',
110
-
flexDirection: 'column',
111
-
gap: 8,
112
-
marginTop: 12
113
-
},
114
-
link: {
115
-
display: 'inline-flex',
116
-
alignItems: 'center',
117
-
gap: 4,
118
-
fontSize: 12,
119
-
fontWeight: 600,
120
-
textDecoration: 'none'
121
-
},
122
-
iconCorner: {
123
-
position: 'absolute',
124
-
right: 12,
125
-
bottom: 12
126
-
}
113
+
card: {
114
+
borderRadius: 12,
115
+
padding: 16,
116
+
fontFamily: "system-ui, sans-serif",
117
+
maxWidth: 480,
118
+
transition:
119
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease",
120
+
position: "relative",
121
+
},
122
+
header: {
123
+
display: "flex",
124
+
gap: 12,
125
+
marginBottom: 8,
126
+
},
127
+
avatar: {
128
+
width: 64,
129
+
height: 64,
130
+
borderRadius: "50%",
131
+
},
132
+
avatarImg: {
133
+
width: 64,
134
+
height: 64,
135
+
borderRadius: "50%",
136
+
objectFit: "cover",
137
+
},
138
+
display: {
139
+
fontSize: 20,
140
+
fontWeight: 600,
141
+
},
142
+
handleLine: {
143
+
fontSize: 13,
144
+
},
145
+
desc: {
146
+
whiteSpace: "pre-wrap",
147
+
fontSize: 14,
148
+
lineHeight: 1.4,
149
+
},
150
+
meta: {
151
+
marginTop: 12,
152
+
fontSize: 12,
153
+
},
154
+
pronouns: {
155
+
display: "inline-flex",
156
+
alignItems: "center",
157
+
gap: 4,
158
+
fontSize: 12,
159
+
fontWeight: 500,
160
+
borderRadius: 999,
161
+
padding: "2px 8px",
162
+
marginTop: 6,
163
+
},
164
+
links: {
165
+
display: "flex",
166
+
flexDirection: "column",
167
+
gap: 8,
168
+
marginTop: 12,
169
+
},
170
+
link: {
171
+
display: "inline-flex",
172
+
alignItems: "center",
173
+
gap: 4,
174
+
fontSize: 12,
175
+
fontWeight: 600,
176
+
textDecoration: "none",
177
+
},
178
+
iconCorner: {
179
+
position: "absolute",
180
+
right: 12,
181
+
bottom: 12,
182
+
},
127
183
};
128
184
129
185
const theme = {
130
-
light: {
131
-
card: {
132
-
border: '1px solid #e2e8f0',
133
-
background: '#ffffff',
134
-
color: '#0f172a'
135
-
},
136
-
avatar: {
137
-
background: '#cbd5e1'
138
-
},
139
-
display: {
140
-
color: '#0f172a'
141
-
},
142
-
handleLine: {
143
-
color: '#64748b'
144
-
},
145
-
desc: {
146
-
color: '#0f172a'
147
-
},
148
-
meta: {
149
-
color: '#94a3b8'
150
-
},
151
-
pronouns: {
152
-
background: '#e2e8f0',
153
-
color: '#1e293b'
154
-
},
155
-
link: {
156
-
color: '#2563eb'
157
-
}
158
-
},
159
-
dark: {
160
-
card: {
161
-
border: '1px solid #1e293b',
162
-
background: '#0b1120',
163
-
color: '#e2e8f0'
164
-
},
165
-
avatar: {
166
-
background: '#1e293b'
167
-
},
168
-
display: {
169
-
color: '#e2e8f0'
170
-
},
171
-
handleLine: {
172
-
color: '#cbd5f5'
173
-
},
174
-
desc: {
175
-
color: '#e2e8f0'
176
-
},
177
-
meta: {
178
-
color: '#a5b4fc'
179
-
},
180
-
pronouns: {
181
-
background: '#1e293b',
182
-
color: '#e2e8f0'
183
-
},
184
-
link: {
185
-
color: '#38bdf8'
186
-
}
187
-
}
188
-
} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
186
+
light: {
187
+
card: {
188
+
border: "1px solid #e2e8f0",
189
+
background: "#ffffff",
190
+
color: "#0f172a",
191
+
},
192
+
avatar: {
193
+
background: "#cbd5e1",
194
+
},
195
+
display: {
196
+
color: "#0f172a",
197
+
},
198
+
handleLine: {
199
+
color: "#64748b",
200
+
},
201
+
desc: {
202
+
color: "#0f172a",
203
+
},
204
+
meta: {
205
+
color: "#94a3b8",
206
+
},
207
+
pronouns: {
208
+
background: "#e2e8f0",
209
+
color: "#1e293b",
210
+
},
211
+
link: {
212
+
color: "#2563eb",
213
+
},
214
+
},
215
+
dark: {
216
+
card: {
217
+
border: "1px solid #1e293b",
218
+
background: "#0b1120",
219
+
color: "#e2e8f0",
220
+
},
221
+
avatar: {
222
+
background: "#1e293b",
223
+
},
224
+
display: {
225
+
color: "#e2e8f0",
226
+
},
227
+
handleLine: {
228
+
color: "#cbd5f5",
229
+
},
230
+
desc: {
231
+
color: "#e2e8f0",
232
+
},
233
+
meta: {
234
+
color: "#a5b4fc",
235
+
},
236
+
pronouns: {
237
+
background: "#1e293b",
238
+
color: "#e2e8f0",
239
+
},
240
+
link: {
241
+
color: "#38bdf8",
242
+
},
243
+
},
244
+
} satisfies Record<"light" | "dark", Record<string, React.CSSProperties>>;
189
245
190
-
export default BlueskyProfileRenderer;
246
+
export default BlueskyProfileRenderer;
+1320
-856
lib/renderers/LeafletDocumentRenderer.tsx
+1320
-856
lib/renderers/LeafletDocumentRenderer.tsx
···
1
-
import React, { useMemo, useRef } from 'react';
2
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
3
-
import { useDidResolution } from '../hooks/useDidResolution';
4
-
import { useBlob } from '../hooks/useBlob';
5
-
import { parseAtUri, formatDidForLabel, toBlueskyPostUrl, leafletRkeyUrl, normalizeLeafletBasePath } from '../utils/at-uri';
6
-
import { BlueskyPost } from '../components/BlueskyPost';
1
+
import React, { useMemo, useRef } from "react";
2
+
import {
3
+
useColorScheme,
4
+
type ColorSchemePreference,
5
+
} from "../hooks/useColorScheme";
6
+
import { useDidResolution } from "../hooks/useDidResolution";
7
+
import { useBlob } from "../hooks/useBlob";
8
+
import {
9
+
parseAtUri,
10
+
formatDidForLabel,
11
+
toBlueskyPostUrl,
12
+
leafletRkeyUrl,
13
+
normalizeLeafletBasePath,
14
+
} from "../utils/at-uri";
15
+
import { BlueskyPost } from "../components/BlueskyPost";
7
16
import type {
8
-
LeafletDocumentRecord,
9
-
LeafletLinearDocumentPage,
10
-
LeafletLinearDocumentBlock,
11
-
LeafletBlock,
12
-
LeafletTextBlock,
13
-
LeafletHeaderBlock,
14
-
LeafletBlockquoteBlock,
15
-
LeafletImageBlock,
16
-
LeafletUnorderedListBlock,
17
-
LeafletListItem,
18
-
LeafletWebsiteBlock,
19
-
LeafletIFrameBlock,
20
-
LeafletMathBlock,
21
-
LeafletCodeBlock,
22
-
LeafletBskyPostBlock,
23
-
LeafletAlignmentValue,
24
-
LeafletRichTextFacet,
25
-
LeafletRichTextFeature,
26
-
LeafletPublicationRecord
27
-
} from '../types/leaflet';
17
+
LeafletDocumentRecord,
18
+
LeafletLinearDocumentPage,
19
+
LeafletLinearDocumentBlock,
20
+
LeafletBlock,
21
+
LeafletTextBlock,
22
+
LeafletHeaderBlock,
23
+
LeafletBlockquoteBlock,
24
+
LeafletImageBlock,
25
+
LeafletUnorderedListBlock,
26
+
LeafletListItem,
27
+
LeafletWebsiteBlock,
28
+
LeafletIFrameBlock,
29
+
LeafletMathBlock,
30
+
LeafletCodeBlock,
31
+
LeafletBskyPostBlock,
32
+
LeafletAlignmentValue,
33
+
LeafletRichTextFacet,
34
+
LeafletRichTextFeature,
35
+
LeafletPublicationRecord,
36
+
} from "../types/leaflet";
28
37
29
38
export interface LeafletDocumentRendererProps {
30
-
record: LeafletDocumentRecord;
31
-
loading: boolean;
32
-
error?: Error;
33
-
colorScheme?: ColorSchemePreference;
34
-
did: string;
35
-
rkey: string;
36
-
canonicalUrl?: string;
37
-
publicationBaseUrl?: string;
38
-
publicationRecord?: LeafletPublicationRecord;
39
+
record: LeafletDocumentRecord;
40
+
loading: boolean;
41
+
error?: Error;
42
+
colorScheme?: ColorSchemePreference;
43
+
did: string;
44
+
rkey: string;
45
+
canonicalUrl?: string;
46
+
publicationBaseUrl?: string;
47
+
publicationRecord?: LeafletPublicationRecord;
39
48
}
40
49
41
-
export const LeafletDocumentRenderer: React.FC<LeafletDocumentRendererProps> = ({ record, loading, error, colorScheme = 'system', did, rkey, canonicalUrl, publicationBaseUrl, publicationRecord }) => {
42
-
const scheme = useColorScheme(colorScheme);
43
-
const palette = scheme === 'dark' ? theme.dark : theme.light;
44
-
const authorDid = record.author?.startsWith('did:') ? record.author : undefined;
45
-
const publicationUri = useMemo(() => parseAtUri(record.publication), [record.publication]);
46
-
const postUrl = useMemo(() => {
47
-
const postRefUri = record.postRef?.uri;
48
-
if (!postRefUri) return undefined;
49
-
const parsed = parseAtUri(postRefUri);
50
-
return parsed ? toBlueskyPostUrl(parsed) : undefined;
51
-
}, [record.postRef?.uri]);
52
-
const { handle: publicationHandle } = useDidResolution(publicationUri?.did);
53
-
const fallbackAuthorLabel = useAuthorLabel(record.author, authorDid);
54
-
const resolvedPublicationLabel = publicationRecord?.name?.trim()
55
-
?? (publicationHandle ? `@${publicationHandle}` : publicationUri ? formatDidForLabel(publicationUri.did) : undefined);
56
-
const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel;
57
-
const authorHref = publicationUri ? `https://bsky.app/profile/${publicationUri.did}` : undefined;
50
+
export const LeafletDocumentRenderer: React.FC<
51
+
LeafletDocumentRendererProps
52
+
> = ({
53
+
record,
54
+
loading,
55
+
error,
56
+
colorScheme = "system",
57
+
did,
58
+
rkey,
59
+
canonicalUrl,
60
+
publicationBaseUrl,
61
+
publicationRecord,
62
+
}) => {
63
+
const scheme = useColorScheme(colorScheme);
64
+
const palette = scheme === "dark" ? theme.dark : theme.light;
65
+
const authorDid = record.author?.startsWith("did:")
66
+
? record.author
67
+
: undefined;
68
+
const publicationUri = useMemo(
69
+
() => parseAtUri(record.publication),
70
+
[record.publication],
71
+
);
72
+
const postUrl = useMemo(() => {
73
+
const postRefUri = record.postRef?.uri;
74
+
if (!postRefUri) return undefined;
75
+
const parsed = parseAtUri(postRefUri);
76
+
return parsed ? toBlueskyPostUrl(parsed) : undefined;
77
+
}, [record.postRef?.uri]);
78
+
const { handle: publicationHandle } = useDidResolution(publicationUri?.did);
79
+
const fallbackAuthorLabel = useAuthorLabel(record.author, authorDid);
80
+
const resolvedPublicationLabel =
81
+
publicationRecord?.name?.trim() ??
82
+
(publicationHandle
83
+
? `@${publicationHandle}`
84
+
: publicationUri
85
+
? formatDidForLabel(publicationUri.did)
86
+
: undefined);
87
+
const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel;
88
+
const authorHref = publicationUri
89
+
? `https://bsky.app/profile/${publicationUri.did}`
90
+
: undefined;
58
91
59
-
if (error) return <div style={{ padding: 12, color: 'crimson' }}>Failed to load leaflet.</div>;
60
-
if (loading && !record) return <div style={{ padding: 12 }}>Loading leaflet…</div>;
61
-
if (!record) return <div style={{ padding: 12, color: 'crimson' }}>Leaflet record missing.</div>;
92
+
if (error)
93
+
return (
94
+
<div style={{ padding: 12, color: "crimson" }}>
95
+
Failed to load leaflet.
96
+
</div>
97
+
);
98
+
if (loading && !record)
99
+
return <div style={{ padding: 12 }}>Loading leaflet…</div>;
100
+
if (!record)
101
+
return (
102
+
<div style={{ padding: 12, color: "crimson" }}>
103
+
Leaflet record missing.
104
+
</div>
105
+
);
62
106
63
-
const publishedAt = record.publishedAt ? new Date(record.publishedAt) : undefined;
64
-
const publishedLabel = publishedAt ? publishedAt.toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short' }) : undefined;
65
-
const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
66
-
const publicationRoot = publicationBaseUrl ?? (publicationRecord?.base_path ?? undefined);
67
-
const resolvedPublicationRoot = publicationRoot ? normalizeLeafletBasePath(publicationRoot) : undefined;
68
-
const publicationLeafletUrl = leafletRkeyUrl(publicationRoot, rkey);
69
-
const viewUrl = canonicalUrl ?? publicationLeafletUrl ?? postUrl ?? (publicationUri ? `https://bsky.app/profile/${publicationUri.did}` : undefined) ?? fallbackLeafletUrl;
107
+
const publishedAt = record.publishedAt
108
+
? new Date(record.publishedAt)
109
+
: undefined;
110
+
const publishedLabel = publishedAt
111
+
? publishedAt.toLocaleString(undefined, {
112
+
dateStyle: "long",
113
+
timeStyle: "short",
114
+
})
115
+
: undefined;
116
+
const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
117
+
const publicationRoot =
118
+
publicationBaseUrl ?? publicationRecord?.base_path ?? undefined;
119
+
const resolvedPublicationRoot = publicationRoot
120
+
? normalizeLeafletBasePath(publicationRoot)
121
+
: undefined;
122
+
const publicationLeafletUrl = leafletRkeyUrl(publicationRoot, rkey);
123
+
const viewUrl =
124
+
canonicalUrl ??
125
+
publicationLeafletUrl ??
126
+
postUrl ??
127
+
(publicationUri
128
+
? `https://bsky.app/profile/${publicationUri.did}`
129
+
: undefined) ??
130
+
fallbackLeafletUrl;
70
131
71
-
const metaItems: React.ReactNode[] = [];
72
-
if (authorLabel) {
73
-
const authorNode = authorHref
74
-
? (
75
-
<a href={authorHref} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
76
-
{authorLabel}
77
-
</a>
78
-
)
79
-
: authorLabel;
80
-
metaItems.push(<span>By {authorNode}</span>);
81
-
}
82
-
if (publishedLabel) metaItems.push(<time dateTime={record.publishedAt}>{publishedLabel}</time>);
83
-
if (resolvedPublicationRoot) {
84
-
metaItems.push(
85
-
<a href={resolvedPublicationRoot} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
86
-
{resolvedPublicationRoot.replace(/^https?:\/\//, '')}
87
-
</a>
88
-
);
89
-
}
90
-
if (viewUrl) {
91
-
metaItems.push(
92
-
<a href={viewUrl} target="_blank" rel="noopener noreferrer" style={palette.metaLink}>
93
-
View source
94
-
</a>
95
-
);
96
-
}
132
+
const metaItems: React.ReactNode[] = [];
133
+
if (authorLabel) {
134
+
const authorNode = authorHref ? (
135
+
<a
136
+
href={authorHref}
137
+
target="_blank"
138
+
rel="noopener noreferrer"
139
+
style={palette.metaLink}
140
+
>
141
+
{authorLabel}
142
+
</a>
143
+
) : (
144
+
authorLabel
145
+
);
146
+
metaItems.push(<span>By {authorNode}</span>);
147
+
}
148
+
if (publishedLabel)
149
+
metaItems.push(
150
+
<time dateTime={record.publishedAt}>{publishedLabel}</time>,
151
+
);
152
+
if (resolvedPublicationRoot) {
153
+
metaItems.push(
154
+
<a
155
+
href={resolvedPublicationRoot}
156
+
target="_blank"
157
+
rel="noopener noreferrer"
158
+
style={palette.metaLink}
159
+
>
160
+
{resolvedPublicationRoot.replace(/^https?:\/\//, "")}
161
+
</a>,
162
+
);
163
+
}
164
+
if (viewUrl) {
165
+
metaItems.push(
166
+
<a
167
+
href={viewUrl}
168
+
target="_blank"
169
+
rel="noopener noreferrer"
170
+
style={palette.metaLink}
171
+
>
172
+
View source
173
+
</a>,
174
+
);
175
+
}
97
176
98
-
return (
99
-
<article style={{ ...base.container, ...palette.container }}>
100
-
<header style={{ ...base.header, ...palette.header }}>
101
-
<div style={base.headerContent}>
102
-
<h1 style={{ ...base.title, ...palette.title }}>{record.title}</h1>
103
-
{record.description && (
104
-
<p style={{ ...base.subtitle, ...palette.subtitle }}>{record.description}</p>
105
-
)}
106
-
</div>
107
-
<div style={{ ...base.meta, ...palette.meta }}>
108
-
{metaItems.map((item, idx) => (
109
-
<React.Fragment key={`meta-${idx}`}>
110
-
{idx > 0 && <span style={palette.metaSeparator}>•</span>}
111
-
{item}
112
-
</React.Fragment>
113
-
))}
114
-
</div>
115
-
</header>
116
-
<div style={base.body}>
117
-
{record.pages?.map((page, pageIndex) => (
118
-
<LeafletPageRenderer
119
-
key={`page-${pageIndex}`}
120
-
page={page}
121
-
documentDid={did}
122
-
colorScheme={scheme}
123
-
/>
124
-
))}
125
-
</div>
126
-
</article>
127
-
);
177
+
return (
178
+
<article style={{ ...base.container, ...palette.container }}>
179
+
<header style={{ ...base.header, ...palette.header }}>
180
+
<div style={base.headerContent}>
181
+
<h1 style={{ ...base.title, ...palette.title }}>
182
+
{record.title}
183
+
</h1>
184
+
{record.description && (
185
+
<p style={{ ...base.subtitle, ...palette.subtitle }}>
186
+
{record.description}
187
+
</p>
188
+
)}
189
+
</div>
190
+
<div style={{ ...base.meta, ...palette.meta }}>
191
+
{metaItems.map((item, idx) => (
192
+
<React.Fragment key={`meta-${idx}`}>
193
+
{idx > 0 && (
194
+
<span style={palette.metaSeparator}>•</span>
195
+
)}
196
+
{item}
197
+
</React.Fragment>
198
+
))}
199
+
</div>
200
+
</header>
201
+
<div style={base.body}>
202
+
{record.pages?.map((page, pageIndex) => (
203
+
<LeafletPageRenderer
204
+
key={`page-${pageIndex}`}
205
+
page={page}
206
+
documentDid={did}
207
+
colorScheme={scheme}
208
+
/>
209
+
))}
210
+
</div>
211
+
</article>
212
+
);
128
213
};
129
214
130
-
const LeafletPageRenderer: React.FC<{ page: LeafletLinearDocumentPage; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ page, documentDid, colorScheme }) => {
131
-
if (!page.blocks?.length) return null;
132
-
return (
133
-
<div style={base.page}>
134
-
{page.blocks.map((blockWrapper, idx) => (
135
-
<LeafletBlockRenderer
136
-
key={`block-${idx}`}
137
-
wrapper={blockWrapper}
138
-
documentDid={documentDid}
139
-
colorScheme={colorScheme}
140
-
isFirst={idx === 0}
141
-
/>
142
-
))}
143
-
</div>
144
-
);
215
+
const LeafletPageRenderer: React.FC<{
216
+
page: LeafletLinearDocumentPage;
217
+
documentDid: string;
218
+
colorScheme: "light" | "dark";
219
+
}> = ({ page, documentDid, colorScheme }) => {
220
+
if (!page.blocks?.length) return null;
221
+
return (
222
+
<div style={base.page}>
223
+
{page.blocks.map((blockWrapper, idx) => (
224
+
<LeafletBlockRenderer
225
+
key={`block-${idx}`}
226
+
wrapper={blockWrapper}
227
+
documentDid={documentDid}
228
+
colorScheme={colorScheme}
229
+
isFirst={idx === 0}
230
+
/>
231
+
))}
232
+
</div>
233
+
);
145
234
};
146
235
147
236
interface LeafletBlockRendererProps {
148
-
wrapper: LeafletLinearDocumentBlock;
149
-
documentDid: string;
150
-
colorScheme: 'light' | 'dark';
151
-
isFirst?: boolean;
237
+
wrapper: LeafletLinearDocumentBlock;
238
+
documentDid: string;
239
+
colorScheme: "light" | "dark";
240
+
isFirst?: boolean;
152
241
}
153
242
154
-
const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({ wrapper, documentDid, colorScheme, isFirst }) => {
155
-
const block = wrapper.block;
156
-
if (!block || !('$type' in block) || !block.$type) {
157
-
return null;
158
-
}
159
-
const alignment = alignmentValue(wrapper.alignment);
243
+
const LeafletBlockRenderer: React.FC<LeafletBlockRendererProps> = ({
244
+
wrapper,
245
+
documentDid,
246
+
colorScheme,
247
+
isFirst,
248
+
}) => {
249
+
const block = wrapper.block;
250
+
if (!block || !("$type" in block) || !block.$type) {
251
+
return null;
252
+
}
253
+
const alignment = alignmentValue(wrapper.alignment);
160
254
161
-
switch (block.$type) {
162
-
case 'pub.leaflet.blocks.header':
163
-
return <LeafletHeaderBlockView block={block} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
164
-
case 'pub.leaflet.blocks.blockquote':
165
-
return <LeafletBlockquoteBlockView block={block} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
166
-
case 'pub.leaflet.blocks.image':
167
-
return <LeafletImageBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
168
-
case 'pub.leaflet.blocks.unorderedList':
169
-
return <LeafletListBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
170
-
case 'pub.leaflet.blocks.website':
171
-
return <LeafletWebsiteBlockView block={block} alignment={alignment} documentDid={documentDid} colorScheme={colorScheme} />;
172
-
case 'pub.leaflet.blocks.iframe':
173
-
return <LeafletIframeBlockView block={block} alignment={alignment} />;
174
-
case 'pub.leaflet.blocks.math':
175
-
return <LeafletMathBlockView block={block} alignment={alignment} colorScheme={colorScheme} />;
176
-
case 'pub.leaflet.blocks.code':
177
-
return <LeafletCodeBlockView block={block} alignment={alignment} colorScheme={colorScheme} />;
178
-
case 'pub.leaflet.blocks.horizontalRule':
179
-
return <LeafletHorizontalRuleBlockView alignment={alignment} colorScheme={colorScheme} />;
180
-
case 'pub.leaflet.blocks.bskyPost':
181
-
return <LeafletBskyPostBlockView block={block} colorScheme={colorScheme} />;
182
-
case 'pub.leaflet.blocks.text':
183
-
default:
184
-
return <LeafletTextBlockView block={block as LeafletTextBlock} alignment={alignment} colorScheme={colorScheme} isFirst={isFirst} />;
185
-
}
255
+
switch (block.$type) {
256
+
case "pub.leaflet.blocks.header":
257
+
return (
258
+
<LeafletHeaderBlockView
259
+
block={block}
260
+
alignment={alignment}
261
+
colorScheme={colorScheme}
262
+
isFirst={isFirst}
263
+
/>
264
+
);
265
+
case "pub.leaflet.blocks.blockquote":
266
+
return (
267
+
<LeafletBlockquoteBlockView
268
+
block={block}
269
+
alignment={alignment}
270
+
colorScheme={colorScheme}
271
+
isFirst={isFirst}
272
+
/>
273
+
);
274
+
case "pub.leaflet.blocks.image":
275
+
return (
276
+
<LeafletImageBlockView
277
+
block={block}
278
+
alignment={alignment}
279
+
documentDid={documentDid}
280
+
colorScheme={colorScheme}
281
+
/>
282
+
);
283
+
case "pub.leaflet.blocks.unorderedList":
284
+
return (
285
+
<LeafletListBlockView
286
+
block={block}
287
+
alignment={alignment}
288
+
documentDid={documentDid}
289
+
colorScheme={colorScheme}
290
+
/>
291
+
);
292
+
case "pub.leaflet.blocks.website":
293
+
return (
294
+
<LeafletWebsiteBlockView
295
+
block={block}
296
+
alignment={alignment}
297
+
documentDid={documentDid}
298
+
colorScheme={colorScheme}
299
+
/>
300
+
);
301
+
case "pub.leaflet.blocks.iframe":
302
+
return (
303
+
<LeafletIframeBlockView block={block} alignment={alignment} />
304
+
);
305
+
case "pub.leaflet.blocks.math":
306
+
return (
307
+
<LeafletMathBlockView
308
+
block={block}
309
+
alignment={alignment}
310
+
colorScheme={colorScheme}
311
+
/>
312
+
);
313
+
case "pub.leaflet.blocks.code":
314
+
return (
315
+
<LeafletCodeBlockView
316
+
block={block}
317
+
alignment={alignment}
318
+
colorScheme={colorScheme}
319
+
/>
320
+
);
321
+
case "pub.leaflet.blocks.horizontalRule":
322
+
return (
323
+
<LeafletHorizontalRuleBlockView
324
+
alignment={alignment}
325
+
colorScheme={colorScheme}
326
+
/>
327
+
);
328
+
case "pub.leaflet.blocks.bskyPost":
329
+
return (
330
+
<LeafletBskyPostBlockView
331
+
block={block}
332
+
colorScheme={colorScheme}
333
+
/>
334
+
);
335
+
case "pub.leaflet.blocks.text":
336
+
default:
337
+
return (
338
+
<LeafletTextBlockView
339
+
block={block as LeafletTextBlock}
340
+
alignment={alignment}
341
+
colorScheme={colorScheme}
342
+
isFirst={isFirst}
343
+
/>
344
+
);
345
+
}
186
346
};
187
347
188
-
const LeafletTextBlockView: React.FC<{ block: LeafletTextBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
189
-
const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
190
-
const textContent = block.plaintext ?? '';
191
-
if (!textContent.trim() && segments.length === 0) {
192
-
return null;
193
-
}
194
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
195
-
const style: React.CSSProperties = {
196
-
...base.paragraph,
197
-
...palette.paragraph,
198
-
...(alignment ? { textAlign: alignment } : undefined),
199
-
...(isFirst ? { marginTop: 0 } : undefined)
200
-
};
201
-
return (
202
-
<p style={style}>
203
-
{segments.map((segment, idx) => (
204
-
<React.Fragment key={`text-${idx}`}>
205
-
{renderSegment(segment, colorScheme)}
206
-
</React.Fragment>
207
-
))}
208
-
</p>
209
-
);
348
+
const LeafletTextBlockView: React.FC<{
349
+
block: LeafletTextBlock;
350
+
alignment?: React.CSSProperties["textAlign"];
351
+
colorScheme: "light" | "dark";
352
+
isFirst?: boolean;
353
+
}> = ({ block, alignment, colorScheme, isFirst }) => {
354
+
const segments = useMemo(
355
+
() => createFacetedSegments(block.plaintext, block.facets),
356
+
[block.plaintext, block.facets],
357
+
);
358
+
const textContent = block.plaintext ?? "";
359
+
if (!textContent.trim() && segments.length === 0) {
360
+
return null;
361
+
}
362
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
363
+
const style: React.CSSProperties = {
364
+
...base.paragraph,
365
+
...palette.paragraph,
366
+
...(alignment ? { textAlign: alignment } : undefined),
367
+
...(isFirst ? { marginTop: 0 } : undefined),
368
+
};
369
+
return (
370
+
<p style={style}>
371
+
{segments.map((segment, idx) => (
372
+
<React.Fragment key={`text-${idx}`}>
373
+
{renderSegment(segment, colorScheme)}
374
+
</React.Fragment>
375
+
))}
376
+
</p>
377
+
);
210
378
};
211
379
212
-
const LeafletHeaderBlockView: React.FC<{ block: LeafletHeaderBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
213
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
214
-
const level = block.level && block.level >= 1 && block.level <= 6 ? block.level : 2;
215
-
const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
216
-
const normalizedLevel = Math.min(Math.max(level, 1), 6) as 1 | 2 | 3 | 4 | 5 | 6;
217
-
const headingTag = (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const)[normalizedLevel - 1];
218
-
const headingStyles = palette.heading[normalizedLevel];
219
-
const style: React.CSSProperties = {
220
-
...base.heading,
221
-
...headingStyles,
222
-
...(alignment ? { textAlign: alignment } : undefined),
223
-
...(isFirst ? { marginTop: 0 } : undefined)
224
-
};
380
+
const LeafletHeaderBlockView: React.FC<{
381
+
block: LeafletHeaderBlock;
382
+
alignment?: React.CSSProperties["textAlign"];
383
+
colorScheme: "light" | "dark";
384
+
isFirst?: boolean;
385
+
}> = ({ block, alignment, colorScheme, isFirst }) => {
386
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
387
+
const level =
388
+
block.level && block.level >= 1 && block.level <= 6 ? block.level : 2;
389
+
const segments = useMemo(
390
+
() => createFacetedSegments(block.plaintext, block.facets),
391
+
[block.plaintext, block.facets],
392
+
);
393
+
const normalizedLevel = Math.min(Math.max(level, 1), 6) as
394
+
| 1
395
+
| 2
396
+
| 3
397
+
| 4
398
+
| 5
399
+
| 6;
400
+
const headingTag = (["h1", "h2", "h3", "h4", "h5", "h6"] as const)[
401
+
normalizedLevel - 1
402
+
];
403
+
const headingStyles = palette.heading[normalizedLevel];
404
+
const style: React.CSSProperties = {
405
+
...base.heading,
406
+
...headingStyles,
407
+
...(alignment ? { textAlign: alignment } : undefined),
408
+
...(isFirst ? { marginTop: 0 } : undefined),
409
+
};
225
410
226
-
return React.createElement(
227
-
headingTag,
228
-
{ style },
229
-
segments.map((segment, idx) => (
230
-
<React.Fragment key={`header-${idx}`}>
231
-
{renderSegment(segment, colorScheme)}
232
-
</React.Fragment>
233
-
))
234
-
);
411
+
return React.createElement(
412
+
headingTag,
413
+
{ style },
414
+
segments.map((segment, idx) => (
415
+
<React.Fragment key={`header-${idx}`}>
416
+
{renderSegment(segment, colorScheme)}
417
+
</React.Fragment>
418
+
)),
419
+
);
235
420
};
236
421
237
-
const LeafletBlockquoteBlockView: React.FC<{ block: LeafletBlockquoteBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark'; isFirst?: boolean }> = ({ block, alignment, colorScheme, isFirst }) => {
238
-
const segments = useMemo(() => createFacetedSegments(block.plaintext, block.facets), [block.plaintext, block.facets]);
239
-
const textContent = block.plaintext ?? '';
240
-
if (!textContent.trim() && segments.length === 0) {
241
-
return null;
242
-
}
243
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
244
-
return (
245
-
<blockquote style={{ ...base.blockquote, ...palette.blockquote, ...(alignment ? { textAlign: alignment } : undefined), ...(isFirst ? { marginTop: 0 } : undefined) }}>
246
-
{segments.map((segment, idx) => (
247
-
<React.Fragment key={`quote-${idx}`}>
248
-
{renderSegment(segment, colorScheme)}
249
-
</React.Fragment>
250
-
))}
251
-
</blockquote>
252
-
);
422
+
const LeafletBlockquoteBlockView: React.FC<{
423
+
block: LeafletBlockquoteBlock;
424
+
alignment?: React.CSSProperties["textAlign"];
425
+
colorScheme: "light" | "dark";
426
+
isFirst?: boolean;
427
+
}> = ({ block, alignment, colorScheme, isFirst }) => {
428
+
const segments = useMemo(
429
+
() => createFacetedSegments(block.plaintext, block.facets),
430
+
[block.plaintext, block.facets],
431
+
);
432
+
const textContent = block.plaintext ?? "";
433
+
if (!textContent.trim() && segments.length === 0) {
434
+
return null;
435
+
}
436
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
437
+
return (
438
+
<blockquote
439
+
style={{
440
+
...base.blockquote,
441
+
...palette.blockquote,
442
+
...(alignment ? { textAlign: alignment } : undefined),
443
+
...(isFirst ? { marginTop: 0 } : undefined),
444
+
}}
445
+
>
446
+
{segments.map((segment, idx) => (
447
+
<React.Fragment key={`quote-${idx}`}>
448
+
{renderSegment(segment, colorScheme)}
449
+
</React.Fragment>
450
+
))}
451
+
</blockquote>
452
+
);
253
453
};
254
454
255
-
const LeafletImageBlockView: React.FC<{ block: LeafletImageBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
256
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
257
-
const cid = block.image?.ref?.$link ?? block.image?.cid;
258
-
const { url, loading, error } = useBlob(documentDid, cid);
259
-
const aspectRatio = block.aspectRatio?.height && block.aspectRatio?.width
260
-
? `${block.aspectRatio.width} / ${block.aspectRatio.height}`
261
-
: undefined;
455
+
const LeafletImageBlockView: React.FC<{
456
+
block: LeafletImageBlock;
457
+
alignment?: React.CSSProperties["textAlign"];
458
+
documentDid: string;
459
+
colorScheme: "light" | "dark";
460
+
}> = ({ block, alignment, documentDid, colorScheme }) => {
461
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
462
+
const cid = block.image?.ref?.$link ?? block.image?.cid;
463
+
const { url, loading, error } = useBlob(documentDid, cid);
464
+
const aspectRatio =
465
+
block.aspectRatio?.height && block.aspectRatio?.width
466
+
? `${block.aspectRatio.width} / ${block.aspectRatio.height}`
467
+
: undefined;
262
468
263
-
return (
264
-
<figure style={{ ...base.figure, ...palette.figure, ...(alignment ? { textAlign: alignment } : undefined) }}>
265
-
<div style={{ ...base.imageWrapper, ...palette.imageWrapper, ...(aspectRatio ? { aspectRatio } : {}) }}>
266
-
{url && !error ? (
267
-
<img src={url} alt={block.alt ?? ''} style={{ ...base.image, ...palette.image }} />
268
-
) : (
269
-
<div style={{ ...base.imagePlaceholder, ...palette.imagePlaceholder }}>
270
-
{loading ? 'Loading image…' : error ? 'Image unavailable' : 'No image'}
271
-
</div>
272
-
)}
273
-
</div>
274
-
{block.alt && block.alt.trim().length > 0 && (
275
-
<figcaption style={{ ...base.caption, ...palette.caption }}>{block.alt}</figcaption>
276
-
)}
277
-
</figure>
278
-
);
469
+
return (
470
+
<figure
471
+
style={{
472
+
...base.figure,
473
+
...palette.figure,
474
+
...(alignment ? { textAlign: alignment } : undefined),
475
+
}}
476
+
>
477
+
<div
478
+
style={{
479
+
...base.imageWrapper,
480
+
...palette.imageWrapper,
481
+
...(aspectRatio ? { aspectRatio } : {}),
482
+
}}
483
+
>
484
+
{url && !error ? (
485
+
<img
486
+
src={url}
487
+
alt={block.alt ?? ""}
488
+
style={{ ...base.image, ...palette.image }}
489
+
/>
490
+
) : (
491
+
<div
492
+
style={{
493
+
...base.imagePlaceholder,
494
+
...palette.imagePlaceholder,
495
+
}}
496
+
>
497
+
{loading
498
+
? "Loading image…"
499
+
: error
500
+
? "Image unavailable"
501
+
: "No image"}
502
+
</div>
503
+
)}
504
+
</div>
505
+
{block.alt && block.alt.trim().length > 0 && (
506
+
<figcaption style={{ ...base.caption, ...palette.caption }}>
507
+
{block.alt}
508
+
</figcaption>
509
+
)}
510
+
</figure>
511
+
);
279
512
};
280
513
281
-
const LeafletListBlockView: React.FC<{ block: LeafletUnorderedListBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
282
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
283
-
return (
284
-
<ul style={{ ...base.list, ...palette.list, ...(alignment ? { textAlign: alignment } : undefined) }}>
285
-
{block.children?.map((child, idx) => (
286
-
<LeafletListItemRenderer
287
-
key={`list-item-${idx}`}
288
-
item={child}
289
-
documentDid={documentDid}
290
-
colorScheme={colorScheme}
291
-
alignment={alignment}
292
-
/>
293
-
))}
294
-
</ul>
295
-
);
514
+
const LeafletListBlockView: React.FC<{
515
+
block: LeafletUnorderedListBlock;
516
+
alignment?: React.CSSProperties["textAlign"];
517
+
documentDid: string;
518
+
colorScheme: "light" | "dark";
519
+
}> = ({ block, alignment, documentDid, colorScheme }) => {
520
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
521
+
return (
522
+
<ul
523
+
style={{
524
+
...base.list,
525
+
...palette.list,
526
+
...(alignment ? { textAlign: alignment } : undefined),
527
+
}}
528
+
>
529
+
{block.children?.map((child, idx) => (
530
+
<LeafletListItemRenderer
531
+
key={`list-item-${idx}`}
532
+
item={child}
533
+
documentDid={documentDid}
534
+
colorScheme={colorScheme}
535
+
alignment={alignment}
536
+
/>
537
+
))}
538
+
</ul>
539
+
);
296
540
};
297
541
298
-
const LeafletListItemRenderer: React.FC<{ item: LeafletListItem; documentDid: string; colorScheme: 'light' | 'dark'; alignment?: React.CSSProperties['textAlign'] }> = ({ item, documentDid, colorScheme, alignment }) => {
299
-
return (
300
-
<li style={{ ...base.listItem, ...(alignment ? { textAlign: alignment } : undefined) }}>
301
-
<div>
302
-
<LeafletInlineBlock block={item.content} colorScheme={colorScheme} documentDid={documentDid} alignment={alignment} />
303
-
</div>
304
-
{item.children && item.children.length > 0 && (
305
-
<ul style={{ ...base.nestedList, ...(alignment ? { textAlign: alignment } : undefined) }}>
306
-
{item.children.map((child, idx) => (
307
-
<LeafletListItemRenderer key={`nested-${idx}`} item={child} documentDid={documentDid} colorScheme={colorScheme} alignment={alignment} />
308
-
))}
309
-
</ul>
310
-
)}
311
-
</li>
312
-
);
542
+
const LeafletListItemRenderer: React.FC<{
543
+
item: LeafletListItem;
544
+
documentDid: string;
545
+
colorScheme: "light" | "dark";
546
+
alignment?: React.CSSProperties["textAlign"];
547
+
}> = ({ item, documentDid, colorScheme, alignment }) => {
548
+
return (
549
+
<li
550
+
style={{
551
+
...base.listItem,
552
+
...(alignment ? { textAlign: alignment } : undefined),
553
+
}}
554
+
>
555
+
<div>
556
+
<LeafletInlineBlock
557
+
block={item.content}
558
+
colorScheme={colorScheme}
559
+
documentDid={documentDid}
560
+
alignment={alignment}
561
+
/>
562
+
</div>
563
+
{item.children && item.children.length > 0 && (
564
+
<ul
565
+
style={{
566
+
...base.nestedList,
567
+
...(alignment ? { textAlign: alignment } : undefined),
568
+
}}
569
+
>
570
+
{item.children.map((child, idx) => (
571
+
<LeafletListItemRenderer
572
+
key={`nested-${idx}`}
573
+
item={child}
574
+
documentDid={documentDid}
575
+
colorScheme={colorScheme}
576
+
alignment={alignment}
577
+
/>
578
+
))}
579
+
</ul>
580
+
)}
581
+
</li>
582
+
);
313
583
};
314
584
315
-
const LeafletInlineBlock: React.FC<{ block: LeafletBlock; colorScheme: 'light' | 'dark'; documentDid: string; alignment?: React.CSSProperties['textAlign'] }> = ({ block, colorScheme, documentDid, alignment }) => {
316
-
switch (block.$type) {
317
-
case 'pub.leaflet.blocks.header':
318
-
return <LeafletHeaderBlockView block={block as LeafletHeaderBlock} colorScheme={colorScheme} alignment={alignment} />;
319
-
case 'pub.leaflet.blocks.blockquote':
320
-
return <LeafletBlockquoteBlockView block={block as LeafletBlockquoteBlock} colorScheme={colorScheme} alignment={alignment} />;
321
-
case 'pub.leaflet.blocks.image':
322
-
return <LeafletImageBlockView block={block as LeafletImageBlock} documentDid={documentDid} colorScheme={colorScheme} alignment={alignment} />;
323
-
default:
324
-
return <LeafletTextBlockView block={block as LeafletTextBlock} colorScheme={colorScheme} alignment={alignment} />;
325
-
}
585
+
const LeafletInlineBlock: React.FC<{
586
+
block: LeafletBlock;
587
+
colorScheme: "light" | "dark";
588
+
documentDid: string;
589
+
alignment?: React.CSSProperties["textAlign"];
590
+
}> = ({ block, colorScheme, documentDid, alignment }) => {
591
+
switch (block.$type) {
592
+
case "pub.leaflet.blocks.header":
593
+
return (
594
+
<LeafletHeaderBlockView
595
+
block={block as LeafletHeaderBlock}
596
+
colorScheme={colorScheme}
597
+
alignment={alignment}
598
+
/>
599
+
);
600
+
case "pub.leaflet.blocks.blockquote":
601
+
return (
602
+
<LeafletBlockquoteBlockView
603
+
block={block as LeafletBlockquoteBlock}
604
+
colorScheme={colorScheme}
605
+
alignment={alignment}
606
+
/>
607
+
);
608
+
case "pub.leaflet.blocks.image":
609
+
return (
610
+
<LeafletImageBlockView
611
+
block={block as LeafletImageBlock}
612
+
documentDid={documentDid}
613
+
colorScheme={colorScheme}
614
+
alignment={alignment}
615
+
/>
616
+
);
617
+
default:
618
+
return (
619
+
<LeafletTextBlockView
620
+
block={block as LeafletTextBlock}
621
+
colorScheme={colorScheme}
622
+
alignment={alignment}
623
+
/>
624
+
);
625
+
}
326
626
};
327
627
328
-
const LeafletWebsiteBlockView: React.FC<{ block: LeafletWebsiteBlock; alignment?: React.CSSProperties['textAlign']; documentDid: string; colorScheme: 'light' | 'dark' }> = ({ block, alignment, documentDid, colorScheme }) => {
329
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
330
-
const previewCid = block.previewImage?.ref?.$link ?? block.previewImage?.cid;
331
-
const { url, loading, error } = useBlob(documentDid, previewCid);
628
+
const LeafletWebsiteBlockView: React.FC<{
629
+
block: LeafletWebsiteBlock;
630
+
alignment?: React.CSSProperties["textAlign"];
631
+
documentDid: string;
632
+
colorScheme: "light" | "dark";
633
+
}> = ({ block, alignment, documentDid, colorScheme }) => {
634
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
635
+
const previewCid =
636
+
block.previewImage?.ref?.$link ?? block.previewImage?.cid;
637
+
const { url, loading, error } = useBlob(documentDid, previewCid);
332
638
333
-
return (
334
-
<a href={block.src} target="_blank" rel="noopener noreferrer" style={{ ...base.linkCard, ...palette.linkCard, ...(alignment ? { textAlign: alignment } : undefined) }}>
335
-
{url && !error ? (
336
-
<img src={url} alt={block.title ?? 'Website preview'} style={{ ...base.linkPreview, ...palette.linkPreview }} />
337
-
) : (
338
-
<div style={{ ...base.linkPreviewPlaceholder, ...palette.linkPreviewPlaceholder }}>
339
-
{loading ? 'Loading preview…' : 'Open link'}
340
-
</div>
341
-
)}
342
-
<div style={base.linkContent}>
343
-
{block.title && <strong style={palette.linkTitle}>{block.title}</strong>}
344
-
{block.description && <p style={palette.linkDescription}>{block.description}</p>}
345
-
<span style={palette.linkUrl}>{block.src}</span>
346
-
</div>
347
-
</a>
348
-
);
639
+
return (
640
+
<a
641
+
href={block.src}
642
+
target="_blank"
643
+
rel="noopener noreferrer"
644
+
style={{
645
+
...base.linkCard,
646
+
...palette.linkCard,
647
+
...(alignment ? { textAlign: alignment } : undefined),
648
+
}}
649
+
>
650
+
{url && !error ? (
651
+
<img
652
+
src={url}
653
+
alt={block.title ?? "Website preview"}
654
+
style={{ ...base.linkPreview, ...palette.linkPreview }}
655
+
/>
656
+
) : (
657
+
<div
658
+
style={{
659
+
...base.linkPreviewPlaceholder,
660
+
...palette.linkPreviewPlaceholder,
661
+
}}
662
+
>
663
+
{loading ? "Loading preview…" : "Open link"}
664
+
</div>
665
+
)}
666
+
<div style={base.linkContent}>
667
+
{block.title && (
668
+
<strong style={palette.linkTitle}>{block.title}</strong>
669
+
)}
670
+
{block.description && (
671
+
<p style={palette.linkDescription}>{block.description}</p>
672
+
)}
673
+
<span style={palette.linkUrl}>{block.src}</span>
674
+
</div>
675
+
</a>
676
+
);
349
677
};
350
678
351
-
const LeafletIframeBlockView: React.FC<{ block: LeafletIFrameBlock; alignment?: React.CSSProperties['textAlign'] }> = ({ block, alignment }) => {
352
-
return (
353
-
<div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}>
354
-
<iframe
355
-
src={block.url}
356
-
title={block.url}
357
-
style={{ ...base.iframe, ...(block.height ? { height: Math.min(Math.max(block.height, 120), 800) } : {}) }}
358
-
loading="lazy"
359
-
allowFullScreen
360
-
/>
361
-
</div>
362
-
);
679
+
const LeafletIframeBlockView: React.FC<{
680
+
block: LeafletIFrameBlock;
681
+
alignment?: React.CSSProperties["textAlign"];
682
+
}> = ({ block, alignment }) => {
683
+
return (
684
+
<div style={{ ...(alignment ? { textAlign: alignment } : undefined) }}>
685
+
<iframe
686
+
src={block.url}
687
+
title={block.url}
688
+
style={{
689
+
...base.iframe,
690
+
...(block.height
691
+
? { height: Math.min(Math.max(block.height, 120), 800) }
692
+
: {}),
693
+
}}
694
+
loading="lazy"
695
+
allowFullScreen
696
+
/>
697
+
</div>
698
+
);
363
699
};
364
700
365
-
const LeafletMathBlockView: React.FC<{ block: LeafletMathBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ block, alignment, colorScheme }) => {
366
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
367
-
return (
368
-
<pre style={{ ...base.math, ...palette.math, ...(alignment ? { textAlign: alignment } : undefined) }}>{block.tex}</pre>
369
-
);
701
+
const LeafletMathBlockView: React.FC<{
702
+
block: LeafletMathBlock;
703
+
alignment?: React.CSSProperties["textAlign"];
704
+
colorScheme: "light" | "dark";
705
+
}> = ({ block, alignment, colorScheme }) => {
706
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
707
+
return (
708
+
<pre
709
+
style={{
710
+
...base.math,
711
+
...palette.math,
712
+
...(alignment ? { textAlign: alignment } : undefined),
713
+
}}
714
+
>
715
+
{block.tex}
716
+
</pre>
717
+
);
370
718
};
371
719
372
-
const LeafletCodeBlockView: React.FC<{ block: LeafletCodeBlock; alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ block, alignment, colorScheme }) => {
373
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
374
-
const codeRef = useRef<HTMLElement | null>(null);
375
-
const langClass = block.language ? `language-${block.language.toLowerCase()}` : undefined;
376
-
return (
377
-
<pre style={{ ...base.code, ...palette.code, ...(alignment ? { textAlign: alignment } : undefined) }}>
378
-
<code ref={codeRef} className={langClass}>{block.plaintext}</code>
379
-
</pre>
380
-
);
720
+
const LeafletCodeBlockView: React.FC<{
721
+
block: LeafletCodeBlock;
722
+
alignment?: React.CSSProperties["textAlign"];
723
+
colorScheme: "light" | "dark";
724
+
}> = ({ block, alignment, colorScheme }) => {
725
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
726
+
const codeRef = useRef<HTMLElement | null>(null);
727
+
const langClass = block.language
728
+
? `language-${block.language.toLowerCase()}`
729
+
: undefined;
730
+
return (
731
+
<pre
732
+
style={{
733
+
...base.code,
734
+
...palette.code,
735
+
...(alignment ? { textAlign: alignment } : undefined),
736
+
}}
737
+
>
738
+
<code ref={codeRef} className={langClass}>
739
+
{block.plaintext}
740
+
</code>
741
+
</pre>
742
+
);
381
743
};
382
744
383
-
const LeafletHorizontalRuleBlockView: React.FC<{ alignment?: React.CSSProperties['textAlign']; colorScheme: 'light' | 'dark' }> = ({ alignment, colorScheme }) => {
384
-
const palette = colorScheme === 'dark' ? theme.dark : theme.light;
385
-
return <hr style={{ ...base.hr, ...palette.hr, marginLeft: alignment ? 'auto' : undefined, marginRight: alignment ? 'auto' : undefined }} />;
745
+
const LeafletHorizontalRuleBlockView: React.FC<{
746
+
alignment?: React.CSSProperties["textAlign"];
747
+
colorScheme: "light" | "dark";
748
+
}> = ({ alignment, colorScheme }) => {
749
+
const palette = colorScheme === "dark" ? theme.dark : theme.light;
750
+
return (
751
+
<hr
752
+
style={{
753
+
...base.hr,
754
+
...palette.hr,
755
+
marginLeft: alignment ? "auto" : undefined,
756
+
marginRight: alignment ? "auto" : undefined,
757
+
}}
758
+
/>
759
+
);
386
760
};
387
761
388
-
const LeafletBskyPostBlockView: React.FC<{ block: LeafletBskyPostBlock; colorScheme: 'light' | 'dark' }> = ({ block, colorScheme }) => {
389
-
const parsed = parseAtUri(block.postRef?.uri);
390
-
if (!parsed) {
391
-
return <div style={base.embedFallback}>Referenced post unavailable.</div>;
392
-
}
393
-
return <BlueskyPost did={parsed.did} rkey={parsed.rkey} colorScheme={colorScheme} iconPlacement="linkInline" />;
762
+
const LeafletBskyPostBlockView: React.FC<{
763
+
block: LeafletBskyPostBlock;
764
+
colorScheme: "light" | "dark";
765
+
}> = ({ block, colorScheme }) => {
766
+
const parsed = parseAtUri(block.postRef?.uri);
767
+
if (!parsed) {
768
+
return (
769
+
<div style={base.embedFallback}>Referenced post unavailable.</div>
770
+
);
771
+
}
772
+
return (
773
+
<BlueskyPost
774
+
did={parsed.did}
775
+
rkey={parsed.rkey}
776
+
colorScheme={colorScheme}
777
+
iconPlacement="linkInline"
778
+
/>
779
+
);
394
780
};
395
781
396
-
function alignmentValue(value?: LeafletAlignmentValue): React.CSSProperties['textAlign'] | undefined {
397
-
if (!value) return undefined;
398
-
let normalized = value.startsWith('#') ? value.slice(1) : value;
399
-
if (normalized.includes('#')) {
400
-
normalized = normalized.split('#').pop() ?? normalized;
401
-
}
402
-
if (normalized.startsWith('lex:')) {
403
-
normalized = normalized.split(':').pop() ?? normalized;
404
-
}
405
-
switch (normalized) {
406
-
case 'textAlignLeft':
407
-
return 'left';
408
-
case 'textAlignCenter':
409
-
return 'center';
410
-
case 'textAlignRight':
411
-
return 'right';
412
-
case 'textAlignJustify':
413
-
return 'justify';
414
-
default:
415
-
return undefined;
416
-
}
782
+
function alignmentValue(
783
+
value?: LeafletAlignmentValue,
784
+
): React.CSSProperties["textAlign"] | undefined {
785
+
if (!value) return undefined;
786
+
let normalized = value.startsWith("#") ? value.slice(1) : value;
787
+
if (normalized.includes("#")) {
788
+
normalized = normalized.split("#").pop() ?? normalized;
789
+
}
790
+
if (normalized.startsWith("lex:")) {
791
+
normalized = normalized.split(":").pop() ?? normalized;
792
+
}
793
+
switch (normalized) {
794
+
case "textAlignLeft":
795
+
return "left";
796
+
case "textAlignCenter":
797
+
return "center";
798
+
case "textAlignRight":
799
+
return "right";
800
+
case "textAlignJustify":
801
+
return "justify";
802
+
default:
803
+
return undefined;
804
+
}
417
805
}
418
806
419
-
function useAuthorLabel(author: string | undefined, authorDid: string | undefined): string | undefined {
420
-
const { handle } = useDidResolution(authorDid);
421
-
if (!author) return undefined;
422
-
if (handle) return `@${handle}`;
423
-
if (authorDid) return formatDidForLabel(authorDid);
424
-
return author;
807
+
function useAuthorLabel(
808
+
author: string | undefined,
809
+
authorDid: string | undefined,
810
+
): string | undefined {
811
+
const { handle } = useDidResolution(authorDid);
812
+
if (!author) return undefined;
813
+
if (handle) return `@${handle}`;
814
+
if (authorDid) return formatDidForLabel(authorDid);
815
+
return author;
425
816
}
426
817
427
818
interface Segment {
428
-
text: string;
429
-
features: LeafletRichTextFeature[];
819
+
text: string;
820
+
features: LeafletRichTextFeature[];
430
821
}
431
822
432
-
function createFacetedSegments(plaintext: string, facets?: LeafletRichTextFacet[]): Segment[] {
433
-
if (!facets?.length) {
434
-
return [{ text: plaintext, features: [] }];
435
-
}
436
-
const prefix = buildBytePrefix(plaintext);
437
-
const startEvents = new Map<number, LeafletRichTextFeature[]>();
438
-
const endEvents = new Map<number, LeafletRichTextFeature[]>();
439
-
const boundaries = new Set<number>([0, prefix.length - 1]);
440
-
for (const facet of facets) {
441
-
const { byteStart, byteEnd } = facet.index ?? {};
442
-
if (typeof byteStart !== 'number' || typeof byteEnd !== 'number' || byteStart >= byteEnd) continue;
443
-
const start = byteOffsetToCharIndex(prefix, byteStart);
444
-
const end = byteOffsetToCharIndex(prefix, byteEnd);
445
-
if (start >= end) continue;
446
-
boundaries.add(start);
447
-
boundaries.add(end);
448
-
if (facet.features?.length) {
449
-
startEvents.set(start, [...(startEvents.get(start) ?? []), ...facet.features]);
450
-
endEvents.set(end, [...(endEvents.get(end) ?? []), ...facet.features]);
451
-
}
452
-
}
453
-
const sortedBounds = [...boundaries].sort((a, b) => a - b);
454
-
const segments: Segment[] = [];
455
-
let active: LeafletRichTextFeature[] = [];
456
-
for (let i = 0; i < sortedBounds.length - 1; i++) {
457
-
const boundary = sortedBounds[i];
458
-
const next = sortedBounds[i + 1];
459
-
const endFeatures = endEvents.get(boundary);
460
-
if (endFeatures?.length) {
461
-
active = active.filter((feature) => !endFeatures.includes(feature));
462
-
}
463
-
const startFeatures = startEvents.get(boundary);
464
-
if (startFeatures?.length) {
465
-
active = [...active, ...startFeatures];
466
-
}
467
-
if (boundary === next) continue;
468
-
const text = sliceByCharRange(plaintext, boundary, next);
469
-
segments.push({ text, features: active.slice() });
470
-
}
471
-
return segments;
823
+
function createFacetedSegments(
824
+
plaintext: string,
825
+
facets?: LeafletRichTextFacet[],
826
+
): Segment[] {
827
+
if (!facets?.length) {
828
+
return [{ text: plaintext, features: [] }];
829
+
}
830
+
const prefix = buildBytePrefix(plaintext);
831
+
const startEvents = new Map<number, LeafletRichTextFeature[]>();
832
+
const endEvents = new Map<number, LeafletRichTextFeature[]>();
833
+
const boundaries = new Set<number>([0, prefix.length - 1]);
834
+
for (const facet of facets) {
835
+
const { byteStart, byteEnd } = facet.index ?? {};
836
+
if (
837
+
typeof byteStart !== "number" ||
838
+
typeof byteEnd !== "number" ||
839
+
byteStart >= byteEnd
840
+
)
841
+
continue;
842
+
const start = byteOffsetToCharIndex(prefix, byteStart);
843
+
const end = byteOffsetToCharIndex(prefix, byteEnd);
844
+
if (start >= end) continue;
845
+
boundaries.add(start);
846
+
boundaries.add(end);
847
+
if (facet.features?.length) {
848
+
startEvents.set(start, [
849
+
...(startEvents.get(start) ?? []),
850
+
...facet.features,
851
+
]);
852
+
endEvents.set(end, [
853
+
...(endEvents.get(end) ?? []),
854
+
...facet.features,
855
+
]);
856
+
}
857
+
}
858
+
const sortedBounds = [...boundaries].sort((a, b) => a - b);
859
+
const segments: Segment[] = [];
860
+
let active: LeafletRichTextFeature[] = [];
861
+
for (let i = 0; i < sortedBounds.length - 1; i++) {
862
+
const boundary = sortedBounds[i];
863
+
const next = sortedBounds[i + 1];
864
+
const endFeatures = endEvents.get(boundary);
865
+
if (endFeatures?.length) {
866
+
active = active.filter((feature) => !endFeatures.includes(feature));
867
+
}
868
+
const startFeatures = startEvents.get(boundary);
869
+
if (startFeatures?.length) {
870
+
active = [...active, ...startFeatures];
871
+
}
872
+
if (boundary === next) continue;
873
+
const text = sliceByCharRange(plaintext, boundary, next);
874
+
segments.push({ text, features: active.slice() });
875
+
}
876
+
return segments;
472
877
}
473
878
474
879
function buildBytePrefix(text: string): number[] {
475
-
const encoder = new TextEncoder();
476
-
const prefix: number[] = [0];
477
-
let byteCount = 0;
478
-
for (let i = 0; i < text.length;) {
479
-
const codePoint = text.codePointAt(i)!;
480
-
const char = String.fromCodePoint(codePoint);
481
-
const encoded = encoder.encode(char);
482
-
byteCount += encoded.length;
483
-
prefix.push(byteCount);
484
-
i += codePoint > 0xffff ? 2 : 1;
485
-
}
486
-
return prefix;
880
+
const encoder = new TextEncoder();
881
+
const prefix: number[] = [0];
882
+
let byteCount = 0;
883
+
for (let i = 0; i < text.length; ) {
884
+
const codePoint = text.codePointAt(i)!;
885
+
const char = String.fromCodePoint(codePoint);
886
+
const encoded = encoder.encode(char);
887
+
byteCount += encoded.length;
888
+
prefix.push(byteCount);
889
+
i += codePoint > 0xffff ? 2 : 1;
890
+
}
891
+
return prefix;
487
892
}
488
893
489
894
function byteOffsetToCharIndex(prefix: number[], byteOffset: number): number {
490
-
for (let i = 0; i < prefix.length; i++) {
491
-
if (prefix[i] === byteOffset) return i;
492
-
if (prefix[i] > byteOffset) return Math.max(0, i - 1);
493
-
}
494
-
return prefix.length - 1;
895
+
for (let i = 0; i < prefix.length; i++) {
896
+
if (prefix[i] === byteOffset) return i;
897
+
if (prefix[i] > byteOffset) return Math.max(0, i - 1);
898
+
}
899
+
return prefix.length - 1;
495
900
}
496
901
497
902
function sliceByCharRange(text: string, start: number, end: number): string {
498
-
if (start <= 0 && end >= text.length) return text;
499
-
let result = '';
500
-
let charIndex = 0;
501
-
for (let i = 0; i < text.length && charIndex < end;) {
502
-
const codePoint = text.codePointAt(i)!;
503
-
const char = String.fromCodePoint(codePoint);
504
-
if (charIndex >= start && charIndex < end) result += char;
505
-
i += codePoint > 0xffff ? 2 : 1;
506
-
charIndex++;
507
-
}
508
-
return result;
903
+
if (start <= 0 && end >= text.length) return text;
904
+
let result = "";
905
+
let charIndex = 0;
906
+
for (let i = 0; i < text.length && charIndex < end; ) {
907
+
const codePoint = text.codePointAt(i)!;
908
+
const char = String.fromCodePoint(codePoint);
909
+
if (charIndex >= start && charIndex < end) result += char;
910
+
i += codePoint > 0xffff ? 2 : 1;
911
+
charIndex++;
912
+
}
913
+
return result;
509
914
}
510
915
511
-
function renderSegment(segment: Segment, colorScheme: 'light' | 'dark'): React.ReactNode {
512
-
const parts = segment.text.split('\n');
513
-
return parts.flatMap((part, idx) => {
514
-
const key = `${segment.text}-${idx}-${part.length}`;
515
-
const wrapped = applyFeatures(part.length ? part : '\u00a0', segment.features, key, colorScheme);
516
-
if (idx === parts.length - 1) return wrapped;
517
-
return [wrapped, <br key={`${key}-br`} />];
518
-
});
916
+
function renderSegment(
917
+
segment: Segment,
918
+
colorScheme: "light" | "dark",
919
+
): React.ReactNode {
920
+
const parts = segment.text.split("\n");
921
+
return parts.flatMap((part, idx) => {
922
+
const key = `${segment.text}-${idx}-${part.length}`;
923
+
const wrapped = applyFeatures(
924
+
part.length ? part : "\u00a0",
925
+
segment.features,
926
+
key,
927
+
colorScheme,
928
+
);
929
+
if (idx === parts.length - 1) return wrapped;
930
+
return [wrapped, <br key={`${key}-br`} />];
931
+
});
519
932
}
520
933
521
-
function applyFeatures(content: React.ReactNode, features: LeafletRichTextFeature[], key: string, colorScheme: 'light' | 'dark'): React.ReactNode {
522
-
if (!features?.length) return <React.Fragment key={key}>{content}</React.Fragment>;
523
-
return (
524
-
<React.Fragment key={key}>
525
-
{features.reduce<React.ReactNode>((child, feature, idx) => wrapFeature(child, feature, `${key}-feature-${idx}`, colorScheme), content)}
526
-
</React.Fragment>
527
-
);
934
+
function applyFeatures(
935
+
content: React.ReactNode,
936
+
features: LeafletRichTextFeature[],
937
+
key: string,
938
+
colorScheme: "light" | "dark",
939
+
): React.ReactNode {
940
+
if (!features?.length)
941
+
return <React.Fragment key={key}>{content}</React.Fragment>;
942
+
return (
943
+
<React.Fragment key={key}>
944
+
{features.reduce<React.ReactNode>(
945
+
(child, feature, idx) =>
946
+
wrapFeature(
947
+
child,
948
+
feature,
949
+
`${key}-feature-${idx}`,
950
+
colorScheme,
951
+
),
952
+
content,
953
+
)}
954
+
</React.Fragment>
955
+
);
528
956
}
529
957
530
-
function wrapFeature(child: React.ReactNode, feature: LeafletRichTextFeature, key: string, colorScheme: 'light' | 'dark'): React.ReactNode {
531
-
switch (feature.$type) {
532
-
case 'pub.leaflet.richtext.facet#link':
533
-
return <a key={key} href={feature.uri} target="_blank" rel="noopener noreferrer" style={linkStyles[colorScheme]}>{child}</a>;
534
-
case 'pub.leaflet.richtext.facet#code':
535
-
return <code key={key} style={inlineCodeStyles[colorScheme]}>{child}</code>;
536
-
case 'pub.leaflet.richtext.facet#highlight':
537
-
return <mark key={key} style={highlightStyles[colorScheme]}>{child}</mark>;
538
-
case 'pub.leaflet.richtext.facet#underline':
539
-
return <span key={key} style={{ textDecoration: 'underline' }}>{child}</span>;
540
-
case 'pub.leaflet.richtext.facet#strikethrough':
541
-
return <span key={key} style={{ textDecoration: 'line-through' }}>{child}</span>;
542
-
case 'pub.leaflet.richtext.facet#bold':
543
-
return <strong key={key}>{child}</strong>;
544
-
case 'pub.leaflet.richtext.facet#italic':
545
-
return <em key={key}>{child}</em>;
546
-
case 'pub.leaflet.richtext.facet#id':
547
-
return <span key={key} id={feature.id}>{child}</span>;
548
-
default:
549
-
return <span key={key}>{child}</span>;
550
-
}
958
+
function wrapFeature(
959
+
child: React.ReactNode,
960
+
feature: LeafletRichTextFeature,
961
+
key: string,
962
+
colorScheme: "light" | "dark",
963
+
): React.ReactNode {
964
+
switch (feature.$type) {
965
+
case "pub.leaflet.richtext.facet#link":
966
+
return (
967
+
<a
968
+
key={key}
969
+
href={feature.uri}
970
+
target="_blank"
971
+
rel="noopener noreferrer"
972
+
style={linkStyles[colorScheme]}
973
+
>
974
+
{child}
975
+
</a>
976
+
);
977
+
case "pub.leaflet.richtext.facet#code":
978
+
return (
979
+
<code key={key} style={inlineCodeStyles[colorScheme]}>
980
+
{child}
981
+
</code>
982
+
);
983
+
case "pub.leaflet.richtext.facet#highlight":
984
+
return (
985
+
<mark key={key} style={highlightStyles[colorScheme]}>
986
+
{child}
987
+
</mark>
988
+
);
989
+
case "pub.leaflet.richtext.facet#underline":
990
+
return (
991
+
<span key={key} style={{ textDecoration: "underline" }}>
992
+
{child}
993
+
</span>
994
+
);
995
+
case "pub.leaflet.richtext.facet#strikethrough":
996
+
return (
997
+
<span key={key} style={{ textDecoration: "line-through" }}>
998
+
{child}
999
+
</span>
1000
+
);
1001
+
case "pub.leaflet.richtext.facet#bold":
1002
+
return <strong key={key}>{child}</strong>;
1003
+
case "pub.leaflet.richtext.facet#italic":
1004
+
return <em key={key}>{child}</em>;
1005
+
case "pub.leaflet.richtext.facet#id":
1006
+
return (
1007
+
<span key={key} id={feature.id}>
1008
+
{child}
1009
+
</span>
1010
+
);
1011
+
default:
1012
+
return <span key={key}>{child}</span>;
1013
+
}
551
1014
}
552
1015
553
1016
const base: Record<string, React.CSSProperties> = {
554
-
container: {
555
-
display: 'flex',
556
-
flexDirection: 'column',
557
-
gap: 24,
558
-
padding: '24px 28px',
559
-
borderRadius: 20,
560
-
border: '1px solid transparent',
561
-
maxWidth: 720,
562
-
width: '100%',
563
-
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
564
-
},
565
-
header: {
566
-
display: 'flex',
567
-
flexDirection: 'column',
568
-
gap: 16
569
-
},
570
-
headerContent: {
571
-
display: 'flex',
572
-
flexDirection: 'column',
573
-
gap: 8
574
-
},
575
-
title: {
576
-
fontSize: 32,
577
-
margin: 0,
578
-
lineHeight: 1.15
579
-
},
580
-
subtitle: {
581
-
margin: 0,
582
-
fontSize: 16,
583
-
lineHeight: 1.5
584
-
},
585
-
meta: {
586
-
display: 'flex',
587
-
flexWrap: 'wrap',
588
-
gap: 8,
589
-
alignItems: 'center',
590
-
fontSize: 14
591
-
},
592
-
body: {
593
-
display: 'flex',
594
-
flexDirection: 'column',
595
-
gap: 18
596
-
},
597
-
page: {
598
-
display: 'flex',
599
-
flexDirection: 'column',
600
-
gap: 18
601
-
},
602
-
paragraph: {
603
-
margin: '1em 0 0',
604
-
lineHeight: 1.65,
605
-
fontSize: 16
606
-
},
607
-
heading: {
608
-
margin: '0.5em 0 0',
609
-
fontWeight: 700
610
-
},
611
-
blockquote: {
612
-
margin: '1em 0 0',
613
-
padding: '0.6em 1em',
614
-
borderLeft: '4px solid'
615
-
},
616
-
figure: {
617
-
margin: '1.2em 0 0',
618
-
display: 'flex',
619
-
flexDirection: 'column',
620
-
gap: 12
621
-
},
622
-
imageWrapper: {
623
-
borderRadius: 16,
624
-
overflow: 'hidden',
625
-
width: '100%',
626
-
position: 'relative',
627
-
background: '#e2e8f0'
628
-
},
629
-
image: {
630
-
width: '100%',
631
-
height: '100%',
632
-
objectFit: 'cover',
633
-
display: 'block'
634
-
},
635
-
imagePlaceholder: {
636
-
width: '100%',
637
-
padding: '24px 16px',
638
-
textAlign: 'center'
639
-
},
640
-
caption: {
641
-
fontSize: 13,
642
-
lineHeight: 1.4
643
-
},
644
-
list: {
645
-
paddingLeft: 28,
646
-
margin: '1em 0 0',
647
-
listStyleType: 'disc',
648
-
listStylePosition: 'outside'
649
-
},
650
-
nestedList: {
651
-
paddingLeft: 20,
652
-
marginTop: 8,
653
-
listStyleType: 'circle',
654
-
listStylePosition: 'outside'
655
-
},
656
-
listItem: {
657
-
marginTop: 8,
658
-
display: 'list-item'
659
-
},
660
-
linkCard: {
661
-
borderRadius: 16,
662
-
border: '1px solid',
663
-
display: 'flex',
664
-
flexDirection: 'column',
665
-
overflow: 'hidden',
666
-
textDecoration: 'none'
667
-
},
668
-
linkPreview: {
669
-
width: '100%',
670
-
height: 180,
671
-
objectFit: 'cover'
672
-
},
673
-
linkPreviewPlaceholder: {
674
-
width: '100%',
675
-
height: 180,
676
-
display: 'flex',
677
-
alignItems: 'center',
678
-
justifyContent: 'center',
679
-
fontSize: 14
680
-
},
681
-
linkContent: {
682
-
display: 'flex',
683
-
flexDirection: 'column',
684
-
gap: 6,
685
-
padding: '16px 18px'
686
-
},
687
-
iframe: {
688
-
width: '100%',
689
-
height: 360,
690
-
border: '1px solid #cbd5f5',
691
-
borderRadius: 16
692
-
},
693
-
math: {
694
-
margin: '1em 0 0',
695
-
padding: '14px 16px',
696
-
borderRadius: 12,
697
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
698
-
overflowX: 'auto'
699
-
},
700
-
code: {
701
-
margin: '1em 0 0',
702
-
padding: '14px 16px',
703
-
borderRadius: 12,
704
-
overflowX: 'auto',
705
-
fontSize: 14
706
-
},
707
-
hr: {
708
-
border: 0,
709
-
borderTop: '1px solid',
710
-
margin: '24px 0 0'
711
-
},
712
-
embedFallback: {
713
-
padding: '12px 16px',
714
-
borderRadius: 12,
715
-
border: '1px solid #e2e8f0',
716
-
fontSize: 14
717
-
}
1017
+
container: {
1018
+
display: "flex",
1019
+
flexDirection: "column",
1020
+
gap: 24,
1021
+
padding: "24px 28px",
1022
+
borderRadius: 20,
1023
+
border: "1px solid transparent",
1024
+
maxWidth: 720,
1025
+
width: "100%",
1026
+
fontFamily:
1027
+
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1028
+
},
1029
+
header: {
1030
+
display: "flex",
1031
+
flexDirection: "column",
1032
+
gap: 16,
1033
+
},
1034
+
headerContent: {
1035
+
display: "flex",
1036
+
flexDirection: "column",
1037
+
gap: 8,
1038
+
},
1039
+
title: {
1040
+
fontSize: 32,
1041
+
margin: 0,
1042
+
lineHeight: 1.15,
1043
+
},
1044
+
subtitle: {
1045
+
margin: 0,
1046
+
fontSize: 16,
1047
+
lineHeight: 1.5,
1048
+
},
1049
+
meta: {
1050
+
display: "flex",
1051
+
flexWrap: "wrap",
1052
+
gap: 8,
1053
+
alignItems: "center",
1054
+
fontSize: 14,
1055
+
},
1056
+
body: {
1057
+
display: "flex",
1058
+
flexDirection: "column",
1059
+
gap: 18,
1060
+
},
1061
+
page: {
1062
+
display: "flex",
1063
+
flexDirection: "column",
1064
+
gap: 18,
1065
+
},
1066
+
paragraph: {
1067
+
margin: "1em 0 0",
1068
+
lineHeight: 1.65,
1069
+
fontSize: 16,
1070
+
},
1071
+
heading: {
1072
+
margin: "0.5em 0 0",
1073
+
fontWeight: 700,
1074
+
},
1075
+
blockquote: {
1076
+
margin: "1em 0 0",
1077
+
padding: "0.6em 1em",
1078
+
borderLeft: "4px solid",
1079
+
},
1080
+
figure: {
1081
+
margin: "1.2em 0 0",
1082
+
display: "flex",
1083
+
flexDirection: "column",
1084
+
gap: 12,
1085
+
},
1086
+
imageWrapper: {
1087
+
borderRadius: 16,
1088
+
overflow: "hidden",
1089
+
width: "100%",
1090
+
position: "relative",
1091
+
background: "#e2e8f0",
1092
+
},
1093
+
image: {
1094
+
width: "100%",
1095
+
height: "100%",
1096
+
objectFit: "cover",
1097
+
display: "block",
1098
+
},
1099
+
imagePlaceholder: {
1100
+
width: "100%",
1101
+
padding: "24px 16px",
1102
+
textAlign: "center",
1103
+
},
1104
+
caption: {
1105
+
fontSize: 13,
1106
+
lineHeight: 1.4,
1107
+
},
1108
+
list: {
1109
+
paddingLeft: 28,
1110
+
margin: "1em 0 0",
1111
+
listStyleType: "disc",
1112
+
listStylePosition: "outside",
1113
+
},
1114
+
nestedList: {
1115
+
paddingLeft: 20,
1116
+
marginTop: 8,
1117
+
listStyleType: "circle",
1118
+
listStylePosition: "outside",
1119
+
},
1120
+
listItem: {
1121
+
marginTop: 8,
1122
+
display: "list-item",
1123
+
},
1124
+
linkCard: {
1125
+
borderRadius: 16,
1126
+
border: "1px solid",
1127
+
display: "flex",
1128
+
flexDirection: "column",
1129
+
overflow: "hidden",
1130
+
textDecoration: "none",
1131
+
},
1132
+
linkPreview: {
1133
+
width: "100%",
1134
+
height: 180,
1135
+
objectFit: "cover",
1136
+
},
1137
+
linkPreviewPlaceholder: {
1138
+
width: "100%",
1139
+
height: 180,
1140
+
display: "flex",
1141
+
alignItems: "center",
1142
+
justifyContent: "center",
1143
+
fontSize: 14,
1144
+
},
1145
+
linkContent: {
1146
+
display: "flex",
1147
+
flexDirection: "column",
1148
+
gap: 6,
1149
+
padding: "16px 18px",
1150
+
},
1151
+
iframe: {
1152
+
width: "100%",
1153
+
height: 360,
1154
+
border: "1px solid #cbd5f5",
1155
+
borderRadius: 16,
1156
+
},
1157
+
math: {
1158
+
margin: "1em 0 0",
1159
+
padding: "14px 16px",
1160
+
borderRadius: 12,
1161
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
1162
+
overflowX: "auto",
1163
+
},
1164
+
code: {
1165
+
margin: "1em 0 0",
1166
+
padding: "14px 16px",
1167
+
borderRadius: 12,
1168
+
overflowX: "auto",
1169
+
fontSize: 14,
1170
+
},
1171
+
hr: {
1172
+
border: 0,
1173
+
borderTop: "1px solid",
1174
+
margin: "24px 0 0",
1175
+
},
1176
+
embedFallback: {
1177
+
padding: "12px 16px",
1178
+
borderRadius: 12,
1179
+
border: "1px solid #e2e8f0",
1180
+
fontSize: 14,
1181
+
},
718
1182
};
719
1183
720
1184
const theme = {
721
-
light: {
722
-
container: {
723
-
background: '#ffffff',
724
-
borderColor: '#e2e8f0',
725
-
color: '#0f172a',
726
-
boxShadow: '0 4px 18px rgba(15, 23, 42, 0.06)'
727
-
},
728
-
header: {},
729
-
title: {
730
-
color: '#0f172a'
731
-
},
732
-
subtitle: {
733
-
color: '#475569'
734
-
},
735
-
meta: {
736
-
color: '#64748b'
737
-
},
738
-
metaLink: {
739
-
color: '#2563eb',
740
-
textDecoration: 'none'
741
-
} satisfies React.CSSProperties,
742
-
metaSeparator: {
743
-
margin: '0 4px'
744
-
} satisfies React.CSSProperties,
745
-
paragraph: {
746
-
color: '#1f2937'
747
-
},
748
-
heading: {
749
-
1: { color: '#0f172a', fontSize: 30 },
750
-
2: { color: '#0f172a', fontSize: 28 },
751
-
3: { color: '#0f172a', fontSize: 24 },
752
-
4: { color: '#0f172a', fontSize: 20 },
753
-
5: { color: '#0f172a', fontSize: 18 },
754
-
6: { color: '#0f172a', fontSize: 16 }
755
-
} satisfies Record<number, React.CSSProperties>,
756
-
blockquote: {
757
-
background: '#f8fafc',
758
-
borderColor: '#cbd5f5',
759
-
color: '#1f2937'
760
-
},
761
-
figure: {},
762
-
imageWrapper: {
763
-
background: '#e2e8f0'
764
-
},
765
-
image: {},
766
-
imagePlaceholder: {
767
-
color: '#475569'
768
-
},
769
-
caption: {
770
-
color: '#475569'
771
-
},
772
-
list: {
773
-
color: '#1f2937'
774
-
},
775
-
linkCard: {
776
-
borderColor: '#e2e8f0',
777
-
background: '#f8fafc',
778
-
color: '#0f172a'
779
-
},
780
-
linkPreview: {},
781
-
linkPreviewPlaceholder: {
782
-
background: '#e2e8f0',
783
-
color: '#475569'
784
-
},
785
-
linkTitle: {
786
-
fontSize: 16,
787
-
color: '#0f172a'
788
-
} satisfies React.CSSProperties,
789
-
linkDescription: {
790
-
margin: 0,
791
-
fontSize: 14,
792
-
color: '#475569',
793
-
lineHeight: 1.5
794
-
} satisfies React.CSSProperties,
795
-
linkUrl: {
796
-
fontSize: 13,
797
-
color: '#2563eb',
798
-
wordBreak: 'break-all'
799
-
} satisfies React.CSSProperties,
800
-
math: {
801
-
background: '#f1f5f9',
802
-
color: '#1f2937',
803
-
border: '1px solid #e2e8f0'
804
-
},
805
-
code: {
806
-
background: '#0f172a',
807
-
color: '#e2e8f0'
808
-
},
809
-
hr: {
810
-
borderColor: '#e2e8f0'
811
-
}
812
-
},
813
-
dark: {
814
-
container: {
815
-
background: 'rgba(15, 23, 42, 0.6)',
816
-
borderColor: 'rgba(148, 163, 184, 0.3)',
817
-
color: '#e2e8f0',
818
-
backdropFilter: 'blur(8px)',
819
-
boxShadow: '0 10px 40px rgba(2, 6, 23, 0.45)'
820
-
},
821
-
header: {},
822
-
title: {
823
-
color: '#f8fafc'
824
-
},
825
-
subtitle: {
826
-
color: '#cbd5f5'
827
-
},
828
-
meta: {
829
-
color: '#94a3b8'
830
-
},
831
-
metaLink: {
832
-
color: '#38bdf8',
833
-
textDecoration: 'none'
834
-
} satisfies React.CSSProperties,
835
-
metaSeparator: {
836
-
margin: '0 4px'
837
-
} satisfies React.CSSProperties,
838
-
paragraph: {
839
-
color: '#e2e8f0'
840
-
},
841
-
heading: {
842
-
1: { color: '#f8fafc', fontSize: 30 },
843
-
2: { color: '#f8fafc', fontSize: 28 },
844
-
3: { color: '#f8fafc', fontSize: 24 },
845
-
4: { color: '#e2e8f0', fontSize: 20 },
846
-
5: { color: '#e2e8f0', fontSize: 18 },
847
-
6: { color: '#e2e8f0', fontSize: 16 }
848
-
} satisfies Record<number, React.CSSProperties>,
849
-
blockquote: {
850
-
background: 'rgba(30, 41, 59, 0.6)',
851
-
borderColor: '#38bdf8',
852
-
color: '#e2e8f0'
853
-
},
854
-
figure: {},
855
-
imageWrapper: {
856
-
background: '#1e293b'
857
-
},
858
-
image: {},
859
-
imagePlaceholder: {
860
-
color: '#94a3b8'
861
-
},
862
-
caption: {
863
-
color: '#94a3b8'
864
-
},
865
-
list: {
866
-
color: '#f1f5f9'
867
-
},
868
-
linkCard: {
869
-
borderColor: 'rgba(148, 163, 184, 0.3)',
870
-
background: 'rgba(15, 23, 42, 0.8)',
871
-
color: '#e2e8f0'
872
-
},
873
-
linkPreview: {},
874
-
linkPreviewPlaceholder: {
875
-
background: '#1e293b',
876
-
color: '#94a3b8'
877
-
},
878
-
linkTitle: {
879
-
fontSize: 16,
880
-
color: '#e0f2fe'
881
-
} satisfies React.CSSProperties,
882
-
linkDescription: {
883
-
margin: 0,
884
-
fontSize: 14,
885
-
color: '#cbd5f5',
886
-
lineHeight: 1.5
887
-
} satisfies React.CSSProperties,
888
-
linkUrl: {
889
-
fontSize: 13,
890
-
color: '#38bdf8',
891
-
wordBreak: 'break-all'
892
-
} satisfies React.CSSProperties,
893
-
math: {
894
-
background: 'rgba(15, 23, 42, 0.8)',
895
-
color: '#e2e8f0',
896
-
border: '1px solid rgba(148, 163, 184, 0.35)'
897
-
},
898
-
code: {
899
-
background: '#020617',
900
-
color: '#e2e8f0'
901
-
},
902
-
hr: {
903
-
borderColor: 'rgba(148, 163, 184, 0.3)'
904
-
}
905
-
}
1185
+
light: {
1186
+
container: {
1187
+
background: "#ffffff",
1188
+
borderColor: "#e2e8f0",
1189
+
color: "#0f172a",
1190
+
boxShadow: "0 4px 18px rgba(15, 23, 42, 0.06)",
1191
+
},
1192
+
header: {},
1193
+
title: {
1194
+
color: "#0f172a",
1195
+
},
1196
+
subtitle: {
1197
+
color: "#475569",
1198
+
},
1199
+
meta: {
1200
+
color: "#64748b",
1201
+
},
1202
+
metaLink: {
1203
+
color: "#2563eb",
1204
+
textDecoration: "none",
1205
+
} satisfies React.CSSProperties,
1206
+
metaSeparator: {
1207
+
margin: "0 4px",
1208
+
} satisfies React.CSSProperties,
1209
+
paragraph: {
1210
+
color: "#1f2937",
1211
+
},
1212
+
heading: {
1213
+
1: { color: "#0f172a", fontSize: 30 },
1214
+
2: { color: "#0f172a", fontSize: 28 },
1215
+
3: { color: "#0f172a", fontSize: 24 },
1216
+
4: { color: "#0f172a", fontSize: 20 },
1217
+
5: { color: "#0f172a", fontSize: 18 },
1218
+
6: { color: "#0f172a", fontSize: 16 },
1219
+
} satisfies Record<number, React.CSSProperties>,
1220
+
blockquote: {
1221
+
background: "#f8fafc",
1222
+
borderColor: "#cbd5f5",
1223
+
color: "#1f2937",
1224
+
},
1225
+
figure: {},
1226
+
imageWrapper: {
1227
+
background: "#e2e8f0",
1228
+
},
1229
+
image: {},
1230
+
imagePlaceholder: {
1231
+
color: "#475569",
1232
+
},
1233
+
caption: {
1234
+
color: "#475569",
1235
+
},
1236
+
list: {
1237
+
color: "#1f2937",
1238
+
},
1239
+
linkCard: {
1240
+
borderColor: "#e2e8f0",
1241
+
background: "#f8fafc",
1242
+
color: "#0f172a",
1243
+
},
1244
+
linkPreview: {},
1245
+
linkPreviewPlaceholder: {
1246
+
background: "#e2e8f0",
1247
+
color: "#475569",
1248
+
},
1249
+
linkTitle: {
1250
+
fontSize: 16,
1251
+
color: "#0f172a",
1252
+
} satisfies React.CSSProperties,
1253
+
linkDescription: {
1254
+
margin: 0,
1255
+
fontSize: 14,
1256
+
color: "#475569",
1257
+
lineHeight: 1.5,
1258
+
} satisfies React.CSSProperties,
1259
+
linkUrl: {
1260
+
fontSize: 13,
1261
+
color: "#2563eb",
1262
+
wordBreak: "break-all",
1263
+
} satisfies React.CSSProperties,
1264
+
math: {
1265
+
background: "#f1f5f9",
1266
+
color: "#1f2937",
1267
+
border: "1px solid #e2e8f0",
1268
+
},
1269
+
code: {
1270
+
background: "#0f172a",
1271
+
color: "#e2e8f0",
1272
+
},
1273
+
hr: {
1274
+
borderColor: "#e2e8f0",
1275
+
},
1276
+
},
1277
+
dark: {
1278
+
container: {
1279
+
background: "rgba(15, 23, 42, 0.6)",
1280
+
borderColor: "rgba(148, 163, 184, 0.3)",
1281
+
color: "#e2e8f0",
1282
+
backdropFilter: "blur(8px)",
1283
+
boxShadow: "0 10px 40px rgba(2, 6, 23, 0.45)",
1284
+
},
1285
+
header: {},
1286
+
title: {
1287
+
color: "#f8fafc",
1288
+
},
1289
+
subtitle: {
1290
+
color: "#cbd5f5",
1291
+
},
1292
+
meta: {
1293
+
color: "#94a3b8",
1294
+
},
1295
+
metaLink: {
1296
+
color: "#38bdf8",
1297
+
textDecoration: "none",
1298
+
} satisfies React.CSSProperties,
1299
+
metaSeparator: {
1300
+
margin: "0 4px",
1301
+
} satisfies React.CSSProperties,
1302
+
paragraph: {
1303
+
color: "#e2e8f0",
1304
+
},
1305
+
heading: {
1306
+
1: { color: "#f8fafc", fontSize: 30 },
1307
+
2: { color: "#f8fafc", fontSize: 28 },
1308
+
3: { color: "#f8fafc", fontSize: 24 },
1309
+
4: { color: "#e2e8f0", fontSize: 20 },
1310
+
5: { color: "#e2e8f0", fontSize: 18 },
1311
+
6: { color: "#e2e8f0", fontSize: 16 },
1312
+
} satisfies Record<number, React.CSSProperties>,
1313
+
blockquote: {
1314
+
background: "rgba(30, 41, 59, 0.6)",
1315
+
borderColor: "#38bdf8",
1316
+
color: "#e2e8f0",
1317
+
},
1318
+
figure: {},
1319
+
imageWrapper: {
1320
+
background: "#1e293b",
1321
+
},
1322
+
image: {},
1323
+
imagePlaceholder: {
1324
+
color: "#94a3b8",
1325
+
},
1326
+
caption: {
1327
+
color: "#94a3b8",
1328
+
},
1329
+
list: {
1330
+
color: "#f1f5f9",
1331
+
},
1332
+
linkCard: {
1333
+
borderColor: "rgba(148, 163, 184, 0.3)",
1334
+
background: "rgba(15, 23, 42, 0.8)",
1335
+
color: "#e2e8f0",
1336
+
},
1337
+
linkPreview: {},
1338
+
linkPreviewPlaceholder: {
1339
+
background: "#1e293b",
1340
+
color: "#94a3b8",
1341
+
},
1342
+
linkTitle: {
1343
+
fontSize: 16,
1344
+
color: "#e0f2fe",
1345
+
} satisfies React.CSSProperties,
1346
+
linkDescription: {
1347
+
margin: 0,
1348
+
fontSize: 14,
1349
+
color: "#cbd5f5",
1350
+
lineHeight: 1.5,
1351
+
} satisfies React.CSSProperties,
1352
+
linkUrl: {
1353
+
fontSize: 13,
1354
+
color: "#38bdf8",
1355
+
wordBreak: "break-all",
1356
+
} satisfies React.CSSProperties,
1357
+
math: {
1358
+
background: "rgba(15, 23, 42, 0.8)",
1359
+
color: "#e2e8f0",
1360
+
border: "1px solid rgba(148, 163, 184, 0.35)",
1361
+
},
1362
+
code: {
1363
+
background: "#020617",
1364
+
color: "#e2e8f0",
1365
+
},
1366
+
hr: {
1367
+
borderColor: "rgba(148, 163, 184, 0.3)",
1368
+
},
1369
+
},
906
1370
} as const;
907
1371
908
1372
const linkStyles = {
909
-
light: {
910
-
color: '#2563eb',
911
-
textDecoration: 'underline'
912
-
} satisfies React.CSSProperties,
913
-
dark: {
914
-
color: '#38bdf8',
915
-
textDecoration: 'underline'
916
-
} satisfies React.CSSProperties
1373
+
light: {
1374
+
color: "#2563eb",
1375
+
textDecoration: "underline",
1376
+
} satisfies React.CSSProperties,
1377
+
dark: {
1378
+
color: "#38bdf8",
1379
+
textDecoration: "underline",
1380
+
} satisfies React.CSSProperties,
917
1381
} as const;
918
1382
919
1383
const inlineCodeStyles = {
920
-
light: {
921
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
922
-
background: '#f1f5f9',
923
-
padding: '0 4px',
924
-
borderRadius: 4
925
-
} satisfies React.CSSProperties,
926
-
dark: {
927
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
928
-
background: '#1e293b',
929
-
padding: '0 4px',
930
-
borderRadius: 4
931
-
} satisfies React.CSSProperties
1384
+
light: {
1385
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
1386
+
background: "#f1f5f9",
1387
+
padding: "0 4px",
1388
+
borderRadius: 4,
1389
+
} satisfies React.CSSProperties,
1390
+
dark: {
1391
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace',
1392
+
background: "#1e293b",
1393
+
padding: "0 4px",
1394
+
borderRadius: 4,
1395
+
} satisfies React.CSSProperties,
932
1396
} as const;
933
1397
934
1398
const highlightStyles = {
935
-
light: {
936
-
background: '#fef08a'
937
-
} satisfies React.CSSProperties,
938
-
dark: {
939
-
background: '#facc15',
940
-
color: '#0f172a'
941
-
} satisfies React.CSSProperties
1399
+
light: {
1400
+
background: "#fef08a",
1401
+
} satisfies React.CSSProperties,
1402
+
dark: {
1403
+
background: "#facc15",
1404
+
color: "#0f172a",
1405
+
} satisfies React.CSSProperties,
942
1406
} as const;
943
1407
944
1408
export default LeafletDocumentRenderer;
+110
-72
lib/renderers/TangledStringRenderer.tsx
+110
-72
lib/renderers/TangledStringRenderer.tsx
···
1
-
import React from 'react';
2
-
import type { ShTangledString } from '@atcute/tangled';
3
-
import { useColorScheme, type ColorSchemePreference } from '../hooks/useColorScheme';
1
+
import React from "react";
2
+
import type { ShTangledString } from "@atcute/tangled";
3
+
import {
4
+
useColorScheme,
5
+
type ColorSchemePreference,
6
+
} from "../hooks/useColorScheme";
4
7
5
8
export type TangledStringRecord = ShTangledString.Main;
6
9
···
14
17
canonicalUrl?: string;
15
18
}
16
19
17
-
export const TangledStringRenderer: React.FC<TangledStringRendererProps> = ({ record, error, loading, colorScheme = 'system', did, rkey, canonicalUrl }) => {
20
+
export const TangledStringRenderer: React.FC<TangledStringRendererProps> = ({
21
+
record,
22
+
error,
23
+
loading,
24
+
colorScheme = "system",
25
+
did,
26
+
rkey,
27
+
canonicalUrl,
28
+
}) => {
18
29
const scheme = useColorScheme(colorScheme);
19
30
20
-
if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load snippet.</div>;
31
+
if (error)
32
+
return (
33
+
<div style={{ padding: 8, color: "crimson" }}>
34
+
Failed to load snippet.
35
+
</div>
36
+
);
21
37
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
22
38
23
-
const palette = scheme === 'dark' ? theme.dark : theme.light;
24
-
const viewUrl = canonicalUrl ?? `https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`;
25
-
const timestamp = new Date(record.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
39
+
const palette = scheme === "dark" ? theme.dark : theme.light;
40
+
const viewUrl =
41
+
canonicalUrl ??
42
+
`https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`;
43
+
const timestamp = new Date(record.createdAt).toLocaleString(undefined, {
44
+
dateStyle: "medium",
45
+
timeStyle: "short",
46
+
});
26
47
return (
27
48
<div style={{ ...base.container, ...palette.container }}>
28
49
<div style={{ ...base.header, ...palette.header }}>
29
-
<strong style={{ ...base.filename, ...palette.filename }}>{record.filename}</strong>
50
+
<strong style={{ ...base.filename, ...palette.filename }}>
51
+
{record.filename}
52
+
</strong>
30
53
<div style={{ ...base.headerRight, ...palette.headerRight }}>
31
-
<time style={{ ...base.timestamp, ...palette.timestamp }} dateTime={record.createdAt}>{timestamp}</time>
32
-
<a href={viewUrl} target="_blank" rel="noopener noreferrer" style={{ ...base.headerLink, ...palette.headerLink }}>
54
+
<time
55
+
style={{ ...base.timestamp, ...palette.timestamp }}
56
+
dateTime={record.createdAt}
57
+
>
58
+
{timestamp}
59
+
</time>
60
+
<a
61
+
href={viewUrl}
62
+
target="_blank"
63
+
rel="noopener noreferrer"
64
+
style={{ ...base.headerLink, ...palette.headerLink }}
65
+
>
33
66
View on Tangled
34
67
</a>
35
68
</div>
36
69
</div>
37
70
{record.description && (
38
-
<div style={{ ...base.description, ...palette.description }}>{record.description}</div>
71
+
<div style={{ ...base.description, ...palette.description }}>
72
+
{record.description}
73
+
</div>
39
74
)}
40
75
<pre style={{ ...base.codeBlock, ...palette.codeBlock }}>
41
76
<code>{record.contents}</code>
···
46
81
47
82
const base: Record<string, React.CSSProperties> = {
48
83
container: {
49
-
fontFamily: 'system-ui, sans-serif',
84
+
fontFamily: "system-ui, sans-serif",
50
85
borderRadius: 6,
51
-
overflow: 'hidden',
52
-
transition: 'background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease',
53
-
width: '100%'
86
+
overflow: "hidden",
87
+
transition:
88
+
"background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease",
89
+
width: "100%",
54
90
},
55
91
header: {
56
-
padding: '10px 16px',
57
-
display: 'flex',
58
-
justifyContent: 'space-between',
59
-
alignItems: 'center',
60
-
gap: 12
92
+
padding: "10px 16px",
93
+
display: "flex",
94
+
justifyContent: "space-between",
95
+
alignItems: "center",
96
+
gap: 12,
61
97
},
62
98
headerRight: {
63
-
display: 'flex',
64
-
alignItems: 'center',
99
+
display: "flex",
100
+
alignItems: "center",
65
101
gap: 12,
66
-
flexWrap: 'wrap',
67
-
justifyContent: 'flex-end'
102
+
flexWrap: "wrap",
103
+
justifyContent: "flex-end",
68
104
},
69
105
filename: {
70
-
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
106
+
fontFamily:
107
+
'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
71
108
fontSize: 13,
72
-
wordBreak: 'break-all'
109
+
wordBreak: "break-all",
73
110
},
74
111
timestamp: {
75
-
fontSize: 12
112
+
fontSize: 12,
76
113
},
77
114
headerLink: {
78
115
fontSize: 12,
79
116
fontWeight: 600,
80
-
textDecoration: 'none'
117
+
textDecoration: "none",
81
118
},
82
119
description: {
83
-
padding: '10px 16px',
120
+
padding: "10px 16px",
84
121
fontSize: 13,
85
-
borderTop: '1px solid transparent'
122
+
borderTop: "1px solid transparent",
86
123
},
87
124
codeBlock: {
88
125
margin: 0,
89
-
padding: '16px',
126
+
padding: "16px",
90
127
fontSize: 13,
91
-
overflowX: 'auto',
92
-
borderTop: '1px solid transparent',
93
-
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace'
94
-
}
128
+
overflowX: "auto",
129
+
borderTop: "1px solid transparent",
130
+
fontFamily:
131
+
'SFMono-Regular, ui-monospace, Menlo, Monaco, "Courier New", monospace',
132
+
},
95
133
};
96
134
97
135
const theme = {
98
136
light: {
99
137
container: {
100
-
border: '1px solid #d0d7de',
101
-
background: '#f6f8fa',
102
-
color: '#1f2328',
103
-
boxShadow: '0 1px 2px rgba(31,35,40,0.05)'
138
+
border: "1px solid #d0d7de",
139
+
background: "#f6f8fa",
140
+
color: "#1f2328",
141
+
boxShadow: "0 1px 2px rgba(31,35,40,0.05)",
104
142
},
105
143
header: {
106
-
background: '#f6f8fa',
107
-
borderBottom: '1px solid #d0d7de'
144
+
background: "#f6f8fa",
145
+
borderBottom: "1px solid #d0d7de",
108
146
},
109
147
headerRight: {},
110
148
filename: {
111
-
color: '#1f2328'
149
+
color: "#1f2328",
112
150
},
113
151
timestamp: {
114
-
color: '#57606a'
152
+
color: "#57606a",
115
153
},
116
154
headerLink: {
117
-
color: '#2563eb'
155
+
color: "#2563eb",
118
156
},
119
157
description: {
120
-
background: '#ffffff',
121
-
borderBottom: '1px solid #d0d7de',
122
-
borderTopColor: '#d0d7de',
123
-
color: '#1f2328'
158
+
background: "#ffffff",
159
+
borderBottom: "1px solid #d0d7de",
160
+
borderTopColor: "#d0d7de",
161
+
color: "#1f2328",
124
162
},
125
163
codeBlock: {
126
-
background: '#ffffff',
127
-
color: '#1f2328',
128
-
borderTopColor: '#d0d7de'
129
-
}
164
+
background: "#ffffff",
165
+
color: "#1f2328",
166
+
borderTopColor: "#d0d7de",
167
+
},
130
168
},
131
169
dark: {
132
170
container: {
133
-
border: '1px solid #30363d',
134
-
background: '#0d1117',
135
-
color: '#c9d1d9',
136
-
boxShadow: '0 0 0 1px rgba(1,4,9,0.3) inset'
171
+
border: "1px solid #30363d",
172
+
background: "#0d1117",
173
+
color: "#c9d1d9",
174
+
boxShadow: "0 0 0 1px rgba(1,4,9,0.3) inset",
137
175
},
138
176
header: {
139
-
background: '#161b22',
140
-
borderBottom: '1px solid #30363d'
177
+
background: "#161b22",
178
+
borderBottom: "1px solid #30363d",
141
179
},
142
180
headerRight: {},
143
181
filename: {
144
-
color: '#c9d1d9'
182
+
color: "#c9d1d9",
145
183
},
146
184
timestamp: {
147
-
color: '#8b949e'
185
+
color: "#8b949e",
148
186
},
149
187
headerLink: {
150
-
color: '#58a6ff'
188
+
color: "#58a6ff",
151
189
},
152
190
description: {
153
-
background: '#161b22',
154
-
borderBottom: '1px solid #30363d',
155
-
borderTopColor: '#30363d',
156
-
color: '#c9d1d9'
191
+
background: "#161b22",
192
+
borderBottom: "1px solid #30363d",
193
+
borderTopColor: "#30363d",
194
+
color: "#c9d1d9",
157
195
},
158
196
codeBlock: {
159
-
background: '#0d1117',
160
-
color: '#c9d1d9',
161
-
borderTopColor: '#30363d'
162
-
}
163
-
}
164
-
} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>;
197
+
background: "#0d1117",
198
+
color: "#c9d1d9",
199
+
borderTopColor: "#30363d",
200
+
},
201
+
},
202
+
} satisfies Record<"light" | "dark", Record<string, React.CSSProperties>>;
165
203
166
204
export default TangledStringRenderer;
+1
-1
lib/types/bluesky.ts
+1
-1
lib/types/bluesky.ts
···
1
1
// Re-export precise lexicon types from @atcute/bluesky instead of redefining.
2
-
import type { AppBskyFeedPost, AppBskyActorProfile } from '@atcute/bluesky';
2
+
import type { AppBskyFeedPost, AppBskyActorProfile } from "@atcute/bluesky";
3
3
4
4
// The atcute lexicon modules expose Main interface for record input shapes.
5
5
export type FeedPostRecord = AppBskyFeedPost.Main;
+133
-127
lib/types/leaflet.ts
+133
-127
lib/types/leaflet.ts
···
1
1
export interface StrongRef {
2
-
uri: string;
3
-
cid: string;
2
+
uri: string;
3
+
cid: string;
4
4
}
5
5
6
6
export interface LeafletDocumentRecord {
7
-
$type?: "pub.leaflet.document";
8
-
title: string;
9
-
postRef?: StrongRef;
10
-
description?: string;
11
-
publishedAt?: string;
12
-
publication: string;
13
-
author: string;
14
-
pages: LeafletDocumentPage[];
7
+
$type?: "pub.leaflet.document";
8
+
title: string;
9
+
postRef?: StrongRef;
10
+
description?: string;
11
+
publishedAt?: string;
12
+
publication: string;
13
+
author: string;
14
+
pages: LeafletDocumentPage[];
15
15
}
16
16
17
17
export type LeafletDocumentPage = LeafletLinearDocumentPage;
18
18
19
19
export interface LeafletLinearDocumentPage {
20
-
$type?: "pub.leaflet.pages.linearDocument";
21
-
blocks?: LeafletLinearDocumentBlock[];
20
+
$type?: "pub.leaflet.pages.linearDocument";
21
+
blocks?: LeafletLinearDocumentBlock[];
22
22
}
23
23
24
24
export type LeafletAlignmentValue =
25
-
| "#textAlignLeft"
26
-
| "#textAlignCenter"
27
-
| "#textAlignRight"
28
-
| "#textAlignJustify"
29
-
| "textAlignLeft"
30
-
| "textAlignCenter"
31
-
| "textAlignRight"
32
-
| "textAlignJustify";
25
+
| "#textAlignLeft"
26
+
| "#textAlignCenter"
27
+
| "#textAlignRight"
28
+
| "#textAlignJustify"
29
+
| "textAlignLeft"
30
+
| "textAlignCenter"
31
+
| "textAlignRight"
32
+
| "textAlignJustify";
33
33
34
34
export interface LeafletLinearDocumentBlock {
35
-
block: LeafletBlock;
36
-
alignment?: LeafletAlignmentValue;
35
+
block: LeafletBlock;
36
+
alignment?: LeafletAlignmentValue;
37
37
}
38
38
39
39
export type LeafletBlock =
40
-
| LeafletTextBlock
41
-
| LeafletHeaderBlock
42
-
| LeafletBlockquoteBlock
43
-
| LeafletImageBlock
44
-
| LeafletUnorderedListBlock
45
-
| LeafletWebsiteBlock
46
-
| LeafletIFrameBlock
47
-
| LeafletMathBlock
48
-
| LeafletCodeBlock
49
-
| LeafletHorizontalRuleBlock
50
-
| LeafletBskyPostBlock;
40
+
| LeafletTextBlock
41
+
| LeafletHeaderBlock
42
+
| LeafletBlockquoteBlock
43
+
| LeafletImageBlock
44
+
| LeafletUnorderedListBlock
45
+
| LeafletWebsiteBlock
46
+
| LeafletIFrameBlock
47
+
| LeafletMathBlock
48
+
| LeafletCodeBlock
49
+
| LeafletHorizontalRuleBlock
50
+
| LeafletBskyPostBlock;
51
51
52
52
export interface LeafletBaseTextBlock {
53
-
plaintext: string;
54
-
facets?: LeafletRichTextFacet[];
53
+
plaintext: string;
54
+
facets?: LeafletRichTextFacet[];
55
55
}
56
56
57
57
export interface LeafletTextBlock extends LeafletBaseTextBlock {
58
-
$type?: "pub.leaflet.blocks.text";
58
+
$type?: "pub.leaflet.blocks.text";
59
59
}
60
60
61
61
export interface LeafletHeaderBlock extends LeafletBaseTextBlock {
62
-
$type?: "pub.leaflet.blocks.header";
63
-
level?: number;
62
+
$type?: "pub.leaflet.blocks.header";
63
+
level?: number;
64
64
}
65
65
66
66
export interface LeafletBlockquoteBlock extends LeafletBaseTextBlock {
67
-
$type?: "pub.leaflet.blocks.blockquote";
67
+
$type?: "pub.leaflet.blocks.blockquote";
68
68
}
69
69
70
70
export interface LeafletImageBlock {
71
-
$type?: "pub.leaflet.blocks.image";
72
-
image: LeafletBlobRef;
73
-
alt?: string;
74
-
aspectRatio: {
75
-
width: number;
76
-
height: number;
77
-
};
71
+
$type?: "pub.leaflet.blocks.image";
72
+
image: LeafletBlobRef;
73
+
alt?: string;
74
+
aspectRatio: {
75
+
width: number;
76
+
height: number;
77
+
};
78
78
}
79
79
80
80
export interface LeafletUnorderedListBlock {
81
-
$type?: "pub.leaflet.blocks.unorderedList";
82
-
children: LeafletListItem[];
81
+
$type?: "pub.leaflet.blocks.unorderedList";
82
+
children: LeafletListItem[];
83
83
}
84
84
85
85
export interface LeafletListItem {
86
-
content: LeafletListContent;
87
-
children?: LeafletListItem[];
86
+
content: LeafletListContent;
87
+
children?: LeafletListItem[];
88
88
}
89
89
90
-
export type LeafletListContent = LeafletTextBlock | LeafletHeaderBlock | LeafletImageBlock;
90
+
export type LeafletListContent =
91
+
| LeafletTextBlock
92
+
| LeafletHeaderBlock
93
+
| LeafletImageBlock;
91
94
92
95
export interface LeafletWebsiteBlock {
93
-
$type?: "pub.leaflet.blocks.website";
94
-
src: string;
95
-
title?: string;
96
-
description?: string;
97
-
previewImage?: LeafletBlobRef;
96
+
$type?: "pub.leaflet.blocks.website";
97
+
src: string;
98
+
title?: string;
99
+
description?: string;
100
+
previewImage?: LeafletBlobRef;
98
101
}
99
102
100
103
export interface LeafletIFrameBlock {
101
-
$type?: "pub.leaflet.blocks.iframe";
102
-
url: string;
103
-
height?: number;
104
+
$type?: "pub.leaflet.blocks.iframe";
105
+
url: string;
106
+
height?: number;
104
107
}
105
108
106
109
export interface LeafletMathBlock {
107
-
$type?: "pub.leaflet.blocks.math";
108
-
tex: string;
110
+
$type?: "pub.leaflet.blocks.math";
111
+
tex: string;
109
112
}
110
113
111
114
export interface LeafletCodeBlock {
112
-
$type?: "pub.leaflet.blocks.code";
113
-
plaintext: string;
114
-
language?: string;
115
-
syntaxHighlightingTheme?: string;
115
+
$type?: "pub.leaflet.blocks.code";
116
+
plaintext: string;
117
+
language?: string;
118
+
syntaxHighlightingTheme?: string;
116
119
}
117
120
118
121
export interface LeafletHorizontalRuleBlock {
119
-
$type?: "pub.leaflet.blocks.horizontalRule";
122
+
$type?: "pub.leaflet.blocks.horizontalRule";
120
123
}
121
124
122
125
export interface LeafletBskyPostBlock {
123
-
$type?: "pub.leaflet.blocks.bskyPost";
124
-
postRef: StrongRef;
126
+
$type?: "pub.leaflet.blocks.bskyPost";
127
+
postRef: StrongRef;
125
128
}
126
129
127
130
export interface LeafletRichTextFacet {
128
-
index: LeafletByteSlice;
129
-
features: LeafletRichTextFeature[];
131
+
index: LeafletByteSlice;
132
+
features: LeafletRichTextFeature[];
130
133
}
131
134
132
135
export interface LeafletByteSlice {
133
-
byteStart: number;
134
-
byteEnd: number;
136
+
byteStart: number;
137
+
byteEnd: number;
135
138
}
136
139
137
140
export type LeafletRichTextFeature =
138
-
| LeafletRichTextLinkFeature
139
-
| LeafletRichTextCodeFeature
140
-
| LeafletRichTextHighlightFeature
141
-
| LeafletRichTextUnderlineFeature
142
-
| LeafletRichTextStrikethroughFeature
143
-
| LeafletRichTextIdFeature
144
-
| LeafletRichTextBoldFeature
145
-
| LeafletRichTextItalicFeature;
141
+
| LeafletRichTextLinkFeature
142
+
| LeafletRichTextCodeFeature
143
+
| LeafletRichTextHighlightFeature
144
+
| LeafletRichTextUnderlineFeature
145
+
| LeafletRichTextStrikethroughFeature
146
+
| LeafletRichTextIdFeature
147
+
| LeafletRichTextBoldFeature
148
+
| LeafletRichTextItalicFeature;
146
149
147
150
export interface LeafletRichTextLinkFeature {
148
-
$type: "pub.leaflet.richtext.facet#link";
149
-
uri: string;
151
+
$type: "pub.leaflet.richtext.facet#link";
152
+
uri: string;
150
153
}
151
154
152
155
export interface LeafletRichTextCodeFeature {
153
-
$type: "pub.leaflet.richtext.facet#code";
156
+
$type: "pub.leaflet.richtext.facet#code";
154
157
}
155
158
156
159
export interface LeafletRichTextHighlightFeature {
157
-
$type: "pub.leaflet.richtext.facet#highlight";
160
+
$type: "pub.leaflet.richtext.facet#highlight";
158
161
}
159
162
160
163
export interface LeafletRichTextUnderlineFeature {
161
-
$type: "pub.leaflet.richtext.facet#underline";
164
+
$type: "pub.leaflet.richtext.facet#underline";
162
165
}
163
166
164
167
export interface LeafletRichTextStrikethroughFeature {
165
-
$type: "pub.leaflet.richtext.facet#strikethrough";
168
+
$type: "pub.leaflet.richtext.facet#strikethrough";
166
169
}
167
170
168
171
export interface LeafletRichTextIdFeature {
169
-
$type: "pub.leaflet.richtext.facet#id";
170
-
id?: string;
172
+
$type: "pub.leaflet.richtext.facet#id";
173
+
id?: string;
171
174
}
172
175
173
176
export interface LeafletRichTextBoldFeature {
174
-
$type: "pub.leaflet.richtext.facet#bold";
177
+
$type: "pub.leaflet.richtext.facet#bold";
175
178
}
176
179
177
180
export interface LeafletRichTextItalicFeature {
178
-
$type: "pub.leaflet.richtext.facet#italic";
181
+
$type: "pub.leaflet.richtext.facet#italic";
179
182
}
180
183
181
184
export interface LeafletBlobRef {
182
-
$type?: string;
183
-
ref?: {
184
-
$link?: string;
185
-
};
186
-
cid?: string;
187
-
mimeType?: string;
188
-
size?: number;
185
+
$type?: string;
186
+
ref?: {
187
+
$link?: string;
188
+
};
189
+
cid?: string;
190
+
mimeType?: string;
191
+
size?: number;
189
192
}
190
193
191
194
export interface LeafletPublicationRecord {
192
-
$type?: "pub.leaflet.publication";
193
-
name: string;
194
-
base_path?: string;
195
-
description?: string;
196
-
icon?: LeafletBlobRef;
197
-
theme?: LeafletTheme;
198
-
preferences?: LeafletPublicationPreferences;
195
+
$type?: "pub.leaflet.publication";
196
+
name: string;
197
+
base_path?: string;
198
+
description?: string;
199
+
icon?: LeafletBlobRef;
200
+
theme?: LeafletTheme;
201
+
preferences?: LeafletPublicationPreferences;
199
202
}
200
203
201
204
export interface LeafletPublicationPreferences {
202
-
showInDiscover?: boolean;
203
-
showComments?: boolean;
205
+
showInDiscover?: boolean;
206
+
showComments?: boolean;
204
207
}
205
208
206
209
export interface LeafletTheme {
207
-
backgroundColor?: LeafletThemeColor;
208
-
backgroundImage?: LeafletThemeBackgroundImage;
209
-
primary?: LeafletThemeColor;
210
-
pageBackground?: LeafletThemeColor;
211
-
showPageBackground?: boolean;
212
-
accentBackground?: LeafletThemeColor;
213
-
accentText?: LeafletThemeColor;
210
+
backgroundColor?: LeafletThemeColor;
211
+
backgroundImage?: LeafletThemeBackgroundImage;
212
+
primary?: LeafletThemeColor;
213
+
pageBackground?: LeafletThemeColor;
214
+
showPageBackground?: boolean;
215
+
accentBackground?: LeafletThemeColor;
216
+
accentText?: LeafletThemeColor;
214
217
}
215
218
216
219
export type LeafletThemeColor = LeafletThemeColorRgb | LeafletThemeColorRgba;
217
220
218
221
export interface LeafletThemeColorRgb {
219
-
$type?: "pub.leaflet.theme.color#rgb";
220
-
r: number;
221
-
g: number;
222
-
b: number;
222
+
$type?: "pub.leaflet.theme.color#rgb";
223
+
r: number;
224
+
g: number;
225
+
b: number;
223
226
}
224
227
225
228
export interface LeafletThemeColorRgba {
226
-
$type?: "pub.leaflet.theme.color#rgba";
227
-
r: number;
228
-
g: number;
229
-
b: number;
230
-
a: number;
229
+
$type?: "pub.leaflet.theme.color#rgba";
230
+
r: number;
231
+
g: number;
232
+
b: number;
233
+
a: number;
231
234
}
232
235
233
236
export interface LeafletThemeBackgroundImage {
234
-
$type?: "pub.leaflet.theme.backgroundImage";
235
-
image: LeafletBlobRef;
236
-
width?: number;
237
-
repeat?: boolean;
237
+
$type?: "pub.leaflet.theme.backgroundImage";
238
+
image: LeafletBlobRef;
239
+
width?: number;
240
+
repeat?: boolean;
238
241
}
239
242
240
-
export type LeafletInlineRenderable = LeafletTextBlock | LeafletHeaderBlock | LeafletBlockquoteBlock;
243
+
export type LeafletInlineRenderable =
244
+
| LeafletTextBlock
245
+
| LeafletHeaderBlock
246
+
| LeafletBlockquoteBlock;
+34
-27
lib/utils/at-uri.ts
+34
-27
lib/utils/at-uri.ts
···
1
1
export interface ParsedAtUri {
2
-
did: string;
3
-
collection: string;
4
-
rkey: string;
2
+
did: string;
3
+
collection: string;
4
+
rkey: string;
5
5
}
6
6
7
7
export function parseAtUri(uri?: string): ParsedAtUri | undefined {
8
-
if (!uri || !uri.startsWith('at://')) return undefined;
9
-
const withoutScheme = uri.slice('at://'.length);
10
-
const [did, collection, rkey] = withoutScheme.split('/');
11
-
if (!did || !collection || !rkey) return undefined;
12
-
return { did, collection, rkey };
8
+
if (!uri || !uri.startsWith("at://")) return undefined;
9
+
const withoutScheme = uri.slice("at://".length);
10
+
const [did, collection, rkey] = withoutScheme.split("/");
11
+
if (!did || !collection || !rkey) return undefined;
12
+
return { did, collection, rkey };
13
13
}
14
14
15
15
export function toBlueskyPostUrl(target: ParsedAtUri): string | undefined {
16
-
if (target.collection !== 'app.bsky.feed.post') return undefined;
17
-
return `https://bsky.app/profile/${target.did}/post/${target.rkey}`;
16
+
if (target.collection !== "app.bsky.feed.post") return undefined;
17
+
return `https://bsky.app/profile/${target.did}/post/${target.rkey}`;
18
18
}
19
19
20
20
export function formatDidForLabel(did: string): string {
21
-
return did.replace(/^did:(plc:)?/, '');
21
+
return did.replace(/^did:(plc:)?/, "");
22
22
}
23
23
24
24
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
25
25
26
-
export function normalizeLeafletBasePath(basePath?: string): string | undefined {
27
-
if (!basePath) return undefined;
28
-
const trimmed = basePath.trim();
29
-
if (!trimmed) return undefined;
30
-
const withScheme = ABSOLUTE_URL_PATTERN.test(trimmed) ? trimmed : `https://${trimmed}`;
31
-
try {
32
-
const url = new URL(withScheme);
33
-
url.hash = '';
34
-
return url.href.replace(/\/?$/, '');
35
-
} catch {
36
-
return undefined;
37
-
}
26
+
export function normalizeLeafletBasePath(
27
+
basePath?: string,
28
+
): string | undefined {
29
+
if (!basePath) return undefined;
30
+
const trimmed = basePath.trim();
31
+
if (!trimmed) return undefined;
32
+
const withScheme = ABSOLUTE_URL_PATTERN.test(trimmed)
33
+
? trimmed
34
+
: `https://${trimmed}`;
35
+
try {
36
+
const url = new URL(withScheme);
37
+
url.hash = "";
38
+
return url.href.replace(/\/?$/, "");
39
+
} catch {
40
+
return undefined;
41
+
}
38
42
}
39
43
40
-
export function leafletRkeyUrl(basePath: string | undefined, rkey: string): string | undefined {
41
-
const normalized = normalizeLeafletBasePath(basePath);
42
-
if (!normalized) return undefined;
43
-
return `${normalized}/${encodeURIComponent(rkey)}`;
44
+
export function leafletRkeyUrl(
45
+
basePath: string | undefined,
46
+
rkey: string,
47
+
): string | undefined {
48
+
const normalized = normalizeLeafletBasePath(basePath);
49
+
if (!normalized) return undefined;
50
+
return `${normalized}/${encodeURIComponent(rkey)}`;
44
51
}
+185
-132
lib/utils/atproto-client.ts
+185
-132
lib/utils/atproto-client.ts
···
1
-
import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client';
2
-
import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver';
3
-
import type { DidDocument } from '@atcute/identity';
4
-
import type { Did, Handle } from '@atcute/lexicons/syntax';
5
-
import type {} from '@atcute/tangled';
6
-
import type {} from '@atcute/atproto';
1
+
import { Client, simpleFetchHandler, type FetchHandler } from "@atcute/client";
2
+
import {
3
+
CompositeDidDocumentResolver,
4
+
PlcDidDocumentResolver,
5
+
WebDidDocumentResolver,
6
+
XrpcHandleResolver,
7
+
} from "@atcute/identity-resolver";
8
+
import type { DidDocument } from "@atcute/identity";
9
+
import type { Did, Handle } from "@atcute/lexicons/syntax";
10
+
import type {} from "@atcute/tangled";
11
+
import type {} from "@atcute/atproto";
7
12
8
13
export interface ServiceResolverOptions {
9
-
plcDirectory?: string;
10
-
identityService?: string;
11
-
fetch?: typeof fetch;
14
+
plcDirectory?: string;
15
+
identityService?: string;
16
+
fetch?: typeof fetch;
12
17
}
13
18
14
-
const DEFAULT_PLC = 'https://plc.directory';
15
-
const DEFAULT_IDENTITY_SERVICE = 'https://public.api.bsky.app';
19
+
const DEFAULT_PLC = "https://plc.directory";
20
+
const DEFAULT_IDENTITY_SERVICE = "https://public.api.bsky.app";
16
21
const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
17
-
const SUPPORTED_DID_METHODS = ['plc', 'web'] as const;
22
+
const SUPPORTED_DID_METHODS = ["plc", "web"] as const;
18
23
type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number];
19
24
type SupportedDid = Did<SupportedDidMethod>;
20
25
21
-
export const SLINGSHOT_BASE_URL = 'https://slingshot.microcosm.blue';
26
+
export const SLINGSHOT_BASE_URL = "https://slingshot.microcosm.blue";
22
27
23
28
export const normalizeBaseUrl = (input: string): string => {
24
-
const trimmed = input.trim();
25
-
if (!trimmed) throw new Error('Service URL cannot be empty');
26
-
const withScheme = ABSOLUTE_URL_RE.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
27
-
const url = new URL(withScheme);
28
-
const pathname = url.pathname.replace(/\/+$/, '');
29
-
return pathname ? `${url.origin}${pathname}` : url.origin;
29
+
const trimmed = input.trim();
30
+
if (!trimmed) throw new Error("Service URL cannot be empty");
31
+
const withScheme = ABSOLUTE_URL_RE.test(trimmed)
32
+
? trimmed
33
+
: `https://${trimmed.replace(/^\/+/, "")}`;
34
+
const url = new URL(withScheme);
35
+
const pathname = url.pathname.replace(/\/+$/, "");
36
+
return pathname ? `${url.origin}${pathname}` : url.origin;
30
37
};
31
38
32
39
export class ServiceResolver {
33
-
private plc: string;
34
-
private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
35
-
private handleResolver: XrpcHandleResolver;
36
-
private fetchImpl: typeof fetch;
37
-
constructor(opts: ServiceResolverOptions = {}) {
38
-
const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC;
39
-
const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE;
40
-
this.plc = normalizeBaseUrl(plcSource);
41
-
const identityBase = normalizeBaseUrl(identitySource);
42
-
this.fetchImpl = bindFetch(opts.fetch);
43
-
const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: this.fetchImpl });
44
-
const webResolver = new WebDidDocumentResolver({ fetch: this.fetchImpl });
45
-
this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } });
46
-
this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: this.fetchImpl });
47
-
}
40
+
private plc: string;
41
+
private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
42
+
private handleResolver: XrpcHandleResolver;
43
+
private fetchImpl: typeof fetch;
44
+
constructor(opts: ServiceResolverOptions = {}) {
45
+
const plcSource =
46
+
opts.plcDirectory && opts.plcDirectory.trim()
47
+
? opts.plcDirectory
48
+
: DEFAULT_PLC;
49
+
const identitySource =
50
+
opts.identityService && opts.identityService.trim()
51
+
? opts.identityService
52
+
: DEFAULT_IDENTITY_SERVICE;
53
+
this.plc = normalizeBaseUrl(plcSource);
54
+
const identityBase = normalizeBaseUrl(identitySource);
55
+
this.fetchImpl = bindFetch(opts.fetch);
56
+
const plcResolver = new PlcDidDocumentResolver({
57
+
apiUrl: this.plc,
58
+
fetch: this.fetchImpl,
59
+
});
60
+
const webResolver = new WebDidDocumentResolver({
61
+
fetch: this.fetchImpl,
62
+
});
63
+
this.didResolver = new CompositeDidDocumentResolver({
64
+
methods: { plc: plcResolver, web: webResolver },
65
+
});
66
+
this.handleResolver = new XrpcHandleResolver({
67
+
serviceUrl: identityBase,
68
+
fetch: this.fetchImpl,
69
+
});
70
+
}
48
71
49
-
async resolveDidDoc(did: string): Promise<DidDocument> {
50
-
const trimmed = did.trim();
51
-
if (!trimmed.startsWith('did:')) throw new Error(`Invalid DID ${did}`);
52
-
const methodEnd = trimmed.indexOf(':', 4);
53
-
const method = (methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)) as string;
54
-
if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) {
55
-
throw new Error(`Unsupported DID method ${method ?? '<unknown>'}`);
56
-
}
57
-
return this.didResolver.resolve(trimmed as SupportedDid);
58
-
}
72
+
async resolveDidDoc(did: string): Promise<DidDocument> {
73
+
const trimmed = did.trim();
74
+
if (!trimmed.startsWith("did:")) throw new Error(`Invalid DID ${did}`);
75
+
const methodEnd = trimmed.indexOf(":", 4);
76
+
const method = (
77
+
methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)
78
+
) as string;
79
+
if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) {
80
+
throw new Error(`Unsupported DID method ${method ?? "<unknown>"}`);
81
+
}
82
+
return this.didResolver.resolve(trimmed as SupportedDid);
83
+
}
59
84
60
-
async pdsEndpointForDid(did: string): Promise<string> {
61
-
const doc = await this.resolveDidDoc(did);
62
-
const svc = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer');
63
-
if (!svc || !svc.serviceEndpoint || typeof svc.serviceEndpoint !== 'string') {
64
-
throw new Error(`No PDS endpoint in DID doc for ${did}`);
65
-
}
66
-
return svc.serviceEndpoint.replace(/\/$/, '');
67
-
}
85
+
async pdsEndpointForDid(did: string): Promise<string> {
86
+
const doc = await this.resolveDidDoc(did);
87
+
const svc = doc.service?.find(
88
+
(s) => s.type === "AtprotoPersonalDataServer",
89
+
);
90
+
if (
91
+
!svc ||
92
+
!svc.serviceEndpoint ||
93
+
typeof svc.serviceEndpoint !== "string"
94
+
) {
95
+
throw new Error(`No PDS endpoint in DID doc for ${did}`);
96
+
}
97
+
return svc.serviceEndpoint.replace(/\/$/, "");
98
+
}
68
99
69
-
async resolveHandle(handle: string): Promise<string> {
70
-
const normalized = handle.trim().toLowerCase();
71
-
if (!normalized) throw new Error('Handle cannot be empty');
72
-
let slingshotError: Error | undefined;
73
-
try {
74
-
const url = new URL('/xrpc/com.atproto.identity.resolveHandle', SLINGSHOT_BASE_URL);
75
-
url.searchParams.set('handle', normalized);
76
-
const response = await this.fetchImpl(url);
77
-
if (response.ok) {
78
-
const payload = await response.json() as { did?: string } | null;
79
-
if (payload?.did) {
80
-
return payload.did;
81
-
}
82
-
slingshotError = new Error('Slingshot resolveHandle response missing DID');
83
-
} else {
84
-
slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`);
85
-
const body = response.body;
86
-
if (body) {
87
-
body.cancel().catch(() => {});
88
-
}
89
-
}
90
-
} catch (err) {
91
-
if (err instanceof DOMException && err.name === 'AbortError') throw err;
92
-
slingshotError = err instanceof Error ? err : new Error(String(err));
93
-
}
100
+
async resolveHandle(handle: string): Promise<string> {
101
+
const normalized = handle.trim().toLowerCase();
102
+
if (!normalized) throw new Error("Handle cannot be empty");
103
+
let slingshotError: Error | undefined;
104
+
try {
105
+
const url = new URL(
106
+
"/xrpc/com.atproto.identity.resolveHandle",
107
+
SLINGSHOT_BASE_URL,
108
+
);
109
+
url.searchParams.set("handle", normalized);
110
+
const response = await this.fetchImpl(url);
111
+
if (response.ok) {
112
+
const payload = (await response.json()) as {
113
+
did?: string;
114
+
} | null;
115
+
if (payload?.did) {
116
+
return payload.did;
117
+
}
118
+
slingshotError = new Error(
119
+
"Slingshot resolveHandle response missing DID",
120
+
);
121
+
} else {
122
+
slingshotError = new Error(
123
+
`Slingshot resolveHandle failed with status ${response.status}`,
124
+
);
125
+
const body = response.body;
126
+
if (body) {
127
+
body.cancel().catch(() => {});
128
+
}
129
+
}
130
+
} catch (err) {
131
+
if (err instanceof DOMException && err.name === "AbortError")
132
+
throw err;
133
+
slingshotError =
134
+
err instanceof Error ? err : new Error(String(err));
135
+
}
94
136
95
-
try {
96
-
const did = await this.handleResolver.resolve(normalized as Handle);
97
-
return did;
98
-
} catch (err) {
99
-
if (slingshotError && err instanceof Error) {
100
-
const prior = err.message;
101
-
err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
102
-
}
103
-
throw err;
104
-
}
105
-
}
137
+
try {
138
+
const did = await this.handleResolver.resolve(normalized as Handle);
139
+
return did;
140
+
} catch (err) {
141
+
if (slingshotError && err instanceof Error) {
142
+
const prior = err.message;
143
+
err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
144
+
}
145
+
throw err;
146
+
}
147
+
}
106
148
}
107
149
108
150
export interface CreateClientOptions extends ServiceResolverOptions {
109
-
did?: string; // optional to create a DID-scoped client
110
-
service?: string; // override service base url
151
+
did?: string; // optional to create a DID-scoped client
152
+
service?: string; // override service base url
111
153
}
112
154
113
155
export async function createAtprotoClient(opts: CreateClientOptions = {}) {
114
-
const fetchImpl = bindFetch(opts.fetch);
115
-
let service = opts.service;
116
-
const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
117
-
if (!service && opts.did) {
118
-
service = await resolver.pdsEndpointForDid(opts.did);
119
-
}
120
-
if (!service) throw new Error('service or did required');
121
-
const normalizedService = normalizeBaseUrl(service);
122
-
const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
123
-
const rpc = new Client({ handler });
124
-
return { rpc, service: normalizedService, resolver };
156
+
const fetchImpl = bindFetch(opts.fetch);
157
+
let service = opts.service;
158
+
const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
159
+
if (!service && opts.did) {
160
+
service = await resolver.pdsEndpointForDid(opts.did);
161
+
}
162
+
if (!service) throw new Error("service or did required");
163
+
const normalizedService = normalizeBaseUrl(service);
164
+
const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
165
+
const rpc = new Client({ handler });
166
+
return { rpc, service: normalizedService, resolver };
125
167
}
126
168
127
-
export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc'];
169
+
export type AtprotoClient = Awaited<
170
+
ReturnType<typeof createAtprotoClient>
171
+
>["rpc"];
128
172
129
173
const SLINGSHOT_RETRY_PATHS = [
130
-
'/xrpc/com.atproto.repo.getRecord',
131
-
'/xrpc/com.atproto.identity.resolveHandle',
174
+
"/xrpc/com.atproto.repo.getRecord",
175
+
"/xrpc/com.atproto.identity.resolveHandle",
132
176
];
133
177
134
-
function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler {
135
-
const primary = simpleFetchHandler({ service, fetch: fetchImpl });
136
-
const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl });
137
-
return async (pathname, init) => {
138
-
const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`));
139
-
if (matched) {
140
-
try {
141
-
const slingshotResponse = await slingshot(pathname, init);
142
-
if (slingshotResponse.ok) {
143
-
return slingshotResponse;
144
-
}
145
-
const body = slingshotResponse.body;
146
-
if (body) {
147
-
body.cancel().catch(() => {});
148
-
}
149
-
} catch (err) {
150
-
if (err instanceof DOMException && err.name === 'AbortError') {
151
-
throw err;
152
-
}
153
-
}
154
-
}
155
-
return primary(pathname, init);
156
-
};
178
+
function createSlingshotAwareHandler(
179
+
service: string,
180
+
fetchImpl: typeof fetch,
181
+
): FetchHandler {
182
+
const primary = simpleFetchHandler({ service, fetch: fetchImpl });
183
+
const slingshot = simpleFetchHandler({
184
+
service: SLINGSHOT_BASE_URL,
185
+
fetch: fetchImpl,
186
+
});
187
+
return async (pathname, init) => {
188
+
const matched = SLINGSHOT_RETRY_PATHS.find(
189
+
(candidate) =>
190
+
pathname === candidate || pathname.startsWith(`${candidate}?`),
191
+
);
192
+
if (matched) {
193
+
try {
194
+
const slingshotResponse = await slingshot(pathname, init);
195
+
if (slingshotResponse.ok) {
196
+
return slingshotResponse;
197
+
}
198
+
const body = slingshotResponse.body;
199
+
if (body) {
200
+
body.cancel().catch(() => {});
201
+
}
202
+
} catch (err) {
203
+
if (err instanceof DOMException && err.name === "AbortError") {
204
+
throw err;
205
+
}
206
+
}
207
+
}
208
+
return primary(pathname, init);
209
+
};
157
210
}
158
211
159
212
function bindFetch(fetchImpl?: typeof fetch): typeof fetch {
160
-
const impl = fetchImpl ?? globalThis.fetch;
161
-
if (typeof impl !== 'function') {
162
-
throw new Error('fetch implementation not available');
163
-
}
164
-
return impl.bind(globalThis);
213
+
const impl = fetchImpl ?? globalThis.fetch;
214
+
if (typeof impl !== "function") {
215
+
throw new Error("fetch implementation not available");
216
+
}
217
+
return impl.bind(globalThis);
165
218
}
+60
-21
lib/utils/cache.ts
+60
-21
lib/utils/cache.ts
···
1
-
import type { DidDocument } from '@atcute/identity';
2
-
import { ServiceResolver } from './atproto-client';
1
+
import type { DidDocument } from "@atcute/identity";
2
+
import { ServiceResolver } from "./atproto-client";
3
3
4
4
interface DidCacheEntry {
5
5
did: string;
···
16
16
pdsEndpoint?: string;
17
17
}
18
18
19
-
const toSnapshot = (entry: DidCacheEntry | undefined): DidCacheSnapshot | undefined => {
19
+
const toSnapshot = (
20
+
entry: DidCacheEntry | undefined,
21
+
): DidCacheSnapshot | undefined => {
20
22
if (!entry) return undefined;
21
23
const { did, handle, doc, pdsEndpoint } = entry;
22
24
return { did, handle, doc, pdsEndpoint };
23
25
};
24
26
25
-
const derivePdsEndpoint = (doc: DidDocument | undefined): string | undefined => {
27
+
const derivePdsEndpoint = (
28
+
doc: DidDocument | undefined,
29
+
): string | undefined => {
26
30
if (!doc?.service) return undefined;
27
-
const svc = doc.service.find(service => service.type === 'AtprotoPersonalDataServer');
31
+
const svc = doc.service.find(
32
+
(service) => service.type === "AtprotoPersonalDataServer",
33
+
);
28
34
if (!svc) return undefined;
29
-
const endpoint = typeof svc.serviceEndpoint === 'string' ? svc.serviceEndpoint : undefined;
35
+
const endpoint =
36
+
typeof svc.serviceEndpoint === "string"
37
+
? svc.serviceEndpoint
38
+
: undefined;
30
39
if (!endpoint) return undefined;
31
-
return endpoint.replace(/\/$/, '');
40
+
return endpoint.replace(/\/$/, "");
32
41
};
33
42
34
43
export class DidCache {
···
48
57
return toSnapshot(this.byDid.get(did));
49
58
}
50
59
51
-
memoize(entry: { did: string; handle?: string; doc?: DidDocument; pdsEndpoint?: string }): DidCacheSnapshot {
60
+
memoize(entry: {
61
+
did: string;
62
+
handle?: string;
63
+
doc?: DidDocument;
64
+
pdsEndpoint?: string;
65
+
}): DidCacheSnapshot {
52
66
const did = entry.did;
53
67
const normalizedHandle = entry.handle?.toLowerCase();
54
-
const existing = this.byDid.get(did) ?? (normalizedHandle ? this.byHandle.get(normalizedHandle) : undefined);
68
+
const existing =
69
+
this.byDid.get(did) ??
70
+
(normalizedHandle
71
+
? this.byHandle.get(normalizedHandle)
72
+
: undefined);
55
73
56
74
const doc = entry.doc ?? existing?.doc;
57
75
const handle = normalizedHandle ?? existing?.handle;
58
-
const pdsEndpoint = entry.pdsEndpoint ?? derivePdsEndpoint(doc) ?? existing?.pdsEndpoint;
76
+
const pdsEndpoint =
77
+
entry.pdsEndpoint ??
78
+
derivePdsEndpoint(doc) ??
79
+
existing?.pdsEndpoint;
59
80
60
81
const merged: DidCacheEntry = {
61
82
did,
···
73
94
return toSnapshot(merged) as DidCacheSnapshot;
74
95
}
75
96
76
-
ensureHandle(resolver: ServiceResolver, handle: string): Promise<DidCacheSnapshot> {
97
+
ensureHandle(
98
+
resolver: ServiceResolver,
99
+
handle: string,
100
+
): Promise<DidCacheSnapshot> {
77
101
const normalized = handle.toLowerCase();
78
102
const cached = this.getByHandle(normalized);
79
103
if (cached?.did) return Promise.resolve(cached);
···
81
105
if (pending) return pending;
82
106
const promise = resolver
83
107
.resolveHandle(normalized)
84
-
.then(did => this.memoize({ did, handle: normalized }))
108
+
.then((did) => this.memoize({ did, handle: normalized }))
85
109
.finally(() => {
86
110
this.handlePromises.delete(normalized);
87
111
});
···
89
113
return promise;
90
114
}
91
115
92
-
ensureDidDoc(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> {
116
+
ensureDidDoc(
117
+
resolver: ServiceResolver,
118
+
did: string,
119
+
): Promise<DidCacheSnapshot> {
93
120
const cached = this.getByDid(did);
94
-
if (cached?.doc && cached.handle !== undefined) return Promise.resolve(cached);
121
+
if (cached?.doc && cached.handle !== undefined)
122
+
return Promise.resolve(cached);
95
123
const pending = this.docPromises.get(did);
96
124
if (pending) return pending;
97
125
const promise = resolver
98
126
.resolveDidDoc(did)
99
-
.then(doc => {
100
-
const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://'));
101
-
const handle = aka ? aka.replace('at://', '').toLowerCase() : cached?.handle;
127
+
.then((doc) => {
128
+
const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://"));
129
+
const handle = aka
130
+
? aka.replace("at://", "").toLowerCase()
131
+
: cached?.handle;
102
132
return this.memoize({ did, handle, doc });
103
133
})
104
134
.finally(() => {
···
108
138
return promise;
109
139
}
110
140
111
-
ensurePdsEndpoint(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> {
141
+
ensurePdsEndpoint(
142
+
resolver: ServiceResolver,
143
+
did: string,
144
+
): Promise<DidCacheSnapshot> {
112
145
const cached = this.getByDid(did);
113
146
if (cached?.pdsEndpoint) return Promise.resolve(cached);
114
147
const pending = this.pdsPromises.get(did);
115
148
if (pending) return pending;
116
149
const promise = (async () => {
117
-
const docSnapshot = await this.ensureDidDoc(resolver, did).catch(() => undefined);
150
+
const docSnapshot = await this.ensureDidDoc(resolver, did).catch(
151
+
() => undefined,
152
+
);
118
153
if (docSnapshot?.pdsEndpoint) return docSnapshot;
119
154
const endpoint = await resolver.pdsEndpointForDid(did);
120
155
return this.memoize({ did, pdsEndpoint: endpoint });
···
159
194
this.store.set(this.key(did, cid), { blob, timestamp: Date.now() });
160
195
}
161
196
162
-
ensure(did: string, cid: string, loader: () => { promise: Promise<Blob>; abort: () => void }): EnsureResult {
197
+
ensure(
198
+
did: string,
199
+
cid: string,
200
+
loader: () => { promise: Promise<Blob>; abort: () => void },
201
+
): EnsureResult {
163
202
const cached = this.get(did, cid);
164
203
if (cached) {
165
204
return { promise: Promise.resolve(cached), release: () => {} };
···
176
215
}
177
216
178
217
const { promise, abort } = loader();
179
-
const wrapped = promise.then(blob => {
218
+
const wrapped = promise.then((blob) => {
180
219
this.set(did, cid, blob);
181
220
return blob;
182
221
});
+5
-3
lib/utils/profile.ts
+5
-3
lib/utils/profile.ts
···
1
-
import type { ProfileRecord } from '../types/bluesky';
1
+
import type { ProfileRecord } from "../types/bluesky";
2
2
3
3
interface LegacyBlobRef {
4
4
ref?: { $link?: string };
5
5
cid?: string;
6
6
}
7
7
8
-
export function getAvatarCid(record: ProfileRecord | undefined): string | undefined {
8
+
export function getAvatarCid(
9
+
record: ProfileRecord | undefined,
10
+
): string | undefined {
9
11
const avatar = record?.avatar as LegacyBlobRef | undefined;
10
12
if (!avatar) return undefined;
11
-
if (typeof avatar.cid === 'string') return avatar.cid;
13
+
if (typeof avatar.cid === "string") return avatar.cid;
12
14
return avatar.ref?.$link;
13
15
}
+531
-324
src/App.tsx
+531
-324
src/App.tsx
···
1
-
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
2
-
import { AtProtoProvider } from '../lib/providers/AtProtoProvider';
3
-
import { AtProtoRecord } from '../lib/core/AtProtoRecord';
4
-
import { TangledString } from '../lib/components/TangledString';
5
-
import { LeafletDocument } from '../lib/components/LeafletDocument';
6
-
import { BlueskyProfile } from '../lib/components/BlueskyProfile';
7
-
import { BlueskyPost, BLUESKY_POST_COLLECTION } from '../lib/components/BlueskyPost';
8
-
import { BlueskyPostList } from '../lib/components/BlueskyPostList';
9
-
import { BlueskyQuotePost } from '../lib/components/BlueskyQuotePost';
10
-
import { useDidResolution } from '../lib/hooks/useDidResolution';
11
-
import { useLatestRecord } from '../lib/hooks/useLatestRecord';
12
-
import { ColorSchemeToggle } from '../lib/components/ColorSchemeToggle.tsx';
13
-
import { useColorScheme, type ColorSchemePreference } from '../lib/hooks/useColorScheme';
14
-
import type { FeedPostRecord } from '../lib/types/bluesky';
1
+
import React, {
2
+
useState,
3
+
useCallback,
4
+
useEffect,
5
+
useMemo,
6
+
useRef,
7
+
} from "react";
8
+
import { AtProtoProvider } from "../lib/providers/AtProtoProvider";
9
+
import { AtProtoRecord } from "../lib/core/AtProtoRecord";
10
+
import { TangledString } from "../lib/components/TangledString";
11
+
import { LeafletDocument } from "../lib/components/LeafletDocument";
12
+
import { BlueskyProfile } from "../lib/components/BlueskyProfile";
13
+
import {
14
+
BlueskyPost,
15
+
BLUESKY_POST_COLLECTION,
16
+
} from "../lib/components/BlueskyPost";
17
+
import { BlueskyPostList } from "../lib/components/BlueskyPostList";
18
+
import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
19
+
import { useDidResolution } from "../lib/hooks/useDidResolution";
20
+
import { useLatestRecord } from "../lib/hooks/useLatestRecord";
21
+
import { ColorSchemeToggle } from "../lib/components/ColorSchemeToggle.tsx";
22
+
import {
23
+
useColorScheme,
24
+
type ColorSchemePreference,
25
+
} from "../lib/hooks/useColorScheme";
26
+
import type { FeedPostRecord } from "../lib/types/bluesky";
15
27
16
-
const COLOR_SCHEME_STORAGE_KEY = 'atproto-ui-color-scheme';
28
+
const COLOR_SCHEME_STORAGE_KEY = "atproto-ui-color-scheme";
17
29
18
30
const basicUsageSnippet = `import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
19
31
···
50
62
};`;
51
63
52
64
const codeBlockBase: React.CSSProperties = {
53
-
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
54
-
fontSize: 12,
55
-
whiteSpace: 'pre',
56
-
overflowX: 'auto',
57
-
borderRadius: 10,
58
-
padding: '12px 14px',
59
-
lineHeight: 1.6
65
+
fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
66
+
fontSize: 12,
67
+
whiteSpace: "pre",
68
+
overflowX: "auto",
69
+
borderRadius: 10,
70
+
padding: "12px 14px",
71
+
lineHeight: 1.6,
60
72
};
61
73
62
74
const FullDemo: React.FC = () => {
63
-
const handleInputRef = useRef<HTMLInputElement | null>(null);
64
-
const [submitted, setSubmitted] = useState<string | null>(null);
65
-
const [colorSchemePreference, setColorSchemePreference] = useState<ColorSchemePreference>(() => {
66
-
if (typeof window === 'undefined') return 'system';
67
-
try {
68
-
const stored = window.localStorage.getItem(COLOR_SCHEME_STORAGE_KEY);
69
-
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
70
-
} catch {
71
-
/* ignore */
72
-
}
73
-
return 'system';
74
-
});
75
-
const scheme = useColorScheme(colorSchemePreference);
76
-
const { did, loading: resolvingDid } = useDidResolution(submitted ?? undefined);
77
-
const onSubmit = useCallback<React.FormEventHandler>((e) => {
78
-
e.preventDefault();
79
-
const rawValue = handleInputRef.current?.value;
80
-
const nextValue = rawValue?.trim();
81
-
if (!nextValue) return;
82
-
if (handleInputRef.current) {
83
-
handleInputRef.current.value = nextValue;
84
-
}
85
-
setSubmitted(nextValue);
86
-
}, []);
75
+
const handleInputRef = useRef<HTMLInputElement | null>(null);
76
+
const [submitted, setSubmitted] = useState<string | null>(null);
77
+
const [colorSchemePreference, setColorSchemePreference] =
78
+
useState<ColorSchemePreference>(() => {
79
+
if (typeof window === "undefined") return "system";
80
+
try {
81
+
const stored = window.localStorage.getItem(
82
+
COLOR_SCHEME_STORAGE_KEY,
83
+
);
84
+
if (
85
+
stored === "light" ||
86
+
stored === "dark" ||
87
+
stored === "system"
88
+
)
89
+
return stored;
90
+
} catch {
91
+
/* ignore */
92
+
}
93
+
return "system";
94
+
});
95
+
const scheme = useColorScheme(colorSchemePreference);
96
+
const { did, loading: resolvingDid } = useDidResolution(
97
+
submitted ?? undefined,
98
+
);
99
+
const onSubmit = useCallback<React.FormEventHandler>((e) => {
100
+
e.preventDefault();
101
+
const rawValue = handleInputRef.current?.value;
102
+
const nextValue = rawValue?.trim();
103
+
if (!nextValue) return;
104
+
if (handleInputRef.current) {
105
+
handleInputRef.current.value = nextValue;
106
+
}
107
+
setSubmitted(nextValue);
108
+
}, []);
87
109
88
-
useEffect(() => {
89
-
if (typeof window === 'undefined') return;
90
-
try {
91
-
window.localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, colorSchemePreference);
92
-
} catch {
93
-
/* ignore */
94
-
}
95
-
}, [colorSchemePreference]);
110
+
useEffect(() => {
111
+
if (typeof window === "undefined") return;
112
+
try {
113
+
window.localStorage.setItem(
114
+
COLOR_SCHEME_STORAGE_KEY,
115
+
colorSchemePreference,
116
+
);
117
+
} catch {
118
+
/* ignore */
119
+
}
120
+
}, [colorSchemePreference]);
96
121
97
-
useEffect(() => {
98
-
if (typeof document === 'undefined') return;
99
-
const root = document.documentElement;
100
-
const body = document.body;
101
-
const prevScheme = root.dataset.colorScheme;
102
-
const prevBg = body.style.backgroundColor;
103
-
const prevColor = body.style.color;
104
-
root.dataset.colorScheme = scheme;
105
-
body.style.backgroundColor = scheme === 'dark' ? '#020617' : '#f8fafc';
106
-
body.style.color = scheme === 'dark' ? '#e2e8f0' : '#0f172a';
107
-
return () => {
108
-
root.dataset.colorScheme = prevScheme ?? '';
109
-
body.style.backgroundColor = prevBg;
110
-
body.style.color = prevColor;
111
-
};
112
-
}, [scheme]);
122
+
useEffect(() => {
123
+
if (typeof document === "undefined") return;
124
+
const root = document.documentElement;
125
+
const body = document.body;
126
+
const prevScheme = root.dataset.colorScheme;
127
+
const prevBg = body.style.backgroundColor;
128
+
const prevColor = body.style.color;
129
+
root.dataset.colorScheme = scheme;
130
+
body.style.backgroundColor = scheme === "dark" ? "#020617" : "#f8fafc";
131
+
body.style.color = scheme === "dark" ? "#e2e8f0" : "#0f172a";
132
+
return () => {
133
+
root.dataset.colorScheme = prevScheme ?? "";
134
+
body.style.backgroundColor = prevBg;
135
+
body.style.color = prevColor;
136
+
};
137
+
}, [scheme]);
113
138
114
-
const showHandle = submitted && !submitted.startsWith('did:') ? submitted : undefined;
139
+
const showHandle =
140
+
submitted && !submitted.startsWith("did:") ? submitted : undefined;
115
141
116
-
const mutedTextColor = useMemo(() => (scheme === 'dark' ? '#94a3b8' : '#555'), [scheme]);
117
-
const panelStyle = useMemo<React.CSSProperties>(() => ({
118
-
display: 'flex',
119
-
flexDirection: 'column',
120
-
gap: 8,
121
-
padding: 10,
122
-
borderRadius: 12,
123
-
borderColor: scheme === 'dark' ? '#1e293b' : '#e2e8f0',
124
-
}), [scheme]);
125
-
const baseTextColor = useMemo(() => (scheme === 'dark' ? '#e2e8f0' : '#0f172a'), [scheme]);
126
-
const gistPanelStyle = useMemo<React.CSSProperties>(() => ({
127
-
...panelStyle,
128
-
padding: 0,
129
-
border: 'none',
130
-
background: 'transparent',
131
-
backdropFilter: 'none',
132
-
marginTop: 32
133
-
}), [panelStyle]);
134
-
const leafletPanelStyle = useMemo<React.CSSProperties>(() => ({
135
-
...panelStyle,
136
-
padding: 0,
137
-
border: 'none',
138
-
background: 'transparent',
139
-
backdropFilter: 'none',
140
-
marginTop: 32,
141
-
alignItems: 'center'
142
-
}), [panelStyle]);
143
-
const primaryGridStyle = useMemo<React.CSSProperties>(() => ({
144
-
display: 'grid',
145
-
gap: 32,
146
-
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))'
147
-
}), []);
148
-
const columnStackStyle = useMemo<React.CSSProperties>(() => ({
149
-
display: 'flex',
150
-
flexDirection: 'column',
151
-
gap: 32
152
-
}), []);
153
-
const codeBlockStyle = useMemo<React.CSSProperties>(() => ({
154
-
...codeBlockBase,
155
-
background: scheme === 'dark' ? '#0b1120' : '#f1f5f9',
156
-
border: `1px solid ${scheme === 'dark' ? '#1e293b' : '#e2e8f0'}`
157
-
}), [scheme]);
158
-
const codeTextStyle = useMemo<React.CSSProperties>(() => ({
159
-
margin: 0,
160
-
display: 'block',
161
-
fontFamily: codeBlockBase.fontFamily,
162
-
fontSize: 12,
163
-
lineHeight: 1.6,
164
-
whiteSpace: 'pre'
165
-
}), []);
166
-
const basicCodeRef = useRef<HTMLElement | null>(null);
167
-
const customCodeRef = useRef<HTMLElement | null>(null);
142
+
const mutedTextColor = useMemo(
143
+
() => (scheme === "dark" ? "#94a3b8" : "#555"),
144
+
[scheme],
145
+
);
146
+
const panelStyle = useMemo<React.CSSProperties>(
147
+
() => ({
148
+
display: "flex",
149
+
flexDirection: "column",
150
+
gap: 8,
151
+
padding: 10,
152
+
borderRadius: 12,
153
+
borderColor: scheme === "dark" ? "#1e293b" : "#e2e8f0",
154
+
}),
155
+
[scheme],
156
+
);
157
+
const baseTextColor = useMemo(
158
+
() => (scheme === "dark" ? "#e2e8f0" : "#0f172a"),
159
+
[scheme],
160
+
);
161
+
const gistPanelStyle = useMemo<React.CSSProperties>(
162
+
() => ({
163
+
...panelStyle,
164
+
padding: 0,
165
+
border: "none",
166
+
background: "transparent",
167
+
backdropFilter: "none",
168
+
marginTop: 32,
169
+
}),
170
+
[panelStyle],
171
+
);
172
+
const leafletPanelStyle = useMemo<React.CSSProperties>(
173
+
() => ({
174
+
...panelStyle,
175
+
padding: 0,
176
+
border: "none",
177
+
background: "transparent",
178
+
backdropFilter: "none",
179
+
marginTop: 32,
180
+
alignItems: "center",
181
+
}),
182
+
[panelStyle],
183
+
);
184
+
const primaryGridStyle = useMemo<React.CSSProperties>(
185
+
() => ({
186
+
display: "grid",
187
+
gap: 32,
188
+
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
189
+
}),
190
+
[],
191
+
);
192
+
const columnStackStyle = useMemo<React.CSSProperties>(
193
+
() => ({
194
+
display: "flex",
195
+
flexDirection: "column",
196
+
gap: 32,
197
+
}),
198
+
[],
199
+
);
200
+
const codeBlockStyle = useMemo<React.CSSProperties>(
201
+
() => ({
202
+
...codeBlockBase,
203
+
background: scheme === "dark" ? "#0b1120" : "#f1f5f9",
204
+
border: `1px solid ${scheme === "dark" ? "#1e293b" : "#e2e8f0"}`,
205
+
}),
206
+
[scheme],
207
+
);
208
+
const codeTextStyle = useMemo<React.CSSProperties>(
209
+
() => ({
210
+
margin: 0,
211
+
display: "block",
212
+
fontFamily: codeBlockBase.fontFamily,
213
+
fontSize: 12,
214
+
lineHeight: 1.6,
215
+
whiteSpace: "pre",
216
+
}),
217
+
[],
218
+
);
219
+
const basicCodeRef = useRef<HTMLElement | null>(null);
220
+
const customCodeRef = useRef<HTMLElement | null>(null);
168
221
169
-
// Latest Bluesky post
170
-
const {
171
-
rkey: latestPostRkey,
172
-
loading: loadingLatestPost,
173
-
empty: noPosts,
174
-
error: latestPostError
175
-
} = useLatestRecord<unknown>(did, BLUESKY_POST_COLLECTION);
222
+
// Latest Bluesky post
223
+
const {
224
+
rkey: latestPostRkey,
225
+
loading: loadingLatestPost,
226
+
empty: noPosts,
227
+
error: latestPostError,
228
+
} = useLatestRecord<unknown>(did, BLUESKY_POST_COLLECTION);
176
229
177
-
const quoteSampleDid = 'did:plc:ttdrpj45ibqunmfhdsb4zdwq';
178
-
const quoteSampleRkey = '3m2prlq6xxc2v';
230
+
const quoteSampleDid = "did:plc:ttdrpj45ibqunmfhdsb4zdwq";
231
+
const quoteSampleRkey = "3m2prlq6xxc2v";
179
232
180
-
return (
181
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, color: baseTextColor }}>
182
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between' }}>
183
-
<form onSubmit={onSubmit} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', flex: '1 1 320px' }}>
184
-
<input
185
-
placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
186
-
ref={handleInputRef}
187
-
style={{ flex: '1 1 260px', padding: '6px 8px', borderRadius: 8, border: '1px solid', borderColor: scheme === 'dark' ? '#1e293b' : '#cbd5f5', background: scheme === 'dark' ? '#0b1120' : '#fff', color: scheme === 'dark' ? '#e2e8f0' : '#0f172a' }}
188
-
/>
189
-
<button type="submit" style={{ padding: '6px 16px', borderRadius: 8, border: 'none', background: '#2563eb', color: '#fff', cursor: 'pointer' }}>Load</button>
190
-
</form>
191
-
<ColorSchemeToggle value={colorSchemePreference} onChange={setColorSchemePreference} scheme={scheme} />
192
-
</div>
193
-
{!submitted && <p style={{ color: mutedTextColor }}>Enter a handle to fetch your profile, latest Bluesky post, a Tangled string, and a Leaflet document.</p>}
194
-
{submitted && resolvingDid && <p style={{ color: mutedTextColor }}>Resolving DID…</p>}
195
-
{did && (
196
-
<>
197
-
<div style={primaryGridStyle}>
198
-
<div style={columnStackStyle}>
199
-
<section style={panelStyle}>
200
-
<h3 style={sectionHeaderStyle}>Profile</h3>
201
-
<BlueskyProfile did={did} handle={showHandle} colorScheme={colorSchemePreference} />
202
-
</section>
203
-
<section style={panelStyle}>
204
-
<h3 style={sectionHeaderStyle}>Recent Posts</h3>
205
-
<BlueskyPostList did={did} colorScheme={colorSchemePreference} />
206
-
</section>
207
-
</div>
208
-
<div style={columnStackStyle}>
209
-
<section style={panelStyle}>
210
-
<h3 style={sectionHeaderStyle}>Latest Bluesky Post</h3>
211
-
{loadingLatestPost && <div style={loadingBox}>Loading latest post…</div>}
212
-
{latestPostError && <div style={errorBox}>Failed to load latest post.</div>}
213
-
{noPosts && <div style={{ ...infoBox, color: mutedTextColor }}>No posts found.</div>}
214
-
{!loadingLatestPost && latestPostRkey && (
215
-
<BlueskyPost did={did} rkey={latestPostRkey} colorScheme={colorSchemePreference} />
216
-
)}
217
-
</section>
218
-
<section style={panelStyle}>
219
-
<h3 style={sectionHeaderStyle}>Quote Post Demo</h3>
220
-
<BlueskyQuotePost did={quoteSampleDid} rkey={quoteSampleRkey} colorScheme={colorSchemePreference} />
221
-
</section>
222
-
</div>
223
-
</div>
224
-
<section style={gistPanelStyle}>
225
-
<h3 style={sectionHeaderStyle}>A Tangled String</h3>
226
-
<TangledString did="nekomimi.pet" rkey="3m2p4gjptg522" colorScheme={colorSchemePreference} />
227
-
</section>
228
-
<section style={leafletPanelStyle}>
229
-
<h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
230
-
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
231
-
<LeafletDocument did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"} rkey={"3m2seagm2222c"} colorScheme={colorSchemePreference} />
232
-
</div>
233
-
</section>
234
-
</>
235
-
)}
236
-
<section style={{ ...panelStyle, marginTop: 32 }}>
237
-
<h3 style={sectionHeaderStyle}>Build your own component</h3>
238
-
<p style={{ color: mutedTextColor, margin: '4px 0 8px' }}>
239
-
Wrap your app with the provider once and drop the ready-made components wherever you need them.
240
-
</p>
241
-
<pre style={codeBlockStyle}>
242
-
<code ref={basicCodeRef} className="language-tsx" style={codeTextStyle}>{basicUsageSnippet}</code>
243
-
</pre>
244
-
<p style={{ color: mutedTextColor, margin: '16px 0 8px' }}>
245
-
Need to make your own component? Compose your own renderer with the hooks and utilities that ship with the library.
246
-
</p>
247
-
<pre style={codeBlockStyle}>
248
-
<code ref={customCodeRef} className="language-tsx" style={codeTextStyle}>{customComponentSnippet}</code>
249
-
</pre>
250
-
{did && (
251
-
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
252
-
<p style={{ color: mutedTextColor, margin: 0 }}>
253
-
Live example with your handle:
254
-
</p>
255
-
<LatestPostSummary did={did} handle={showHandle} colorScheme={colorSchemePreference} />
256
-
</div>
257
-
)}
258
-
</section>
259
-
</div>
260
-
);
233
+
return (
234
+
<div
235
+
style={{
236
+
display: "flex",
237
+
flexDirection: "column",
238
+
gap: 20,
239
+
color: baseTextColor,
240
+
}}
241
+
>
242
+
<div
243
+
style={{
244
+
display: "flex",
245
+
flexWrap: "wrap",
246
+
gap: 12,
247
+
alignItems: "center",
248
+
justifyContent: "space-between",
249
+
}}
250
+
>
251
+
<form
252
+
onSubmit={onSubmit}
253
+
style={{
254
+
display: "flex",
255
+
gap: 8,
256
+
flexWrap: "wrap",
257
+
flex: "1 1 320px",
258
+
}}
259
+
>
260
+
<input
261
+
placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
262
+
ref={handleInputRef}
263
+
style={{
264
+
flex: "1 1 260px",
265
+
padding: "6px 8px",
266
+
borderRadius: 8,
267
+
border: "1px solid",
268
+
borderColor:
269
+
scheme === "dark" ? "#1e293b" : "#cbd5f5",
270
+
background: scheme === "dark" ? "#0b1120" : "#fff",
271
+
color: scheme === "dark" ? "#e2e8f0" : "#0f172a",
272
+
}}
273
+
/>
274
+
<button
275
+
type="submit"
276
+
style={{
277
+
padding: "6px 16px",
278
+
borderRadius: 8,
279
+
border: "none",
280
+
background: "#2563eb",
281
+
color: "#fff",
282
+
cursor: "pointer",
283
+
}}
284
+
>
285
+
Load
286
+
</button>
287
+
</form>
288
+
<ColorSchemeToggle
289
+
value={colorSchemePreference}
290
+
onChange={setColorSchemePreference}
291
+
scheme={scheme}
292
+
/>
293
+
</div>
294
+
{!submitted && (
295
+
<p style={{ color: mutedTextColor }}>
296
+
Enter a handle to fetch your profile, latest Bluesky post, a
297
+
Tangled string, and a Leaflet document.
298
+
</p>
299
+
)}
300
+
{submitted && resolvingDid && (
301
+
<p style={{ color: mutedTextColor }}>Resolving DID…</p>
302
+
)}
303
+
{did && (
304
+
<>
305
+
<div style={primaryGridStyle}>
306
+
<div style={columnStackStyle}>
307
+
<section style={panelStyle}>
308
+
<h3 style={sectionHeaderStyle}>Profile</h3>
309
+
<BlueskyProfile
310
+
did={did}
311
+
handle={showHandle}
312
+
colorScheme={colorSchemePreference}
313
+
/>
314
+
</section>
315
+
<section style={panelStyle}>
316
+
<h3 style={sectionHeaderStyle}>Recent Posts</h3>
317
+
<BlueskyPostList
318
+
did={did}
319
+
colorScheme={colorSchemePreference}
320
+
/>
321
+
</section>
322
+
</div>
323
+
<div style={columnStackStyle}>
324
+
<section style={panelStyle}>
325
+
<h3 style={sectionHeaderStyle}>
326
+
Latest Bluesky Post
327
+
</h3>
328
+
{loadingLatestPost && (
329
+
<div style={loadingBox}>
330
+
Loading latest post…
331
+
</div>
332
+
)}
333
+
{latestPostError && (
334
+
<div style={errorBox}>
335
+
Failed to load latest post.
336
+
</div>
337
+
)}
338
+
{noPosts && (
339
+
<div
340
+
style={{
341
+
...infoBox,
342
+
color: mutedTextColor,
343
+
}}
344
+
>
345
+
No posts found.
346
+
</div>
347
+
)}
348
+
{!loadingLatestPost && latestPostRkey && (
349
+
<BlueskyPost
350
+
did={did}
351
+
rkey={latestPostRkey}
352
+
colorScheme={colorSchemePreference}
353
+
/>
354
+
)}
355
+
</section>
356
+
<section style={panelStyle}>
357
+
<h3 style={sectionHeaderStyle}>
358
+
Quote Post Demo
359
+
</h3>
360
+
<BlueskyQuotePost
361
+
did={quoteSampleDid}
362
+
rkey={quoteSampleRkey}
363
+
colorScheme={colorSchemePreference}
364
+
/>
365
+
</section>
366
+
</div>
367
+
</div>
368
+
<section style={gistPanelStyle}>
369
+
<h3 style={sectionHeaderStyle}>A Tangled String</h3>
370
+
<TangledString
371
+
did="nekomimi.pet"
372
+
rkey="3m2p4gjptg522"
373
+
colorScheme={colorSchemePreference}
374
+
/>
375
+
</section>
376
+
<section style={leafletPanelStyle}>
377
+
<h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
378
+
<div
379
+
style={{
380
+
width: "100%",
381
+
display: "flex",
382
+
justifyContent: "center",
383
+
}}
384
+
>
385
+
<LeafletDocument
386
+
did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"}
387
+
rkey={"3m2seagm2222c"}
388
+
colorScheme={colorSchemePreference}
389
+
/>
390
+
</div>
391
+
</section>
392
+
</>
393
+
)}
394
+
<section style={{ ...panelStyle, marginTop: 32 }}>
395
+
<h3 style={sectionHeaderStyle}>Build your own component</h3>
396
+
<p style={{ color: mutedTextColor, margin: "4px 0 8px" }}>
397
+
Wrap your app with the provider once and drop the ready-made
398
+
components wherever you need them.
399
+
</p>
400
+
<pre style={codeBlockStyle}>
401
+
<code
402
+
ref={basicCodeRef}
403
+
className="language-tsx"
404
+
style={codeTextStyle}
405
+
>
406
+
{basicUsageSnippet}
407
+
</code>
408
+
</pre>
409
+
<p style={{ color: mutedTextColor, margin: "16px 0 8px" }}>
410
+
Need to make your own component? Compose your own renderer
411
+
with the hooks and utilities that ship with the library.
412
+
</p>
413
+
<pre style={codeBlockStyle}>
414
+
<code
415
+
ref={customCodeRef}
416
+
className="language-tsx"
417
+
style={codeTextStyle}
418
+
>
419
+
{customComponentSnippet}
420
+
</code>
421
+
</pre>
422
+
{did && (
423
+
<div
424
+
style={{
425
+
marginTop: 16,
426
+
display: "flex",
427
+
flexDirection: "column",
428
+
gap: 12,
429
+
}}
430
+
>
431
+
<p style={{ color: mutedTextColor, margin: 0 }}>
432
+
Live example with your handle:
433
+
</p>
434
+
<LatestPostSummary
435
+
did={did}
436
+
handle={showHandle}
437
+
colorScheme={colorSchemePreference}
438
+
/>
439
+
</div>
440
+
)}
441
+
</section>
442
+
</div>
443
+
);
261
444
};
262
445
263
-
const LatestPostSummary: React.FC<{ did: string; handle?: string; colorScheme: ColorSchemePreference }> = ({ did, colorScheme }) => {
264
-
const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION);
265
-
const scheme = useColorScheme(colorScheme);
266
-
const palette = scheme === 'dark' ? latestSummaryPalette.dark : latestSummaryPalette.light;
446
+
const LatestPostSummary: React.FC<{
447
+
did: string;
448
+
handle?: string;
449
+
colorScheme: ColorSchemePreference;
450
+
}> = ({ did, colorScheme }) => {
451
+
const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(
452
+
did,
453
+
BLUESKY_POST_COLLECTION,
454
+
);
455
+
const scheme = useColorScheme(colorScheme);
456
+
const palette =
457
+
scheme === "dark"
458
+
? latestSummaryPalette.dark
459
+
: latestSummaryPalette.light;
267
460
268
-
if (loading) return <div style={palette.muted}>Loading summary…</div>;
269
-
if (error) return <div style={palette.error}>Failed to load the latest post.</div>;
270
-
if (!rkey) return <div style={palette.muted}>No posts published yet.</div>;
461
+
if (loading) return <div style={palette.muted}>Loading summary…</div>;
462
+
if (error)
463
+
return <div style={palette.error}>Failed to load the latest post.</div>;
464
+
if (!rkey) return <div style={palette.muted}>No posts published yet.</div>;
271
465
272
-
const atProtoProps = record
273
-
? { record }
274
-
: { did, collection: 'app.bsky.feed.post', rkey };
466
+
const atProtoProps = record
467
+
? { record }
468
+
: { did, collection: "app.bsky.feed.post", rkey };
275
469
276
-
return (
277
-
<AtProtoRecord<FeedPostRecord>
278
-
{...atProtoProps}
279
-
renderer={({ record: resolvedRecord }) => (
280
-
<article data-color-scheme={scheme}>
281
-
<strong>{resolvedRecord?.text ?? 'Empty post'}</strong>
282
-
</article>
283
-
)}
284
-
/>
285
-
);
470
+
return (
471
+
<AtProtoRecord<FeedPostRecord>
472
+
{...atProtoProps}
473
+
renderer={({ record: resolvedRecord }) => (
474
+
<article data-color-scheme={scheme}>
475
+
<strong>{resolvedRecord?.text ?? "Empty post"}</strong>
476
+
</article>
477
+
)}
478
+
/>
479
+
);
286
480
};
287
481
288
-
const sectionHeaderStyle: React.CSSProperties = { margin: '4px 0', fontSize: 16 };
482
+
const sectionHeaderStyle: React.CSSProperties = {
483
+
margin: "4px 0",
484
+
fontSize: 16,
485
+
};
289
486
const loadingBox: React.CSSProperties = { padding: 8 };
290
-
const errorBox: React.CSSProperties = { padding: 8, color: 'crimson' };
291
-
const infoBox: React.CSSProperties = { padding: 8, color: '#555' };
487
+
const errorBox: React.CSSProperties = { padding: 8, color: "crimson" };
488
+
const infoBox: React.CSSProperties = { padding: 8, color: "#555" };
292
489
293
490
const latestSummaryPalette = {
294
-
light: {
295
-
card: {
296
-
border: '1px solid #e2e8f0',
297
-
background: '#ffffff',
298
-
borderRadius: 12,
299
-
padding: 12,
300
-
display: 'flex',
301
-
flexDirection: 'column',
302
-
gap: 8
303
-
} satisfies React.CSSProperties,
304
-
header: {
305
-
display: 'flex',
306
-
alignItems: 'baseline',
307
-
justifyContent: 'space-between',
308
-
gap: 12,
309
-
color: '#0f172a'
310
-
} satisfies React.CSSProperties,
311
-
time: {
312
-
fontSize: 12,
313
-
color: '#64748b'
314
-
} satisfies React.CSSProperties,
315
-
text: {
316
-
margin: 0,
317
-
color: '#1f2937',
318
-
whiteSpace: 'pre-wrap'
319
-
} satisfies React.CSSProperties,
320
-
link: {
321
-
color: '#2563eb',
322
-
fontWeight: 600,
323
-
fontSize: 12,
324
-
textDecoration: 'none'
325
-
} satisfies React.CSSProperties,
326
-
muted: {
327
-
color: '#64748b'
328
-
} satisfies React.CSSProperties,
329
-
error: {
330
-
color: 'crimson'
331
-
} satisfies React.CSSProperties
332
-
},
333
-
dark: {
334
-
card: {
335
-
border: '1px solid #1e293b',
336
-
background: '#0f172a',
337
-
borderRadius: 12,
338
-
padding: 12,
339
-
display: 'flex',
340
-
flexDirection: 'column',
341
-
gap: 8
342
-
} satisfies React.CSSProperties,
343
-
header: {
344
-
display: 'flex',
345
-
alignItems: 'baseline',
346
-
justifyContent: 'space-between',
347
-
gap: 12,
348
-
color: '#e2e8f0'
349
-
} satisfies React.CSSProperties,
350
-
time: {
351
-
fontSize: 12,
352
-
color: '#cbd5f5'
353
-
} satisfies React.CSSProperties,
354
-
text: {
355
-
margin: 0,
356
-
color: '#e2e8f0',
357
-
whiteSpace: 'pre-wrap'
358
-
} satisfies React.CSSProperties,
359
-
link: {
360
-
color: '#38bdf8',
361
-
fontWeight: 600,
362
-
fontSize: 12,
363
-
textDecoration: 'none'
364
-
} satisfies React.CSSProperties,
365
-
muted: {
366
-
color: '#94a3b8'
367
-
} satisfies React.CSSProperties,
368
-
error: {
369
-
color: '#f472b6'
370
-
} satisfies React.CSSProperties
371
-
}
491
+
light: {
492
+
card: {
493
+
border: "1px solid #e2e8f0",
494
+
background: "#ffffff",
495
+
borderRadius: 12,
496
+
padding: 12,
497
+
display: "flex",
498
+
flexDirection: "column",
499
+
gap: 8,
500
+
} satisfies React.CSSProperties,
501
+
header: {
502
+
display: "flex",
503
+
alignItems: "baseline",
504
+
justifyContent: "space-between",
505
+
gap: 12,
506
+
color: "#0f172a",
507
+
} satisfies React.CSSProperties,
508
+
time: {
509
+
fontSize: 12,
510
+
color: "#64748b",
511
+
} satisfies React.CSSProperties,
512
+
text: {
513
+
margin: 0,
514
+
color: "#1f2937",
515
+
whiteSpace: "pre-wrap",
516
+
} satisfies React.CSSProperties,
517
+
link: {
518
+
color: "#2563eb",
519
+
fontWeight: 600,
520
+
fontSize: 12,
521
+
textDecoration: "none",
522
+
} satisfies React.CSSProperties,
523
+
muted: {
524
+
color: "#64748b",
525
+
} satisfies React.CSSProperties,
526
+
error: {
527
+
color: "crimson",
528
+
} satisfies React.CSSProperties,
529
+
},
530
+
dark: {
531
+
card: {
532
+
border: "1px solid #1e293b",
533
+
background: "#0f172a",
534
+
borderRadius: 12,
535
+
padding: 12,
536
+
display: "flex",
537
+
flexDirection: "column",
538
+
gap: 8,
539
+
} satisfies React.CSSProperties,
540
+
header: {
541
+
display: "flex",
542
+
alignItems: "baseline",
543
+
justifyContent: "space-between",
544
+
gap: 12,
545
+
color: "#e2e8f0",
546
+
} satisfies React.CSSProperties,
547
+
time: {
548
+
fontSize: 12,
549
+
color: "#cbd5f5",
550
+
} satisfies React.CSSProperties,
551
+
text: {
552
+
margin: 0,
553
+
color: "#e2e8f0",
554
+
whiteSpace: "pre-wrap",
555
+
} satisfies React.CSSProperties,
556
+
link: {
557
+
color: "#38bdf8",
558
+
fontWeight: 600,
559
+
fontSize: 12,
560
+
textDecoration: "none",
561
+
} satisfies React.CSSProperties,
562
+
muted: {
563
+
color: "#94a3b8",
564
+
} satisfies React.CSSProperties,
565
+
error: {
566
+
color: "#f472b6",
567
+
} satisfies React.CSSProperties,
568
+
},
372
569
} as const;
373
570
374
571
export const App: React.FC = () => {
375
-
return (
376
-
<AtProtoProvider>
377
-
<div style={{ maxWidth: 860, margin: '40px auto', padding: '0 20px', fontFamily: 'system-ui, sans-serif' }}>
378
-
<h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1>
379
-
<p style={{ lineHeight: 1.4 }}>A component library for rendering common AT Protocol records for applications such as Bluesky and Tangled.</p>
380
-
<hr style={{ margin: '32px 0' }} />
381
-
<FullDemo />
382
-
</div>
383
-
</AtProtoProvider>
384
-
);
572
+
return (
573
+
<AtProtoProvider>
574
+
<div
575
+
style={{
576
+
maxWidth: 860,
577
+
margin: "40px auto",
578
+
padding: "0 20px",
579
+
fontFamily: "system-ui, sans-serif",
580
+
}}
581
+
>
582
+
<h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1>
583
+
<p style={{ lineHeight: 1.4 }}>
584
+
A component library for rendering common AT Protocol records
585
+
for applications such as Bluesky and Tangled.
586
+
</p>
587
+
<hr style={{ margin: "32px 0" }} />
588
+
<FullDemo />
589
+
</div>
590
+
</AtProtoProvider>
591
+
);
385
592
};
386
593
387
594
export default App;
+5
-5
src/main.tsx
+5
-5
src/main.tsx
···
1
-
import { createRoot } from 'react-dom/client';
2
-
import App from './App';
1
+
import { createRoot } from "react-dom/client";
2
+
import App from "./App";
3
3
4
-
const el = document.getElementById('root');
4
+
const el = document.getElementById("root");
5
5
if (el) {
6
-
const root = createRoot(el);
7
-
root.render(<App />);
6
+
const root = createRoot(el);
7
+
root.render(<App />);
8
8
}