A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
40
fork

Configure Feed

Select the types of activity you want to include in your feed.

formatting

+5502 -3854
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 // 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 }