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

formatting

+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 }