BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: handle additional record embeds and more exhaustive quoted post handling

+2211 -473
+10 -2
docs/todo.md
··· 5 5 6 6 ## Bugs 7 7 8 + - [ ] Quoted posts should link to the original post 9 + - [ ] They should also nest properly 10 + 8 11 ## High Priority Updates 9 12 10 - - [ ] Profile RSS 11 - - OK. So making an RSS reader with share to BlueSky would be cool... 13 + - [ ] Feed view 14 + - [ ] Starter pack view 15 + - [ ] List view 16 + 17 + ## Updates 18 + 19 + - [ ] Profile RSS? 12 20 13 21 ## Multicolumn Layouts 14 22
+1 -1
src/components/feeds/DraftsList.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { DraftController } from "$/lib/api/drafts"; 3 - import { formatRelativeTime } from "$/lib/feeds"; 4 3 import type { Draft } from "$/lib/types"; 4 + import { formatRelativeTime } from "$/lib/utils/text"; 5 5 import { normalizeError } from "$/lib/utils/text"; 6 6 import * as logger from "@tauri-apps/plugin-log"; 7 7 import { createEffect, createSignal, For, Show } from "solid-js";
+161 -173
src/components/feeds/ImageGallery.tsx
··· 1 1 import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 2 2 import { Icon } from "$/components/shared/Icon"; 3 3 import { MediaController } from "$/lib/api/media"; 4 - import { clamp, normalizeError } from "$/lib/utils/text"; 4 + import { clamp } from "$/lib/utils/text"; 5 5 import { revealItemInDir } from "@tauri-apps/plugin-opener"; 6 6 import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; 7 7 import { Portal } from "solid-js/web"; 8 8 import { Motion, Presence } from "solid-motionone"; 9 + import { filenameFromPath, toDownloadErrorMessage } from "./embeds/shared"; 9 10 10 11 type GalleryImage = { alt?: string; fullsize?: string; thumb?: string }; 11 12 13 + type GalleryOverlayProps = { 14 + authorHandle?: string; 15 + authorHref?: string; 16 + downloadPending: boolean; 17 + expanded: boolean; 18 + hasManyImages: boolean; 19 + imageCount: number; 20 + index: number; 21 + postText?: string; 22 + selectedImage: GalleryImage | null; 23 + showPostTextToggle: boolean; 24 + onClose: () => void; 25 + onDownload: () => void; 26 + onStep: (offset: -1 | 1) => void; 27 + onToggleExpand: () => void; 28 + }; 29 + 30 + function GalleryOverlay(props: GalleryOverlayProps) { 31 + return ( 32 + <Motion.div 33 + class="fixed inset-0 z-60 grid min-h-0 grid-rows-[1fr_auto] bg-surface-container-highest/70 p-4 backdrop-blur-[20px] max-[760px]:p-3" 34 + initial={{ opacity: 0 }} 35 + animate={{ opacity: 1 }} 36 + exit={{ opacity: 0 }} 37 + transition={{ duration: 0.18 }}> 38 + <button 39 + type="button" 40 + aria-label="Close gallery" 41 + class="absolute inset-0 border-0 bg-transparent" 42 + onClick={() => props.onClose()} /> 43 + 44 + <div class="relative z-1 grid min-h-0"> 45 + <Toolbar 46 + current={props.hasManyImages ? props.index + 1 : 1} 47 + disabled={props.downloadPending} 48 + total={props.hasManyImages ? props.imageCount : 1} 49 + onDownload={props.onDownload} 50 + onClose={props.onClose} 51 + pending={props.downloadPending} /> 52 + 53 + <div class="relative grid min-h-0 place-items-center px-14 py-3 max-[760px]:px-11"> 54 + <img 55 + class="max-h-full max-w-full rounded-2xl object-contain shadow-[0_30px_60px_rgba(0,0,0,0.35)]" 56 + src={props.selectedImage?.fullsize ?? props.selectedImage?.thumb} 57 + alt={props.selectedImage?.alt ?? ""} /> 58 + 59 + {props.hasManyImages ? <ArrowButton direction="left" onClick={() => props.onStep(-1)} /> : null} 60 + {props.hasManyImages ? <ArrowButton direction="right" onClick={() => props.onStep(1)} /> : null} 61 + </div> 62 + </div> 63 + 64 + <CaptionPanel 65 + alt={props.selectedImage?.alt} 66 + authorHandle={props.authorHandle} 67 + authorHref={props.authorHref} 68 + expanded={props.expanded} 69 + postText={props.postText} 70 + showToggle={props.showPostTextToggle} 71 + onToggleExpand={props.onToggleExpand} /> 72 + </Motion.div> 73 + ); 74 + } 75 + 76 + type ToolbarProps = { 77 + current: number; 78 + disabled: boolean; 79 + total: number; 80 + pending: boolean; 81 + onDownload: () => void; 82 + onClose: () => void; 83 + }; 84 + 85 + function Toolbar(props: ToolbarProps) { 86 + return ( 87 + <div class="flex min-h-10 items-center justify-between gap-3"> 88 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.current} / {props.total}</p> 89 + <div class="flex items-center gap-2"> 90 + <button 91 + type="button" 92 + disabled={props.disabled} 93 + class="inline-flex items-center gap-1.5 rounded-full border-0 bg-surface-container-high px-3 py-1.5 text-xs text-on-surface transition duration-150 ease-out hover:bg-surface-bright disabled:cursor-wait disabled:opacity-65" 94 + aria-label="Download image" 95 + onClick={() => props.onDownload()}> 96 + <Icon 97 + aria-hidden="true" 98 + iconClass={props.pending ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line"} /> 99 + <span>{props.pending ? "Saving..." : "Download"}</span> 100 + </button> 101 + <button 102 + type="button" 103 + class="inline-flex h-9 w-9 items-center justify-center rounded-full border-0 bg-surface-container-high text-on-surface-variant transition hover:bg-surface-bright hover:text-on-surface" 104 + aria-label="Close gallery" 105 + onClick={() => props.onClose()}> 106 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 107 + </button> 108 + </div> 109 + </div> 110 + ); 111 + } 112 + 113 + function ArrowButton(props: { direction: "left" | "right"; onClick: () => void }) { 114 + return ( 115 + <button 116 + type="button" 117 + class="absolute top-1/2 z-2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border-0 bg-surface-container-high/88 text-on-surface transition hover:bg-surface-container-highest" 118 + classList={{ "left-1": props.direction === "left", "right-1": props.direction === "right" }} 119 + aria-label={props.direction === "left" ? "Previous image" : "Next image"} 120 + onClick={() => props.onClick()}> 121 + <Icon 122 + aria-hidden="true" 123 + iconClass={props.direction === "left" ? "i-ri-arrow-left-s-line" : "i-ri-arrow-right-s-line"} /> 124 + </button> 125 + ); 126 + } 127 + 128 + type CaptionPanelProps = { 129 + alt?: string; 130 + authorHandle?: string; 131 + authorHref?: string; 132 + expanded: boolean; 133 + postText?: string; 134 + showToggle: boolean; 135 + onToggleExpand: () => void; 136 + }; 137 + 138 + function CaptionPanel(props: CaptionPanelProps) { 139 + const label = () => props.expanded ? "Show less" : "Show more"; 140 + return ( 141 + <div class="relative z-1 grid gap-2 rounded-2xl bg-surface-container-high/86 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 142 + <Show when={props.alt}>{(alt) => <p class="m-0 text-sm leading-normal text-on-surface">{alt()}</p>}</Show> 143 + <Show when={(props.postText ?? "").trim().length > 0}> 144 + <div class="grid items-start gap-1"> 145 + <p class="m-0 text-xs leading-normal text-on-surface-variant" classList={{ "line-clamp-2": !props.expanded }}> 146 + {props.postText} 147 + </p> 148 + <Show when={props.showToggle}> 149 + <button 150 + type="button" 151 + class="justify-self-start border-0 bg-transparent p-0 text-xs text-primary transition hover:text-on-surface" 152 + onClick={() => props.onToggleExpand()}> 153 + {label()} 154 + </button> 155 + </Show> 156 + </div> 157 + </Show> 158 + <Show when={props.authorHandle && props.authorHref}> 159 + <a 160 + class="justify-self-start text-xs text-primary no-underline transition hover:text-on-surface" 161 + href={`#${props.authorHref}`} 162 + title={props.authorHandle}> 163 + {props.authorHandle} 164 + </a> 165 + </Show> 166 + </div> 167 + ); 168 + } 169 + 12 170 type ImageGalleryProps = { 13 171 authorHandle?: string; 14 172 authorHref?: string; ··· 120 278 : await MediaController.downloadImage(currentImage); 121 279 queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 122 280 } catch (error) { 123 - queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); 281 + queueNotice({ kind: "error", message: toDownloadErrorMessage(error, "Couldn't save this image right now.") }); 124 282 } finally { 125 283 setDownloadPending(false); 126 284 } ··· 138 296 <Portal> 139 297 <Presence> 140 298 <Show when={props.open}> 299 + {/* FIXME: this needs to be simplified */} 141 300 <GalleryOverlay 142 301 authorHandle={props.authorHandle} 143 302 authorHref={props.authorHref} ··· 160 319 </Portal> 161 320 ); 162 321 } 163 - 164 - function GalleryOverlay( 165 - props: { 166 - authorHandle?: string; 167 - authorHref?: string; 168 - downloadPending: boolean; 169 - expanded: boolean; 170 - hasManyImages: boolean; 171 - imageCount: number; 172 - index: number; 173 - postText?: string; 174 - selectedImage: GalleryImage | null; 175 - showPostTextToggle: boolean; 176 - onClose: () => void; 177 - onDownload: () => void; 178 - onStep: (offset: -1 | 1) => void; 179 - onToggleExpand: () => void; 180 - }, 181 - ) { 182 - return ( 183 - <Motion.div 184 - class="fixed inset-0 z-60 grid min-h-0 grid-rows-[1fr_auto] bg-surface-container-highest/70 p-4 backdrop-blur-[20px] max-[760px]:p-3" 185 - initial={{ opacity: 0 }} 186 - animate={{ opacity: 1 }} 187 - exit={{ opacity: 0 }} 188 - transition={{ duration: 0.18 }}> 189 - <button 190 - type="button" 191 - aria-label="Close gallery" 192 - class="absolute inset-0 border-0 bg-transparent" 193 - onClick={() => props.onClose()} /> 194 - 195 - <div class="relative z-1 grid min-h-0"> 196 - <Toolbar 197 - current={props.hasManyImages ? props.index + 1 : 1} 198 - disabled={props.downloadPending} 199 - total={props.hasManyImages ? props.imageCount : 1} 200 - onDownload={props.onDownload} 201 - onClose={props.onClose} 202 - pending={props.downloadPending} /> 203 - 204 - <div class="relative grid min-h-0 place-items-center px-14 py-3 max-[760px]:px-11"> 205 - <img 206 - class="max-h-full max-w-full rounded-2xl object-contain shadow-[0_30px_60px_rgba(0,0,0,0.35)]" 207 - src={props.selectedImage?.fullsize ?? props.selectedImage?.thumb} 208 - alt={props.selectedImage?.alt ?? ""} /> 209 - 210 - {props.hasManyImages ? <ArrowButton direction="left" onClick={() => props.onStep(-1)} /> : null} 211 - {props.hasManyImages ? <ArrowButton direction="right" onClick={() => props.onStep(1)} /> : null} 212 - </div> 213 - </div> 214 - 215 - <CaptionPanel 216 - alt={props.selectedImage?.alt} 217 - authorHandle={props.authorHandle} 218 - authorHref={props.authorHref} 219 - expanded={props.expanded} 220 - postText={props.postText} 221 - showToggle={props.showPostTextToggle} 222 - onToggleExpand={props.onToggleExpand} /> 223 - </Motion.div> 224 - ); 225 - } 226 - 227 - function Toolbar( 228 - props: { 229 - current: number; 230 - disabled: boolean; 231 - total: number; 232 - pending: boolean; 233 - onDownload: () => void; 234 - onClose: () => void; 235 - }, 236 - ) { 237 - return ( 238 - <div class="flex min-h-10 items-center justify-between gap-3"> 239 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.current} / {props.total}</p> 240 - <div class="flex items-center gap-2"> 241 - <button 242 - type="button" 243 - disabled={props.disabled} 244 - class="inline-flex items-center gap-1.5 rounded-full border-0 bg-surface-container-high px-3 py-1.5 text-xs text-on-surface transition duration-150 ease-out hover:bg-surface-bright disabled:cursor-wait disabled:opacity-65" 245 - aria-label="Download image" 246 - onClick={() => props.onDownload()}> 247 - <Icon 248 - aria-hidden="true" 249 - iconClass={props.pending ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line"} /> 250 - <span>{props.pending ? "Saving..." : "Download"}</span> 251 - </button> 252 - <button 253 - type="button" 254 - class="inline-flex h-9 w-9 items-center justify-center rounded-full border-0 bg-surface-container-high text-on-surface-variant transition hover:bg-surface-bright hover:text-on-surface" 255 - aria-label="Close gallery" 256 - onClick={() => props.onClose()}> 257 - <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 258 - </button> 259 - </div> 260 - </div> 261 - ); 262 - } 263 - 264 - function ArrowButton(props: { direction: "left" | "right"; onClick: () => void }) { 265 - return ( 266 - <button 267 - type="button" 268 - class="absolute top-1/2 z-2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border-0 bg-surface-container-high/88 text-on-surface transition hover:bg-surface-container-highest" 269 - classList={{ "left-1": props.direction === "left", "right-1": props.direction === "right" }} 270 - aria-label={props.direction === "left" ? "Previous image" : "Next image"} 271 - onClick={() => props.onClick()}> 272 - <Icon 273 - aria-hidden="true" 274 - iconClass={props.direction === "left" ? "i-ri-arrow-left-s-line" : "i-ri-arrow-right-s-line"} /> 275 - </button> 276 - ); 277 - } 278 - 279 - function CaptionPanel( 280 - props: { 281 - alt?: string; 282 - authorHandle?: string; 283 - authorHref?: string; 284 - expanded: boolean; 285 - postText?: string; 286 - showToggle: boolean; 287 - onToggleExpand: () => void; 288 - }, 289 - ) { 290 - const label = () => props.expanded ? "Show less" : "Show more"; 291 - return ( 292 - <div class="relative z-1 grid gap-2 rounded-2xl bg-surface-container-high/86 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 293 - <Show when={props.alt}>{(alt) => <p class="m-0 text-sm leading-normal text-on-surface">{alt()}</p>}</Show> 294 - <Show when={(props.postText ?? "").trim().length > 0}> 295 - <div class="grid items-start gap-1"> 296 - <p class="m-0 text-xs leading-normal text-on-surface-variant" classList={{ "line-clamp-2": !props.expanded }}> 297 - {props.postText} 298 - </p> 299 - <Show when={props.showToggle}> 300 - <button 301 - type="button" 302 - class="justify-self-start border-0 bg-transparent p-0 text-xs text-primary transition hover:text-on-surface" 303 - onClick={() => props.onToggleExpand()}> 304 - {label()} 305 - </button> 306 - </Show> 307 - </div> 308 - </Show> 309 - <Show when={props.authorHandle && props.authorHref}> 310 - <a 311 - class="justify-self-start text-xs text-primary no-underline transition hover:text-on-surface" 312 - href={`#${props.authorHref}`} 313 - title={props.authorHandle}> 314 - {props.authorHandle} 315 - </a> 316 - </Show> 317 - </div> 318 - ); 319 - } 320 - 321 - function filenameFromPath(path: string) { 322 - const parts = path.split(/[/\\]/u); 323 - return parts.at(-1) || "downloaded file"; 324 - } 325 - 326 - function toDownloadErrorMessage(error: unknown) { 327 - const message = normalizeError(error); 328 - if (/download folder|writable|save|directory|exists/iu.test(message)) { 329 - return "Couldn't save — check that the download folder exists."; 330 - } 331 - 332 - return "Couldn't save this image right now."; 333 - }
+2 -2
src/components/feeds/PostCard.tsx
··· 9 9 import { ModerationController } from "$/lib/api/moderation"; 10 10 import { 11 11 buildPublicPostUrl, 12 - formatRelativeTime, 13 12 getAvatarLabel, 14 13 getDisplayName, 15 14 getPostCreatedAt, 16 15 getPostFacets, 17 16 getPostText, 18 17 hasKnownThreadContext, 19 - isReplyItem, 20 18 } from "$/lib/feeds"; 19 + import { isReplyItem } from "$/lib/feeds/type-guards"; 21 20 import { collectModerationLabels } from "$/lib/moderation"; 22 21 import type { PostEngagementTab } from "$/lib/post-engagement-routes"; 23 22 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; ··· 30 29 PostView, 31 30 RichTextFacet, 32 31 } from "$/lib/types"; 32 + import { formatRelativeTime } from "$/lib/utils/text"; 33 33 import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; 34 34 import * as logger from "@tauri-apps/plugin-log"; 35 35 import { createMemo, createSignal, type ParentProps, Show, splitProps } from "solid-js";
+137 -50
src/components/feeds/embeds/ContentEmbed.tsx
··· 1 1 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 2 - import { getQuotedAuthor, getQuotedHref, getQuotedText, getQuotedUri, postRkeyFromUri } from "$/lib/feeds"; 2 + import { normalizeEmbed, postRkeyFromUri } from "$/lib/feeds"; 3 + import type { NormalizedEmbed, QuotedRecordPresentation } from "$/lib/feeds"; 4 + import { isNormalizedEmbed } from "$/lib/feeds/type-guards"; 3 5 import { buildPostRoute } from "$/lib/post-routes"; 4 - import type { EmbedView, ImagesEmbedView, PostView } from "$/lib/types"; 5 - import { createMemo, Match, Show, Switch } from "solid-js"; 6 + import type { EmbedView, PostView } from "$/lib/types"; 7 + import { createMemo, For, type JSX, Show } from "solid-js"; 6 8 import { ExternalEmbed } from "./ExternalEmbed"; 7 9 import { ImageEmbed } from "./ImageEmbed"; 8 10 import { VideoEmbed } from "./VideoEmbed"; 9 11 10 - export function EmbedContent(props: { embed: EmbedView; onOpenPost?: (uri: string) => void; post: PostView }) { 11 - const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 12 - const media = () => ("media" in props.embed ? props.embed.media : null); 13 - const quotedUri = createMemo(() => getQuotedUri(props.embed)); 12 + const MAX_EMBED_DEPTH = 3; 13 + 14 + function RecognizedEmbedNotice(props: { message: string }) { 15 + return ( 16 + <div class="ui-input-strong rounded-2xl p-4 shadow-(--inset-shadow)"> 17 + <p class="m-0 text-sm leading-[1.55] text-on-surface-variant">{props.message}</p> 18 + </div> 19 + ); 20 + } 21 + 22 + function RenderQuotedPreview( 23 + props: { quoted: QuotedRecordPresentation; depth: number; post: PostView; onOpenPost?: (uri: string) => void }, 24 + ) { 25 + const quotedExternalHref = createMemo(() => props.quoted.href); 26 + const quotedUri = createMemo(() => props.quoted.uri); 14 27 const quotedInternalHref = createMemo(() => { 15 28 const uri = quotedUri(); 16 29 return uri ? `#${buildPostRoute(uri)}` : null; 17 30 }); 18 - const quotedExternalHref = createMemo(() => getQuotedHref(props.embed)); 31 + 19 32 const openQuotedPost = () => { 20 33 const uri = quotedUri(); 21 34 if (!uri || !props.onOpenPost) { ··· 25 38 props.onOpenPost(uri); 26 39 }; 27 40 41 + const quotedPostForEmbeds = createMemo<PostView | null>(() => { 42 + const value = props.quoted; 43 + if (value.kind !== "post" || !value.uri) { 44 + return null; 45 + } 46 + 47 + return { 48 + author: value.author ?? props.post.author, 49 + cid: "", 50 + indexedAt: props.post.indexedAt, 51 + record: { createdAt: props.post.indexedAt, facets: value.facets ?? [], text: value.text ?? "" }, 52 + uri: value.uri, 53 + }; 54 + }); 55 + 28 56 return ( 29 - <Switch> 30 - <Match when={props.embed.$type === "app.bsky.embed.images#view"}> 31 - <ImageEmbed embed={props.embed as ImagesEmbedView} post={props.post} /> 32 - </Match> 33 - <Match when={props.embed.$type === "app.bsky.embed.external#view"}> 34 - <ExternalEmbed 35 - description={(props.embed as { external: { description?: string } }).external.description} 36 - thumb={(props.embed as { external: { thumb?: string } }).external.thumb} 37 - title={(props.embed as { external: { title?: string } }).external.title} 38 - uri={(props.embed as { external: { uri?: string } }).external.uri} /> 39 - </Match> 40 - <Match when={props.embed.$type === "app.bsky.embed.video#view"}> 41 - <VideoEmbed 42 - alt={(props.embed as { alt?: string }).alt} 43 - aspectRatio={(props.embed as { aspectRatio?: { height: number; width: number } }).aspectRatio} 44 - downloadFilename={postRkey() ?? undefined} 45 - playlist={(props.embed as { playlist?: string }).playlist} 46 - thumbnail={(props.embed as { thumbnail?: string }).thumbnail} /> 47 - </Match> 48 - <Match when={props.embed.$type === "app.bsky.embed.record#view"}> 49 - <QuotedPostPreview 50 - author={getQuotedAuthor(props.embed)} 51 - href={quotedUri() && props.onOpenPost ? null : quotedInternalHref() ?? quotedExternalHref()} 52 - onOpenPost={quotedUri() && props.onOpenPost ? openQuotedPost : undefined} 53 - text={getQuotedText(props.embed)} 54 - title="Quoted post" /> 55 - </Match> 56 - <Match when={props.embed.$type === "app.bsky.embed.recordWithMedia#view"}> 57 - <div class="grid gap-3"> 58 - <Show when={media()}> 59 - {(current) => ( 60 - <EmbedContent embed={current() as EmbedView} onOpenPost={props.onOpenPost} post={props.post} /> 61 - )} 57 + <> 58 + <QuotedPostPreview 59 + author={props.quoted.author} 60 + emptyText={props.quoted.emptyText} 61 + facets={props.quoted.facets} 62 + href={quotedUri() && props.onOpenPost ? null : quotedInternalHref() ?? quotedExternalHref()} 63 + onOpenPost={quotedUri() && props.onOpenPost ? openQuotedPost : undefined} 64 + text={props.quoted.text} 65 + title={props.quoted.title} /> 66 + <Show when={quotedPostForEmbeds()}> 67 + {(quotedPost) => ( 68 + <Show when={props.quoted.normalizedEmbeds.length > 0}> 69 + <div class="mt-3 grid gap-2"> 70 + <For each={props.quoted.normalizedEmbeds}> 71 + {(embed) => ( 72 + <EmbedContent 73 + depth={props.depth + 1} 74 + embed={embed} 75 + onOpenPost={props.onOpenPost} 76 + post={quotedPost()} /> 77 + )} 78 + </For> 79 + </div> 62 80 </Show> 63 - <QuotedPostPreview 64 - author={getQuotedAuthor(props.embed)} 65 - href={quotedUri() && props.onOpenPost ? null : quotedInternalHref() ?? quotedExternalHref()} 66 - onOpenPost={quotedUri() && props.onOpenPost ? openQuotedPost : undefined} 67 - text={getQuotedText(props.embed)} 68 - title="Quoted post" /> 69 - </div> 70 - </Match> 71 - </Switch> 81 + )} 82 + </Show> 83 + </> 84 + ); 85 + } 86 + 87 + export function EmbedContent( 88 + props: { depth?: number; embed: EmbedView | NormalizedEmbed; onOpenPost?: (uri: string) => void; post: PostView }, 89 + ) { 90 + const depth = createMemo(() => props.depth ?? 0); 91 + const normalized = createMemo<NormalizedEmbed>(() => 92 + isNormalizedEmbed(props.embed) ? props.embed : normalizeEmbed(props.embed, { depth: depth(), source: "top" }) 72 93 ); 94 + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 95 + 96 + const content = createMemo<JSX.Element | null>(() => { 97 + const embed = normalized(); 98 + if (depth() >= MAX_EMBED_DEPTH || embed.meta.depthLimited) { 99 + return <RecognizedEmbedNotice message="Embed nesting limit reached." />; 100 + } 101 + if (embed.meta.cycle) { 102 + return <RecognizedEmbedNotice message="Embed cycle detected." />; 103 + } 104 + 105 + switch (embed.kind) { 106 + case "images": { 107 + return <ImageEmbed embed={embed.embed} post={props.post} />; 108 + } 109 + case "external": { 110 + return ( 111 + <ExternalEmbed 112 + description={embed.embed.external.description} 113 + thumb={embed.embed.external.thumb} 114 + title={embed.embed.external.title} 115 + uri={embed.embed.external.uri} /> 116 + ); 117 + } 118 + case "video": { 119 + return ( 120 + <VideoEmbed 121 + alt={embed.embed.alt} 122 + aspectRatio={embed.embed.aspectRatio} 123 + downloadFilename={postRkey() ?? undefined} 124 + playlist={embed.embed.playlist} 125 + thumbnail={embed.embed.thumbnail} /> 126 + ); 127 + } 128 + case "record": { 129 + return ( 130 + <RenderQuotedPreview depth={depth()} post={props.post} quoted={embed.quoted} onOpenPost={props.onOpenPost} /> 131 + ); 132 + } 133 + case "recordWithMedia": { 134 + return ( 135 + <div class="grid gap-3"> 136 + <Show when={embed.media}> 137 + {(mediaEmbed) => ( 138 + <EmbedContent 139 + depth={depth() + 1} 140 + embed={mediaEmbed()} 141 + onOpenPost={props.onOpenPost} 142 + post={props.post} /> 143 + )} 144 + </Show> 145 + <RenderQuotedPreview 146 + depth={depth()} 147 + post={props.post} 148 + quoted={embed.quoted} 149 + onOpenPost={props.onOpenPost} /> 150 + </div> 151 + ); 152 + } 153 + default: { 154 + return null; 155 + } 156 + } 157 + }); 158 + 159 + return <>{content()}</>; 73 160 }
+1 -1
src/components/feeds/embeds/ExternalEmbed.tsx
··· 3 3 export function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 4 4 return ( 5 5 <a 6 - class="grid min-w-0 gap-3 overflow-hidden rounded-2xl bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 6 + class="ui-input-strong grid min-w-0 gap-3 overflow-hidden rounded-2xl p-3 text-inherit no-underline shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright" 7 7 href={props.uri} 8 8 rel="noreferrer" 9 9 target="_blank"
+4 -17
src/components/feeds/embeds/ImageEmbed.tsx
··· 5 5 import { getPostText, postRkeyFromUri } from "$/lib/feeds"; 6 6 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 7 7 import type { ImagesEmbedView, PostView } from "$/lib/types"; 8 - import { formatHandle, normalizeError } from "$/lib/utils/text"; 8 + import { formatHandle } from "$/lib/utils/text"; 9 9 import { revealItemInDir } from "@tauri-apps/plugin-opener"; 10 10 import { createMemo, createSignal, For, onCleanup } from "solid-js"; 11 + import { filenameFromPath, toDownloadErrorMessage } from "./shared"; 11 12 12 13 function buildImageFilename(postRkey: string | null, imageCount: number, imageIndex: number | null) { 13 14 if (!postRkey) { ··· 19 20 } 20 21 21 22 return postRkey; 22 - } 23 - 24 - function filenameFromPath(path: string) { 25 - const parts = path.split(/[/\\]/u); 26 - return parts.at(-1) || "downloaded file"; 27 - } 28 - 29 - function toDownloadErrorMessage(error: unknown) { 30 - const message = normalizeError(error); 31 - if (/download folder|writable|save|directory|exists/iu.test(message)) { 32 - return "Couldn't save — check that the download folder exists."; 33 - } 34 - 35 - return "Couldn't save this image right now."; 36 23 } 37 24 38 25 export function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { ··· 118 105 119 106 queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 120 107 } catch (error) { 121 - queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); 108 + queueNotice({ kind: "error", message: toDownloadErrorMessage(error, "Couldn't save this image right now.") }); 122 109 } finally { 123 110 setDownloadPending(false); 124 111 } ··· 131 118 {(image, index) => ( 132 119 <button 133 120 type="button" 134 - class="overflow-hidden rounded-[1.2rem] border-0 bg-black/30 p-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 121 + class="ui-input-strong overflow-hidden rounded-[1.2rem] border-0 p-0 shadow-(--inset-shadow)" 135 122 onClick={(event) => openGallery(index(), event)} 136 123 onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb, index())}> 137 124 <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} />
+15 -28
src/components/feeds/embeds/VideoEmbed.tsx
··· 7 7 import { revealItemInDir } from "@tauri-apps/plugin-opener"; 8 8 import { createMemo, createSignal, onCleanup, Show } from "solid-js"; 9 9 import type { JSX } from "solid-js"; 10 + import { filenameFromPath, toDownloadErrorMessage } from "./shared"; 10 11 11 12 type VideoEmbedProps = { 12 13 alt?: string; ··· 206 207 return { "aspect-ratio": ratio }; 207 208 } 208 209 209 - function filenameFromPath(path: string) { 210 - const parts = path.split(/[/\\]/u); 211 - return parts.at(-1) || "downloaded file"; 212 - } 213 - 214 210 function isM3u8Url(value: string) { 215 211 return /\.m3u8($|[?#])/iu.test(value); 216 212 } 217 213 218 - function toDownloadErrorMessage(error: unknown, fallback: string) { 219 - const message = normalizeError(error); 220 - if (/download folder|writable|save|directory|exists/iu.test(message)) { 221 - return "Couldn't save — check that the download folder exists."; 222 - } 223 - 224 - return fallback; 225 - } 226 - 227 214 function toPlaybackMessage(error: unknown) { 228 215 const message = normalizeError(error); 229 216 if (!message || message === "AbortError") { ··· 233 220 return "Couldn't start playback right now."; 234 221 } 235 222 236 - function VideoPlayerStage( 237 - props: { 238 - aspectRatio: string; 239 - hasPlaylist: boolean; 240 - hlsLoading: boolean; 241 - poster?: string; 242 - started: boolean; 243 - onPlay: () => void; 244 - onVideoRef: (element: HTMLVideoElement) => void; 245 - }, 246 - ) { 223 + type VideoPlayerStageProps = { 224 + aspectRatio: string; 225 + hasPlaylist: boolean; 226 + hlsLoading: boolean; 227 + poster?: string; 228 + started: boolean; 229 + onPlay: () => void; 230 + onVideoRef: (element: HTMLVideoElement) => void; 231 + }; 232 + 233 + function VideoPlayerStage(props: VideoPlayerStageProps) { 247 234 return ( 248 235 <div 249 - class="relative w-full overflow-hidden rounded-[1.2rem] bg-black/40 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 236 + class="ui-input-strong relative w-full overflow-hidden rounded-[1.2rem] shadow-(--inset-shadow)" 250 237 style={containerStyle(props.aspectRatio)}> 251 238 <video 252 239 ref={(element) => props.onVideoRef(element)} ··· 269 256 <button 270 257 type="button" 271 258 aria-label="Play video" 272 - class="absolute inset-0 grid place-items-center border-0 bg-black/35 backdrop-blur-[2px] transition hover:bg-black/45" 259 + class="absolute inset-0 grid place-items-center border-0 bg-surface-container-highest/70 backdrop-blur-[2px] transition hover:bg-surface-container-highest/85" 273 260 onClick={() => props.onPlay()}> 274 261 <span class="grid h-16 w-16 place-items-center rounded-full bg-primary/88 text-on-primary-fixed shadow-[0_16px_30px_rgba(0,0,0,0.32)]"> 275 262 <Icon aria-hidden="true" iconClass="i-ri-play-fill text-3xl" /> ··· 280 267 281 268 function LoadingBadge() { 282 269 return ( 283 - <div class="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full bg-black/50 px-3 py-1.5 text-xs text-on-surface"> 270 + <div class="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full bg-surface-container-high/90 px-3 py-1.5 text-xs text-on-surface"> 284 271 <Icon aria-hidden="true" iconClass="i-ri-loader-4-line animate-spin" /> 285 272 <span>Loading stream</span> 286 273 </div>
+15
src/components/feeds/embeds/shared.ts
··· 1 + import { normalizeError } from "$/lib/utils/text"; 2 + 3 + export function filenameFromPath(path: string) { 4 + const parts = path.split(/[/\\]/u); 5 + return parts.at(-1) || "downloaded file"; 6 + } 7 + 8 + export function toDownloadErrorMessage(error: unknown, fallback: string) { 9 + const message = normalizeError(error); 10 + if (/download folder|writable|save|directory|exists/iu.test(message)) { 11 + return "Couldn't save — check that the download folder exists."; 12 + } 13 + 14 + return fallback; 15 + }
+1 -1
src/components/feeds/hooks/useFeedWorkspaceController.ts
··· 8 8 extractHashtags, 9 9 getFeedName, 10 10 getReplyRootPost, 11 - isThreadViewPost, 12 11 patchFeedItems, 13 12 toStrongRef, 14 13 } from "$/lib/feeds"; 14 + import { isThreadViewPost } from "$/lib/feeds/type-guards"; 15 15 import type { PostEngagementTab } from "$/lib/post-engagement-routes"; 16 16 import type { 17 17 ActiveSession,
+175
src/components/feeds/tests/PostCard.test.tsx
··· 282 282 expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/quoted"); 283 283 }); 284 284 285 + it("renders quoted post image and video embeds from the quoted record", () => { 286 + render(() => ( 287 + <PostCard 288 + post={{ 289 + ...createPost(), 290 + embed: { 291 + $type: "app.bsky.embed.record#view", 292 + record: { 293 + $type: "app.bsky.embed.record#viewRecord", 294 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 295 + embeds: [ 296 + { 297 + $type: "app.bsky.embed.images#view", 298 + images: [{ alt: "Quoted image", fullsize: "https://cdn.example.com/quoted-image.png" }], 299 + }, 300 + { 301 + $type: "app.bsky.embed.video#view", 302 + alt: "Quoted clip", 303 + playlist: "https://cdn.example.com/quoted-video.m3u8", 304 + thumbnail: "https://cdn.example.com/quoted-video-thumb.jpg", 305 + }, 306 + ], 307 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 308 + value: { text: "Quoted body with media" }, 309 + }, 310 + }, 311 + }} /> 312 + )); 313 + 314 + expect(screen.getByAltText("Quoted image")).toHaveAttribute("src", "https://cdn.example.com/quoted-image.png"); 315 + expect(screen.getByRole("button", { name: "Play video" })).toBeInTheDocument(); 316 + expect(screen.getByText("Quoted clip")).toBeInTheDocument(); 317 + }); 318 + 319 + it("renders quoted external card embeds from the quoted record", () => { 320 + render(() => ( 321 + <PostCard 322 + post={{ 323 + ...createPost(), 324 + embed: { 325 + $type: "app.bsky.embed.record#view", 326 + record: { 327 + $type: "app.bsky.embed.record#viewRecord", 328 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 329 + embeds: [{ 330 + $type: "app.bsky.embed.external#view", 331 + external: { 332 + description: "Deep dive", 333 + title: "External article", 334 + uri: "https://example.com/article", 335 + }, 336 + }], 337 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 338 + value: { text: "Quoted body with external card" }, 339 + }, 340 + }, 341 + }} /> 342 + )); 343 + 344 + expect(screen.getByRole("link", { name: /external article/i })).toHaveAttribute("href", "https://example.com/article"); 345 + }); 346 + 347 + it("renders feed generator record embeds with feed metadata and external links", () => { 348 + const onOpenThread = vi.fn(); 349 + render(() => ( 350 + <PostCard 351 + post={{ 352 + ...createPost(), 353 + embed: { 354 + $type: "app.bsky.embed.record#view", 355 + record: { 356 + $type: "app.bsky.feed.defs#generatorView", 357 + creator: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 358 + description: "Prioritizes high-signal posts.", 359 + displayName: "For You", 360 + uri: "at://did:plc:alice/app.bsky.feed.generator/for-you", 361 + }, 362 + }, 363 + }} 364 + onOpenThread={onOpenThread} /> 365 + )); 366 + 367 + expect(screen.getByText("Embedded feed")).toBeInTheDocument(); 368 + expect(screen.getByRole("link", { name: /for you/i })).toHaveAttribute( 369 + "href", 370 + "https://bsky.app/profile/alice.test/feed/for-you", 371 + ); 372 + fireEvent.click(screen.getByRole("link", { name: /for you/i })); 373 + expect(onOpenThread).not.toHaveBeenCalled(); 374 + }); 375 + 376 + it("renders list record embeds with list metadata and external links", () => { 377 + const onOpenThread = vi.fn(); 378 + render(() => ( 379 + <PostCard 380 + post={{ 381 + ...createPost(), 382 + embed: { 383 + $type: "app.bsky.embed.record#view", 384 + record: { 385 + $type: "app.bsky.graph.defs#listView", 386 + creator: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 387 + name: "Science Curators", 388 + uri: "at://did:plc:alice/app.bsky.graph.list/science-curators", 389 + }, 390 + }, 391 + }} 392 + onOpenThread={onOpenThread} /> 393 + )); 394 + 395 + expect(screen.getByText("Embedded list")).toBeInTheDocument(); 396 + expect(screen.getByRole("link", { name: /science curators/i })).toHaveAttribute( 397 + "href", 398 + "https://bsky.app/profile/alice.test/lists/science-curators", 399 + ); 400 + fireEvent.click(screen.getByRole("link", { name: /science curators/i })); 401 + expect(onOpenThread).not.toHaveBeenCalled(); 402 + }); 403 + 404 + it("ignores non-media payloads inside recordWithMedia and avoids duplicate quote previews", () => { 405 + render(() => ( 406 + <PostCard 407 + post={{ 408 + ...createPost(), 409 + embed: { 410 + $type: "app.bsky.embed.recordWithMedia#view", 411 + media: { 412 + $type: "app.bsky.embed.record#view", 413 + record: { 414 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 415 + uri: "at://did:plc:bob/app.bsky.feed.post/nested", 416 + value: { text: "Nested record" }, 417 + }, 418 + }, 419 + record: { 420 + $type: "app.bsky.embed.record#view", 421 + record: { 422 + author: { did: "did:plc:carol", handle: "carol.test", displayName: "Carol" }, 423 + uri: "at://did:plc:carol/app.bsky.feed.post/outer", 424 + value: { text: "Outer quote" }, 425 + }, 426 + }, 427 + }, 428 + }} /> 429 + )); 430 + 431 + expect(screen.getByText("Outer quote")).toBeInTheDocument(); 432 + expect(screen.queryByText("Nested record")).not.toBeInTheDocument(); 433 + expect(screen.getAllByText("Quoted post")).toHaveLength(1); 434 + expect(screen.queryByText("This recognized media type is not valid in recordWithMedia.media.")).not.toBeInTheDocument(); 435 + }); 436 + 437 + it("does not show unsupported embed fallback cards for custom quoted embeds", () => { 438 + render(() => ( 439 + <PostCard 440 + post={{ 441 + ...createPost(), 442 + embed: { 443 + $type: "app.bsky.embed.record#view", 444 + record: { 445 + $type: "app.bsky.embed.record#viewRecord", 446 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 447 + embeds: [{ $type: "app.bsky.embed.unsupported#view" }], 448 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 449 + value: { text: "Quoted body" }, 450 + }, 451 + }, 452 + }} /> 453 + )); 454 + 455 + expect(screen.queryByText("Unsupported custom embed type.")).not.toBeInTheDocument(); 456 + expect(screen.queryByText("View JSON")).not.toBeInTheDocument(); 457 + expect(screen.getByText("Quoted body")).toBeInTheDocument(); 458 + }); 459 + 285 460 it("renders inline video embed player for video attachments", () => { 286 461 render(() => ( 287 462 <PostCard
+2 -1
src/components/messages/MessagesPanel.tsx
··· 3 3 import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 4 4 import { useAppSession } from "$/contexts/app-session"; 5 5 import { ConvoController } from "$/lib/api/conversations"; 6 - import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 6 + import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 7 import { collectModerationLabels } from "$/lib/moderation"; 8 8 import type { ConvoView, DeletedMessageView, MessageView } from "$/lib/types"; 9 + import { formatRelativeTime } from "$/lib/utils/text"; 9 10 import { normalizeError } from "$/lib/utils/text"; 10 11 import * as logger from "@tauri-apps/plugin-log"; 11 12 import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
+2 -1
src/components/notifications/NotificationItem.tsx
··· 3 3 import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay"; 4 4 import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 5 5 import { Icon } from "$/components/shared/Icon"; 6 - import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 6 + import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 7 import { collectModerationLabels } from "$/lib/moderation"; 8 8 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 9 9 import type { NotificationReason, NotificationView } from "$/lib/types"; 10 + import { formatRelativeTime } from "$/lib/utils/text"; 10 11 import { createMemo, Show } from "solid-js"; 11 12 import { 12 13 notificationBodyTargetUri,
+2 -1
src/components/notifications/NotificationsPanel.tsx
··· 5 5 import { useAppSession } from "$/contexts/app-session"; 6 6 import { listNotifications, updateSeen } from "$/lib/api/notifications"; 7 7 import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 8 - import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 8 + import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 9 9 import { collectModerationLabels } from "$/lib/moderation"; 10 10 import { buildPostRoute } from "$/lib/post-routes"; 11 11 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 12 12 import type { ListNotificationsResponse, NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types"; 13 + import { formatRelativeTime } from "$/lib/utils/text"; 13 14 import { normalizeError } from "$/lib/utils/text"; 14 15 import { listen } from "@tauri-apps/api/event"; 15 16 import * as logger from "@tauri-apps/plugin-log";
+2 -1
src/components/posts/PostPanel.tsx
··· 2 2 import { Icon } from "$/components/shared/Icon"; 3 3 import { useAppSession } from "$/contexts/app-session"; 4 4 import { FeedController } from "$/lib/api/feeds"; 5 - import { isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds"; 5 + import { patchThreadNode } from "$/lib/feeds"; 6 + import { isBlockedNode, isNotFoundNode, isThreadViewPost } from "$/lib/feeds/type-guards"; 6 7 import type { PostView, ThreadNode, ThreadViewPost } from "$/lib/types"; 7 8 import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 8 9 import { createStore } from "solid-js/store";
+2 -1
src/components/posts/ThreadDrawer.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { useAppSession } from "$/contexts/app-session"; 3 3 import { FeedController } from "$/lib/api/feeds"; 4 - import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds"; 4 + import { findRootPost, patchThreadNode } from "$/lib/feeds"; 5 + import { isBlockedNode, isNotFoundNode, isThreadViewPost } from "$/lib/feeds/type-guards"; 5 6 import { useNavigationHistory } from "$/lib/navigation-history"; 6 7 import type { PostView, ThreadNode } from "$/lib/types"; 7 8 import { createEffect, createMemo, For, Match, onCleanup, Show, splitProps, Switch } from "solid-js";
+1 -1
src/components/saved/SavedPostsPanel.tsx
··· 8 8 import { useAppSession } from "$/contexts/app-session"; 9 9 import { SearchController } from "$/lib/api/search"; 10 10 import type { LocalPostResult, SavedPostSource, SyncStatus } from "$/lib/api/types/search"; 11 - import { formatRelativeTime } from "$/lib/feeds"; 12 11 import { subscribeBookmarkChanged } from "$/lib/post-events"; 13 12 import type { PostView } from "$/lib/types"; 13 + import { formatRelativeTime } from "$/lib/utils/text"; 14 14 import { normalizeError } from "$/lib/utils/text"; 15 15 import * as logger from "@tauri-apps/plugin-log"; 16 16 import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js";
+1 -1
src/components/search/SearchResultCard.tsx
··· 1 - import { formatRelativeTime } from "$/lib/feeds"; 2 1 import { buildProfileRoute } from "$/lib/profile"; 2 + import { formatRelativeTime } from "$/lib/utils/text"; 3 3 import { escapeForRegex } from "$/lib/utils/text"; 4 4 import { createMemo, type JSX, type ParentProps, Show } from "solid-js"; 5 5 import { Icon } from "../shared/Icon";
+1 -1
src/components/search/SyncStatusPanel.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { SearchController } from "$/lib/api/search"; 3 3 import type { SyncStatus } from "$/lib/api/types/search"; 4 - import { formatRelativeTime } from "$/lib/feeds"; 4 + import { formatRelativeTime } from "$/lib/utils/text"; 5 5 import * as logger from "@tauri-apps/plugin-log"; 6 6 import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 7 7 import { Motion, Presence } from "solid-motionone";
+1 -1
src/components/search/hooks/useSearchController.ts
··· 12 12 SearchMode, 13 13 SyncStatus, 14 14 } from "$/lib/api/types/search"; 15 - import { formatRelativeTime } from "$/lib/feeds"; 16 15 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 17 16 import { buildSearchRoute, parseSearchRouteState, toLocalDayStartIso, toLocalDayUntilIso } from "$/lib/search-routes"; 18 17 import type { PostSearchFilters, SearchTab } from "$/lib/search-routes"; 19 18 import type { ProfileViewBasic } from "$/lib/types"; 19 + import { formatRelativeTime } from "$/lib/utils/text"; 20 20 import { normalizeError } from "$/lib/utils/text"; 21 21 import { useLocation, useNavigate } from "@solidjs/router"; 22 22 import * as logger from "@tauri-apps/plugin-log";
+54 -34
src/components/shared/QuotedPostPreview.tsx
··· 5 5 import { createMemo, Show } from "solid-js"; 6 6 7 7 function QuotedText(props: { facets?: RichTextFacet[] | null; text: string; truncated: boolean }) { 8 - const hasFacets = createMemo(() => Array.isArray(props.facets) && props.facets.length > 0); 9 - 10 8 return ( 11 9 <Show 12 - when={hasFacets()} 10 + when={props.facets} 13 11 fallback={ 14 12 <Show 15 13 when={props.truncated} ··· 21 19 <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{props.text}</p> 22 20 </Show> 23 21 }> 24 - <div class="mt-2" classList={{ "line-clamp-4": props.truncated }}> 25 - <PostRichText 26 - class="m-0 text-sm leading-[1.55] text-on-secondary-container [&_p]:text-sm [&_p]:leading-[1.55] [&_p]:text-on-secondary-container" 27 - facets={props.facets} 28 - text={props.text} /> 29 - </div> 22 + {facets => ( 23 + <div class="mt-2" classList={{ "line-clamp-4": props.truncated }}> 24 + <PostRichText 25 + class="m-0 text-sm leading-[1.55] text-on-secondary-container [&_p]:text-sm [&_p]:leading-[1.55] [&_p]:text-on-secondary-container" 26 + facets={facets()} 27 + text={props.text} /> 28 + </div> 29 + )} 30 30 </Show> 31 31 ); 32 32 } 33 33 34 + type QuotedPreviewContentProps = { 35 + author: ProfileViewBasic | null; 36 + emptyText?: string; 37 + facets?: RichTextFacet[] | null; 38 + preview: string; 39 + truncated: boolean; 40 + }; 41 + 42 + function QuotedPreviewContent(props: QuotedPreviewContentProps) { 43 + return ( 44 + <> 45 + <Show when={props.author}> 46 + {(value) => { 47 + const author = value(); 48 + return ( 49 + <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface"> 50 + {getDisplayName(author)} 51 + <span class="ml-1 break-all text-xs font-normal text-on-surface-variant"> 52 + {formatHandle(author.handle, author.did)} 53 + </span> 54 + </p> 55 + ); 56 + }} 57 + </Show> 58 + <Show 59 + when={props.preview} 60 + fallback={ 61 + <p class="mt-2 text-sm leading-[1.55] text-on-surface-variant">{props.emptyText ?? "Quoted post"}</p> 62 + }> 63 + {(text) => <QuotedText facets={props.facets} text={text()} truncated={props.truncated} />} 64 + </Show> 65 + </> 66 + ); 67 + } 68 + 34 69 type QuotedPostPreviewProps = { 35 70 author: ProfileViewBasic | null; 36 71 class?: string; 72 + emptyText?: string; 37 73 facets?: RichTextFacet[] | null; 38 74 href?: string | null; 39 75 onOpenPost?: () => void; ··· 55 91 fallback={ 56 92 <Show 57 93 when={props.href} 58 - fallback={<QuotedPreviewContent author={props.author} preview={preview()} truncated={truncated()} />}> 94 + fallback={ 95 + <QuotedPreviewContent 96 + author={props.author} 97 + emptyText={props.emptyText} 98 + preview={preview()} 99 + truncated={truncated()} /> 100 + }> 59 101 {(href) => ( 60 102 <a 61 103 class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-surface-bright" ··· 65 107 onClick={(event) => event.stopPropagation()}> 66 108 <QuotedPreviewContent 67 109 author={props.author} 110 + emptyText={props.emptyText} 68 111 facets={props.facets} 69 112 preview={preview()} 70 113 truncated={truncated()} /> ··· 81 124 }}> 82 125 <QuotedPreviewContent 83 126 author={props.author} 127 + emptyText={props.emptyText} 84 128 facets={props.facets} 85 129 preview={preview()} 86 130 truncated={truncated()} /> ··· 89 133 </div> 90 134 ); 91 135 } 92 - 93 - function QuotedPreviewContent( 94 - props: { author: ProfileViewBasic | null; facets?: RichTextFacet[] | null; preview: string; truncated: boolean }, 95 - ) { 96 - return ( 97 - <> 98 - <Show when={props.author}> 99 - {(author) => ( 100 - <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface"> 101 - {getDisplayName(author())} 102 - <span class="ml-1 break-all text-xs font-normal text-on-surface-variant"> 103 - {formatHandle(author().handle, author().did)} 104 - </span> 105 - </p> 106 - )} 107 - </Show> 108 - <Show 109 - when={props.preview} 110 - fallback={<p class="mt-2 text-sm leading-[1.55] text-on-surface-variant">Quoted post</p>}> 111 - {(text) => <QuotedText facets={props.facets} text={text()} truncated={props.truncated} />} 112 - </Show> 113 - </> 114 - ); 115 - }
+9
src/lib/constants/collections.ts
··· 1 + export const POST_COLLECTION = "app.bsky.feed.post"; 2 + 3 + export const FEED_COLLECTION = "app.bsky.feed.generator"; 4 + 5 + export const LIST_COLLECTION = "app.bsky.graph.list"; 6 + 7 + export const STARTER_PACK_COLLECTION = "app.bsky.graph.starterpack"; 8 + 9 + export const LABELER_COLLECTION = "app.bsky.labeler.service";
+931 -141
src/lib/feeds.ts
··· 1 + import * as logger from "@tauri-apps/plugin-log"; 2 + import { 3 + FEED_COLLECTION, 4 + LABELER_COLLECTION, 5 + LIST_COLLECTION, 6 + POST_COLLECTION, 7 + STARTER_PACK_COLLECTION, 8 + } from "./constants/collections"; 9 + import { 10 + isFeedViewPost, 11 + isProfileViewBasic, 12 + isQuoteEmbed, 13 + isReplyByUnfollowed, 14 + isReplyItem, 15 + isRepostReason, 16 + isThreadNode, 17 + isThreadViewPost, 18 + } from "./feeds/type-guards"; 1 19 import { asArray, asRecord } from "./type-guards"; 2 20 import type { 3 - BlockedPost, 4 21 EmbedView, 5 22 FeedGeneratorsResponse, 6 - FeedReplyNode, 7 23 FeedResponse, 8 24 FeedViewPost, 9 25 FeedViewPrefItem, 10 26 Maybe, 11 - NotFoundPost, 12 27 PostRecord, 13 28 PostView, 14 29 ProfileViewBasic, 30 + RichTextFacet, 15 31 SavedFeedItem, 16 32 StrongRefInput, 17 33 ThreadNode, 18 34 ThreadResponse, 19 - ThreadViewPost, 20 35 } from "./types"; 36 + import { hashString, stringifyUnknown } from "./utils/text"; 21 37 22 38 export const TIMELINE_ROUTE = "/timeline"; 39 + 23 40 const THREAD_QUERY_PARAM = "thread"; 24 41 25 42 function asPostRecord(value: unknown): PostRecord { 26 43 return (asRecord(value) ?? {}) as PostRecord; 27 44 } 28 45 29 - function isProfileViewBasic(value: unknown): boolean { 30 - const record = asRecord(value); 31 - return !!record && typeof record.did === "string" && typeof record.handle === "string"; 32 - } 33 - 34 - function isPostView(value: unknown): value is PostView { 35 - const record = asRecord(value); 36 - const author = asRecord(record?.author); 37 - const postRecord = asRecord(record?.record); 38 - 39 - return !!record 40 - && !!author 41 - && !!postRecord 42 - && typeof record.cid === "string" 43 - && typeof record.indexedAt === "string" 44 - && typeof record.uri === "string" 45 - && isProfileViewBasic(author); 46 - } 47 - 48 - function isFeedViewPost(value: unknown): value is FeedViewPost { 49 - const record = asRecord(value); 50 - return !!record && isPostView(record.post); 51 - } 52 - 53 - function isThreadNode(value: unknown): value is ThreadNode { 54 - const record = asRecord(value); 55 - if (!record || typeof record.$type !== "string") { 56 - return false; 57 - } 58 - 59 - if (record.$type === "app.bsky.feed.defs#threadViewPost") { 60 - return isPostView(record.post); 61 - } 62 - 63 - return record.$type === "app.bsky.feed.defs#blockedPost" || record.$type === "app.bsky.feed.defs#notFoundPost"; 64 - } 65 - 66 46 export function parseFeedResponse(value: unknown): FeedResponse { 67 47 const record = asRecord(value); 68 48 const feed = asArray(record?.feed); ··· 75 55 throw new Error("feed response cursor is invalid"); 76 56 } 77 57 78 - return { cursor: (record.cursor as string | null | undefined) ?? null, feed }; 58 + return { cursor: typeof record.cursor === "string" ? record.cursor : null, feed }; 79 59 } 80 60 81 61 export function parseThreadResponse(value: unknown): ThreadResponse { ··· 121 101 return getDisplayName(author).slice(0, 1).toUpperCase() || "?"; 122 102 } 123 103 124 - export function formatRelativeTime(value: string) { 125 - const timestamp = new Date(value).getTime(); 126 - if (Number.isNaN(timestamp)) { 127 - return ""; 128 - } 129 - 130 - const deltaSeconds = Math.round((timestamp - Date.now()) / 1000); 131 - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 132 - const ranges = [ 133 - ["year", 60 * 60 * 24 * 365], 134 - ["month", 60 * 60 * 24 * 30], 135 - ["day", 60 * 60 * 24], 136 - ["hour", 60 * 60], 137 - ["minute", 60], 138 - ] as const; 139 - 140 - for (const [unit, seconds] of ranges) { 141 - if (Math.abs(deltaSeconds) >= seconds) { 142 - return formatter.format(Math.round(deltaSeconds / seconds), unit); 143 - } 144 - } 145 - 146 - return formatter.format(deltaSeconds, "second"); 147 - } 148 - 149 104 export function getFeedName(item: { type: string; value: string }, hydratedName?: string | null) { 150 105 if (item.type === "timeline") { 151 106 return item.value === "following" ? "Following" : "Timeline"; ··· 181 136 }; 182 137 } 183 138 184 - function isRepostReason(item: FeedViewPost) { 185 - return item.reason?.$type === "app.bsky.feed.defs#reasonRepost"; 186 - } 187 - 188 - function isQuoteEmbed(embed: Maybe<EmbedView>) { 189 - return embed?.$type === "app.bsky.embed.record#view" || embed?.$type === "app.bsky.embed.recordWithMedia#view"; 190 - } 191 - 192 - export function isReplyItem(item: FeedViewPost) { 193 - if (item.reply) { 194 - return true; 195 - } 196 - 197 - const record = asRecord(item.post.record); 198 - return !!asRecord(record?.reply); 199 - } 200 - 201 139 export function hasKnownThreadContext(post: PostView, item?: FeedViewPost) { 202 140 if (item && isReplyItem(item)) { 203 141 return true; ··· 210 148 return typeof post.replyCount === "number" && post.replyCount > 0; 211 149 } 212 150 213 - function isReplyByUnfollowed(item: FeedViewPost) { 214 - return isReplyItem(item) && !item.post.author.viewer?.following; 215 - } 216 - 217 151 export function getReplyRootPost(item: FeedViewPost) { 218 152 if (item.reply?.root.$type === "app.bsky.feed.defs#postView") { 219 153 return item.reply.root; ··· 224 158 225 159 export function toStrongRef(post: PostView) { 226 160 return { cid: post.cid, uri: post.uri } satisfies StrongRefInput; 227 - } 228 - 229 - export function isThreadViewPost(node: Maybe<ThreadNode>): node is ThreadViewPost { 230 - return !!node && node.$type === "app.bsky.feed.defs#threadViewPost"; 231 - } 232 - 233 - export function isBlockedNode(node: Maybe<ThreadNode | FeedReplyNode>): node is BlockedPost { 234 - return !!node && node.$type === "app.bsky.feed.defs#blockedPost"; 235 - } 236 - 237 - export function isNotFoundNode(node: Maybe<ThreadNode | FeedReplyNode>): node is NotFoundPost { 238 - return !!node && node.$type === "app.bsky.feed.defs#notFoundPost"; 239 161 } 240 162 241 163 export function extractHashtags(posts: PostView[]) { ··· 296 218 }); 297 219 } 298 220 299 - function getQuotedRecord(embed: Maybe<EmbedView>) { 300 - if (!embed) { 221 + type QuotedRecordKind = 222 + | "blocked" 223 + | "detached" 224 + | "feed" 225 + | "labeler" 226 + | "list" 227 + | "not-found" 228 + | "post" 229 + | "starter-pack" 230 + | "unknown"; 231 + 232 + type QuotedRecordVariant = 233 + | "generatorView" 234 + | "labelerView" 235 + | "listView" 236 + | "open-union" 237 + | "starterPackViewBasic" 238 + | "viewBlocked" 239 + | "viewDetached" 240 + | "viewNotFound" 241 + | "viewRecord"; 242 + 243 + type EmbedCanonicalKind = "external" | "images" | "record" | "recordWithMedia" | "video"; 244 + 245 + export type NormalizedEmbedSource = 246 + | "quoted" 247 + | "recordWithMedia.media" 248 + | "top" 249 + | "value.embed" 250 + | "value.embeds" 251 + | "viewRecord.embeds"; 252 + 253 + type NormalizationMeta = { 254 + cycle: boolean; 255 + depth: number; 256 + depthLimited: boolean; 257 + explicitType: string | null; 258 + inferred: boolean; 259 + source: NormalizedEmbedSource; 260 + }; 261 + 262 + export type UnknownEmbedEntry = { 263 + explicitType: string | null; 264 + fingerprint: string; 265 + inferred: boolean; 266 + raw: unknown; 267 + source: NormalizedEmbedSource; 268 + }; 269 + 270 + export type QuotedRecordPresentation = { 271 + author: ProfileViewBasic | null; 272 + emptyText: string; 273 + facets: RichTextFacet[] | null; 274 + href: string | null; 275 + kind: QuotedRecordKind; 276 + normalizedEmbeds: NormalizedEmbed[]; 277 + text: string | null; 278 + title: string; 279 + unknownEmbeds: UnknownEmbedEntry[]; 280 + uri: string | null; 281 + }; 282 + 283 + export type NormalizedQuotedRecord = QuotedRecordPresentation & { 284 + cycle: boolean; 285 + depth: number; 286 + depthLimited: boolean; 287 + variant: QuotedRecordVariant; 288 + }; 289 + 290 + export type NormalizedEmbed = 291 + | { embed: Extract<EmbedView, { $type: "app.bsky.embed.external#view" }>; kind: "external"; meta: NormalizationMeta } 292 + | { embed: Extract<EmbedView, { $type: "app.bsky.embed.images#view" }>; kind: "images"; meta: NormalizationMeta } 293 + | { kind: "record"; meta: NormalizationMeta; quoted: NormalizedQuotedRecord } 294 + | { kind: "recordWithMedia"; media: NormalizedEmbed | null; meta: NormalizationMeta; quoted: NormalizedQuotedRecord } 295 + | { kind: "recognized-unrenderable"; message: string; meta: NormalizationMeta; recognizedType: string } 296 + | { kind: "unknown"; meta: NormalizationMeta; unknown: UnknownEmbedEntry } 297 + | { embed: Extract<EmbedView, { $type: "app.bsky.embed.video#view" }>; kind: "video"; meta: NormalizationMeta }; 298 + 299 + type NormalizeEmbedOptions = { 300 + depth?: number; 301 + maxDepth?: number; 302 + source?: NormalizedEmbedSource; 303 + trail?: WeakSet<object>; 304 + }; 305 + 306 + type NormalizeEmbedContext = { depth: number; maxDepth: number; source: NormalizedEmbedSource; trail: WeakSet<object> }; 307 + type QuotedRecordClassification = { kind: QuotedRecordKind; variant: QuotedRecordVariant }; 308 + 309 + const DEFAULT_NORMALIZE_EMBED_MAX_DEPTH = 6; 310 + const UNKNOWN_EMBED_WARN_INTERVAL = 25; 311 + const unknownEmbedTelemetry = new Map<string, number>(); 312 + 313 + const VIEW_TYPE_TO_KIND: Readonly<Record<string, EmbedCanonicalKind>> = { 314 + "app.bsky.embed.external#view": "external", 315 + "app.bsky.embed.images#view": "images", 316 + "app.bsky.embed.record#view": "record", 317 + "app.bsky.embed.recordWithMedia#view": "recordWithMedia", 318 + "app.bsky.embed.video#view": "video", 319 + }; 320 + 321 + const MAIN_TYPE_TO_KIND: Readonly<Record<string, EmbedCanonicalKind>> = { 322 + "app.bsky.embed.external": "external", 323 + "app.bsky.embed.images": "images", 324 + "app.bsky.embed.record": "record", 325 + "app.bsky.embed.recordWithMedia": "recordWithMedia", 326 + "app.bsky.embed.video": "video", 327 + }; 328 + 329 + const QUOTED_RECORD_TYPE_CLASSIFICATION: Readonly<Record<string, QuotedRecordClassification>> = { 330 + "app.bsky.embed.record#viewBlocked": { kind: "blocked", variant: "viewBlocked" }, 331 + "app.bsky.embed.record#viewDetached": { kind: "detached", variant: "viewDetached" }, 332 + "app.bsky.embed.record#viewNotFound": { kind: "not-found", variant: "viewNotFound" }, 333 + "app.bsky.embed.record#viewRecord": { kind: "post", variant: "viewRecord" }, 334 + "app.bsky.feed.defs#generatorView": { kind: "feed", variant: "generatorView" }, 335 + "app.bsky.graph.defs#listView": { kind: "list", variant: "listView" }, 336 + "app.bsky.graph.defs#starterPackViewBasic": { kind: "starter-pack", variant: "starterPackViewBasic" }, 337 + "app.bsky.labeler.defs#labelerView": { kind: "labeler", variant: "labelerView" }, 338 + }; 339 + 340 + export function resetUnknownEmbedTelemetryForTests() { 341 + unknownEmbedTelemetry.clear(); 342 + } 343 + 344 + export function getUnknownEmbedTelemetryForTests() { 345 + return new Map(unknownEmbedTelemetry); 346 + } 347 + 348 + function debugEmbedKey(unknown: UnknownEmbedEntry) { 349 + return `${unknown.source}|${unknown.inferred ? "inferred" : "explicit"}|${unknown.fingerprint}`; 350 + } 351 + 352 + function trackUnknownEmbedTelemetry(unknown: UnknownEmbedEntry) { 353 + const key = debugEmbedKey(unknown); 354 + const count = (unknownEmbedTelemetry.get(key) ?? 0) + 1; 355 + unknownEmbedTelemetry.set(key, count); 356 + if (count !== 1 && count % UNKNOWN_EMBED_WARN_INTERVAL !== 0) { 357 + return; 358 + } 359 + 360 + logger.warn("unknown embed shape encountered", { 361 + keyValues: { 362 + count: String(count), 363 + explicitType: unknown.explicitType ?? "none", 364 + fingerprint: unknown.fingerprint, 365 + inferred: String(unknown.inferred), 366 + payloadJson: stringifyUnknown(unknown.raw), 367 + source: unknown.source, 368 + }, 369 + }); 370 + } 371 + 372 + function assertNever(value: never): never { 373 + throw new Error(`Unhandled value: ${String(value)}`); 374 + } 375 + 376 + function shapeSignature(value: unknown, depth = 0, seen = new WeakSet<object>()): string { 377 + if (depth > 3) { 378 + return "depth-limit"; 379 + } 380 + 381 + if (value === null) { 382 + return "null"; 383 + } 384 + 385 + if (Array.isArray(value)) { 386 + const preview = value.slice(0, 3).map((item) => shapeSignature(item, depth + 1, seen)); 387 + return `array(${value.length})[${preview.join(",")}]`; 388 + } 389 + 390 + const record = asRecord(value); 391 + if (record) { 392 + if (seen.has(record)) { 393 + return "cycle"; 394 + } 395 + seen.add(record); 396 + const keys = Object.keys(record).toSorted().slice(0, 12); 397 + const parts = keys.map((key) => `${key}:${shapeSignature(record[key], depth + 1, seen)}`); 398 + seen.delete(record); 399 + return `object{${parts.join("|")}}`; 400 + } 401 + 402 + return typeof value; 403 + } 404 + 405 + function buildEmbedFingerprint(value: unknown, explicitType: string | null, inferred: boolean) { 406 + const shapeHash = hashString(shapeSignature(value)); 407 + const typePart = explicitType ?? (inferred ? "inferred-shape" : "untyped"); 408 + return `${typePart}:${shapeHash}`; 409 + } 410 + 411 + function asAspectRatio(value: unknown) { 412 + const ratio = asRecord(value); 413 + if (!ratio || typeof ratio.width !== "number" || typeof ratio.height !== "number") { 414 + return; 415 + } 416 + 417 + return { height: ratio.height, width: ratio.width }; 418 + } 419 + 420 + function buildMeta( 421 + context: NormalizeEmbedContext, 422 + options: Partial<Pick<NormalizationMeta, "cycle" | "depthLimited" | "explicitType" | "inferred">> = {}, 423 + ): NormalizationMeta { 424 + return { 425 + cycle: options.cycle ?? false, 426 + depth: context.depth, 427 + depthLimited: options.depthLimited ?? false, 428 + explicitType: options.explicitType ?? null, 429 + inferred: options.inferred ?? false, 430 + source: context.source, 431 + }; 432 + } 433 + 434 + function childContext(parent: NormalizeEmbedContext, source: NormalizedEmbedSource): NormalizeEmbedContext { 435 + return { depth: parent.depth + 1, maxDepth: parent.maxDepth, source, trail: parent.trail }; 436 + } 437 + 438 + function canonicalEmbedKindFromType(type: string | null) { 439 + if (!type) { 301 440 return null; 302 441 } 442 + if (Object.prototype.hasOwnProperty.call(VIEW_TYPE_TO_KIND, type)) { 443 + return VIEW_TYPE_TO_KIND[type]; 444 + } 445 + if (Object.prototype.hasOwnProperty.call(MAIN_TYPE_TO_KIND, type)) { 446 + return MAIN_TYPE_TO_KIND[type]; 447 + } 448 + 449 + return null; 450 + } 303 451 304 - if (embed.$type === "app.bsky.embed.record#view") { 305 - return embed.record; 452 + function inferCanonicalEmbedKind(record: Record<string, unknown>): EmbedCanonicalKind | null { 453 + if (asRecord(record.record) && asRecord(record.media)) { 454 + return "recordWithMedia"; 455 + } 456 + if (asRecord(record.record)) { 457 + return "record"; 458 + } 459 + if (Array.isArray(record.images)) { 460 + return "images"; 461 + } 462 + if (asRecord(record.external)) { 463 + return "external"; 464 + } 465 + if ( 466 + Object.prototype.hasOwnProperty.call(record, "playlist") 467 + || Object.prototype.hasOwnProperty.call(record, "thumbnail") 468 + || Object.prototype.hasOwnProperty.call(record, "video") 469 + ) { 470 + return "video"; 306 471 } 307 472 308 - if (embed.$type === "app.bsky.embed.recordWithMedia#view") { 309 - return embed.record?.record ?? null; 473 + return null; 474 + } 475 + 476 + function unknownNormalizedEmbed( 477 + value: unknown, 478 + context: NormalizeEmbedContext, 479 + explicitType: string | null, 480 + inferred: boolean, 481 + ): Extract<NormalizedEmbed, { kind: "unknown" }> { 482 + const unknown: UnknownEmbedEntry = { 483 + explicitType, 484 + fingerprint: buildEmbedFingerprint(value, explicitType, inferred), 485 + inferred, 486 + raw: value, 487 + source: context.source, 488 + }; 489 + trackUnknownEmbedTelemetry(unknown); 490 + return { kind: "unknown", meta: buildMeta(context, { explicitType, inferred }), unknown }; 491 + } 492 + 493 + function recognizedUnrenderableEmbed( 494 + context: NormalizeEmbedContext, 495 + recognizedType: string, 496 + message: string, 497 + raw: unknown, 498 + options: Partial<Pick<NormalizationMeta, "cycle" | "depthLimited" | "explicitType" | "inferred">> = {}, 499 + ): Extract<NormalizedEmbed, { kind: "recognized-unrenderable" }> { 500 + logger.warn("recognized embed shape could not be rendered", { 501 + keyValues: { 502 + explicitType: options.explicitType ?? "none", 503 + inferred: String(options.inferred ?? false), 504 + message, 505 + payloadJson: stringifyUnknown(raw), 506 + recognizedType, 507 + source: context.source, 508 + }, 509 + }); 510 + return { kind: "recognized-unrenderable", message, meta: buildMeta(context, options), recognizedType }; 511 + } 512 + 513 + function normalizeImagesEmbedView(record: Record<string, unknown>) { 514 + const images = asArray(record.images); 515 + if (!images) { 516 + return null; 517 + } 518 + 519 + const normalizedImages = images.map((item) => asRecord(item)).filter((item): item is Record<string, unknown> => 520 + !!item 521 + ).map((item) => { 522 + const fullsize = typeof item.fullsize === "string" ? item.fullsize : undefined; 523 + const thumb = typeof item.thumb === "string" ? item.thumb : undefined; 524 + if (!fullsize && !thumb) { 525 + return null; 526 + } 527 + 528 + return { 529 + alt: typeof item.alt === "string" ? item.alt : undefined, 530 + aspectRatio: asAspectRatio(item.aspectRatio), 531 + fullsize, 532 + thumb, 533 + }; 534 + }).filter((item): item is NonNullable<typeof item> => !!item); 535 + 536 + if (normalizedImages.length === 0) { 537 + return null; 538 + } 539 + 540 + return { $type: "app.bsky.embed.images#view", images: normalizedImages } as const; 541 + } 542 + 543 + function normalizeExternalEmbedView(record: Record<string, unknown>) { 544 + const external = asRecord(record.external); 545 + if (!external) { 546 + return null; 547 + } 548 + 549 + const normalized = { 550 + description: typeof external.description === "string" ? external.description : undefined, 551 + thumb: typeof external.thumb === "string" ? external.thumb : undefined, 552 + title: typeof external.title === "string" ? external.title : undefined, 553 + uri: typeof external.uri === "string" ? external.uri : undefined, 554 + }; 555 + 556 + if (!normalized.description && !normalized.thumb && !normalized.title && !normalized.uri) { 557 + return null; 558 + } 559 + 560 + return { $type: "app.bsky.embed.external#view", external: normalized } as const; 561 + } 562 + 563 + function normalizeVideoEmbedView(record: Record<string, unknown>) { 564 + const normalized = { 565 + alt: typeof record.alt === "string" ? record.alt : undefined, 566 + aspectRatio: asAspectRatio(record.aspectRatio), 567 + playlist: typeof record.playlist === "string" ? record.playlist : undefined, 568 + thumbnail: typeof record.thumbnail === "string" ? record.thumbnail : undefined, 569 + }; 570 + 571 + if (!normalized.alt && !normalized.aspectRatio && !normalized.playlist && !normalized.thumbnail) { 572 + return null; 573 + } 574 + 575 + return { $type: "app.bsky.embed.video#view", ...normalized } as const; 576 + } 577 + 578 + function getProfileFromRecord(record: Record<string, unknown>, keys: string[]) { 579 + for (const key of keys) { 580 + const candidate = asRecord(record[key]); 581 + if (candidate && isProfileViewBasic(candidate)) { 582 + return candidate; 583 + } 310 584 } 311 585 312 586 return null; 313 587 } 314 588 589 + function atUriParts(value: Maybe<string>) { 590 + if (typeof value !== "string") { 591 + return null; 592 + } 593 + 594 + const trimmed = value.trim(); 595 + if (!trimmed.startsWith("at://")) { 596 + return null; 597 + } 598 + 599 + const segments = trimmed.slice(5).split("/").map((segment) => segment.trim()).filter((segment) => segment.length > 0); 600 + if (segments.length === 0) { 601 + return null; 602 + } 603 + 604 + return { 605 + collection: segments.length > 1 ? segments[1] : null, 606 + did: segments[0], 607 + rkey: segments.length > 2 ? segments[2] : null, 608 + uri: trimmed, 609 + }; 610 + } 611 + 612 + function classifyQuotedRecord(record: Record<string, unknown>): QuotedRecordClassification { 613 + const type = typeof record.$type === "string" ? record.$type : null; 614 + if (type && Object.prototype.hasOwnProperty.call(QUOTED_RECORD_TYPE_CLASSIFICATION, type)) { 615 + return QUOTED_RECORD_TYPE_CLASSIFICATION[type]; 616 + } 617 + if (record.blocked === true) { 618 + return { kind: "blocked", variant: "viewBlocked" }; 619 + } 620 + if (record.detached === true) { 621 + return { kind: "detached", variant: "viewDetached" }; 622 + } 623 + if (record.notFound === true) { 624 + return { kind: "not-found", variant: "viewNotFound" }; 625 + } 626 + 627 + const uriCollection = atUriParts(typeof record.uri === "string" ? record.uri : null)?.collection; 628 + if (uriCollection === POST_COLLECTION) { 629 + return { kind: "post", variant: "open-union" }; 630 + } 631 + if (uriCollection === FEED_COLLECTION) { 632 + return { kind: "feed", variant: "open-union" }; 633 + } 634 + if (uriCollection === LIST_COLLECTION) { 635 + return { kind: "list", variant: "open-union" }; 636 + } 637 + if (uriCollection === STARTER_PACK_COLLECTION) { 638 + return { kind: "starter-pack", variant: "open-union" }; 639 + } 640 + if (uriCollection === LABELER_COLLECTION) { 641 + return { kind: "labeler", variant: "open-union" }; 642 + } 643 + 644 + const valueRecord = asRecord(record.value); 645 + if (valueRecord?.$type === POST_COLLECTION || typeof valueRecord?.text === "string") { 646 + return { kind: "post", variant: "open-union" }; 647 + } 648 + 649 + return { kind: "unknown", variant: "open-union" }; 650 + } 651 + 652 + function quotedRecordText(kind: QuotedRecordKind, record: Record<string, unknown>) { 653 + if (kind === "post") { 654 + const text = asRecord(record.value)?.text; 655 + return typeof text === "string" && text.trim().length > 0 ? text : null; 656 + } 657 + if (kind === "feed") { 658 + const displayName = record.displayName; 659 + if (typeof displayName === "string" && displayName.trim().length > 0) { 660 + return displayName; 661 + } 662 + 663 + const description = record.description; 664 + return typeof description === "string" && description.trim().length > 0 ? description : null; 665 + } 666 + if (kind === "list") { 667 + const name = record.name; 668 + if (typeof name === "string" && name.trim().length > 0) { 669 + return name; 670 + } 671 + 672 + const description = record.description; 673 + return typeof description === "string" && description.trim().length > 0 ? description : null; 674 + } 675 + if (kind === "labeler") { 676 + return "Moderation service"; 677 + } 678 + if (kind === "starter-pack") { 679 + const name = asRecord(record.record)?.name; 680 + if (typeof name === "string" && name.trim().length > 0) { 681 + return name; 682 + } 683 + return "Starter pack"; 684 + } 685 + if (kind === "blocked") { 686 + return "This record is blocked."; 687 + } 688 + if (kind === "not-found") { 689 + return "This record was not found."; 690 + } 691 + if (kind === "detached") { 692 + return "This record has been detached."; 693 + } 694 + 695 + return "Unsupported embedded record."; 696 + } 697 + 698 + function quotedRecordTitles(kind: QuotedRecordKind) { 699 + if (kind === "post") { 700 + return { emptyText: "Quoted post", title: "Quoted post" }; 701 + } 702 + if (kind === "feed") { 703 + return { emptyText: "Feed", title: "Embedded feed" }; 704 + } 705 + if (kind === "list") { 706 + return { emptyText: "List", title: "Embedded list" }; 707 + } 708 + if (kind === "labeler") { 709 + return { emptyText: "Labeler", title: "Embedded labeler" }; 710 + } 711 + if (kind === "starter-pack") { 712 + return { emptyText: "Starter pack", title: "Embedded starter pack" }; 713 + } 714 + if (kind === "blocked") { 715 + return { emptyText: "This record is blocked.", title: "Embedded record" }; 716 + } 717 + if (kind === "not-found") { 718 + return { emptyText: "This record was not found.", title: "Embedded record" }; 719 + } 720 + if (kind === "detached") { 721 + return { emptyText: "This record has been detached.", title: "Embedded record" }; 722 + } 723 + 724 + return { emptyText: "Unsupported embedded record.", title: "Embedded record" }; 725 + } 726 + 727 + function quotedRecordFacets(kind: QuotedRecordKind, record: Record<string, unknown>) { 728 + if (kind !== "post") { 729 + return null; 730 + } 731 + 732 + const facets = asRecord(record.value)?.facets; 733 + return Array.isArray(facets) ? (facets as RichTextFacet[]) : null; 734 + } 735 + 736 + type QuotedEmbedExtraction = { source: "value.embed" | "value.embeds" | "viewRecord.embeds"; values: unknown[] }; 737 + 738 + function quotedEmbedExtraction(record: Record<string, unknown>): QuotedEmbedExtraction | null { 739 + if (Object.prototype.hasOwnProperty.call(record, "embeds")) { 740 + const direct = asArray(record.embeds); 741 + return { source: "viewRecord.embeds", values: direct ?? (record.embeds === undefined ? [] : [record.embeds]) }; 742 + } 743 + 744 + const value = asRecord(record.value); 745 + if (!value) { 746 + return null; 747 + } 748 + 749 + if (Object.prototype.hasOwnProperty.call(value, "embed")) { 750 + if (value.embed === null || value.embed === undefined) { 751 + return { source: "value.embed", values: [] }; 752 + } 753 + return { source: "value.embed", values: [value.embed] }; 754 + } 755 + 756 + if (Object.prototype.hasOwnProperty.call(value, "embeds")) { 757 + const embeds = asArray(value.embeds); 758 + return { source: "value.embeds", values: embeds ?? (value.embeds === undefined ? [] : [value.embeds]) }; 759 + } 760 + 761 + return null; 762 + } 763 + 764 + function collectUnknownEmbeds(embed: NormalizedEmbed, unknowns: UnknownEmbedEntry[]) { 765 + if (embed.kind === "unknown") { 766 + unknowns.push(embed.unknown); 767 + return; 768 + } 769 + 770 + if (embed.kind === "record") { 771 + unknowns.push(...embed.quoted.unknownEmbeds); 772 + return; 773 + } 774 + 775 + if (embed.kind === "recordWithMedia") { 776 + if (embed.media) { 777 + collectUnknownEmbeds(embed.media, unknowns); 778 + } 779 + unknowns.push(...embed.quoted.unknownEmbeds); 780 + } 781 + } 782 + 783 + function recordPayloadFromRecordWithMedia(record: Record<string, unknown>) { 784 + const outer = asRecord(record.record); 785 + if (!outer) { 786 + return null; 787 + } 788 + 789 + const nested = asRecord(outer.record); 790 + if (nested) { 791 + return nested; 792 + } 793 + 794 + return outer; 795 + } 796 + 797 + function toPresentation(record: NormalizedQuotedRecord): QuotedRecordPresentation { 798 + return { 799 + author: record.author, 800 + emptyText: record.emptyText, 801 + facets: record.facets, 802 + href: record.href, 803 + kind: record.kind, 804 + normalizedEmbeds: record.normalizedEmbeds, 805 + text: record.text, 806 + title: record.title, 807 + unknownEmbeds: record.unknownEmbeds, 808 + uri: record.uri, 809 + }; 810 + } 811 + 812 + function fallbackQuotedPresentation(kind: QuotedRecordKind, context: NormalizeEmbedContext): NormalizedQuotedRecord { 813 + const { emptyText, title } = quotedRecordTitles(kind); 814 + return { 815 + author: null, 816 + cycle: false, 817 + depth: context.depth, 818 + depthLimited: context.depth > context.maxDepth, 819 + emptyText, 820 + facets: null, 821 + href: null, 822 + kind, 823 + normalizedEmbeds: [], 824 + text: quotedRecordText(kind, {}), 825 + title, 826 + unknownEmbeds: [], 827 + uri: null, 828 + variant: "open-union", 829 + }; 830 + } 831 + 832 + function normalizeQuotedEmbeds(record: Record<string, unknown>, context: NormalizeEmbedContext) { 833 + const extraction = quotedEmbedExtraction(record); 834 + if (!extraction) { 835 + return { normalizedEmbeds: [] as NormalizedEmbed[], unknownEmbeds: [] as UnknownEmbedEntry[] }; 836 + } 837 + 838 + const normalizedEmbeds = extraction.values.map((value) => 839 + normalizeEmbed(value, { 840 + depth: context.depth + 1, 841 + maxDepth: context.maxDepth, 842 + source: extraction.source, 843 + trail: context.trail, 844 + }) 845 + ); 846 + const unknownEmbeds: UnknownEmbedEntry[] = []; 847 + for (const normalized of normalizedEmbeds) { 848 + collectUnknownEmbeds(normalized, unknownEmbeds); 849 + } 850 + 851 + return { normalizedEmbeds, unknownEmbeds }; 852 + } 853 + 854 + function normalizeQuotedRecord(recordValue: unknown, context: NormalizeEmbedContext): NormalizedQuotedRecord { 855 + const record = asRecord(recordValue); 856 + if (!record) { 857 + return fallbackQuotedPresentation("unknown", context); 858 + } 859 + 860 + if (context.depth > context.maxDepth) { 861 + return fallbackQuotedPresentation("unknown", context); 862 + } 863 + 864 + if (context.trail.has(record)) { 865 + const fallback = fallbackQuotedPresentation("unknown", context); 866 + return { ...fallback, cycle: true }; 867 + } 868 + 869 + context.trail.add(record); 870 + try { 871 + const classification = classifyQuotedRecord(record); 872 + const { kind, variant } = classification; 873 + const author = getProfileFromRecord(record, ["author", "creator"]); 874 + const uri = typeof record.uri === "string" && record.uri.trim().length > 0 ? record.uri : null; 875 + const { emptyText, title } = quotedRecordTitles(kind); 876 + const normalized = kind === "post" 877 + ? normalizeQuotedEmbeds(record, context) 878 + : { normalizedEmbeds: [] as NormalizedEmbed[], unknownEmbeds: [] as UnknownEmbedEntry[] }; 879 + 880 + return { 881 + author, 882 + cycle: false, 883 + depth: context.depth, 884 + depthLimited: false, 885 + emptyText, 886 + facets: quotedRecordFacets(kind, record), 887 + href: buildPublicRecordHref(author, uri, kind), 888 + kind, 889 + normalizedEmbeds: normalized.normalizedEmbeds, 890 + text: quotedRecordText(kind, record), 891 + title, 892 + unknownEmbeds: normalized.unknownEmbeds, 893 + uri: quotedRecordUri(kind, uri), 894 + variant, 895 + }; 896 + } finally { 897 + context.trail.delete(record); 898 + } 899 + } 900 + 901 + function normalizedQuotedFromEmbed(embed: NormalizedEmbed): NormalizedQuotedRecord | null { 902 + if (embed.kind === "record") { 903 + return embed.quoted; 904 + } 905 + if (embed.kind === "recordWithMedia") { 906 + return embed.quoted; 907 + } 908 + return null; 909 + } 910 + 911 + type KnownEmbedNormalizationOptions = Pick<NormalizationMeta, "explicitType" | "inferred">; 912 + 913 + function normalizeKnownEmbedKind( 914 + kind: EmbedCanonicalKind, 915 + record: Record<string, unknown>, 916 + context: NormalizeEmbedContext, 917 + options: KnownEmbedNormalizationOptions, 918 + ): Exclude<NormalizedEmbed, { kind: "unknown" }> { 919 + const { explicitType, inferred } = options; 920 + 921 + if (context.source === "recordWithMedia.media" && (kind === "record" || kind === "recordWithMedia")) { 922 + return recognizedUnrenderableEmbed( 923 + context, 924 + kind, 925 + "This recognized media type is not valid in recordWithMedia.media.", 926 + record, 927 + { explicitType, inferred }, 928 + ); 929 + } 930 + 931 + switch (kind) { 932 + case "images": { 933 + const embed = normalizeImagesEmbedView(record); 934 + if (!embed) { 935 + return recognizedUnrenderableEmbed( 936 + context, 937 + "app.bsky.embed.images#view", 938 + "Recognized image embed could not be rendered.", 939 + record, 940 + { explicitType, inferred }, 941 + ); 942 + } 943 + 944 + return { embed, kind: "images", meta: buildMeta(context, { explicitType, inferred }) }; 945 + } 946 + case "external": { 947 + const embed = normalizeExternalEmbedView(record); 948 + if (!embed) { 949 + return recognizedUnrenderableEmbed( 950 + context, 951 + "app.bsky.embed.external#view", 952 + "Recognized external embed could not be rendered.", 953 + record, 954 + { explicitType, inferred }, 955 + ); 956 + } 957 + 958 + return { embed, kind: "external", meta: buildMeta(context, { explicitType, inferred }) }; 959 + } 960 + case "video": { 961 + const embed = normalizeVideoEmbedView(record); 962 + if (!embed) { 963 + return recognizedUnrenderableEmbed( 964 + context, 965 + "app.bsky.embed.video#view", 966 + "Recognized video embed could not be rendered.", 967 + record, 968 + { explicitType, inferred }, 969 + ); 970 + } 971 + 972 + return { embed, kind: "video", meta: buildMeta(context, { explicitType, inferred }) }; 973 + } 974 + case "record": { 975 + const recordPayload = asRecord(record.record); 976 + if (!recordPayload) { 977 + return recognizedUnrenderableEmbed( 978 + context, 979 + "app.bsky.embed.record#view", 980 + "Recognized quoted record embed could not be rendered.", 981 + record, 982 + { explicitType, inferred }, 983 + ); 984 + } 985 + 986 + return { 987 + kind: "record", 988 + meta: buildMeta(context, { explicitType, inferred }), 989 + quoted: normalizeQuotedRecord(recordPayload, childContext(context, "quoted")), 990 + }; 991 + } 992 + case "recordWithMedia": { 993 + const media = record.media === undefined || record.media === null 994 + ? null 995 + : normalizeEmbedWithContext(record.media, childContext(context, "recordWithMedia.media")); 996 + const quotedRecord = normalizeQuotedRecord( 997 + recordPayloadFromRecordWithMedia(record), 998 + childContext(context, "quoted"), 999 + ); 1000 + return { 1001 + kind: "recordWithMedia", 1002 + media, 1003 + meta: buildMeta(context, { explicitType, inferred }), 1004 + quoted: quotedRecord, 1005 + }; 1006 + } 1007 + default: { 1008 + return assertNever(kind); 1009 + } 1010 + } 1011 + } 1012 + 1013 + function normalizeEmbedWithContext(value: unknown, context: NormalizeEmbedContext): NormalizedEmbed { 1014 + if (context.depth > context.maxDepth) { 1015 + return recognizedUnrenderableEmbed(context, "depth-limit", "Embed nesting limit reached.", value, { 1016 + depthLimited: true, 1017 + }); 1018 + } 1019 + 1020 + const record = asRecord(value); 1021 + if (!record) { 1022 + return unknownNormalizedEmbed(value, context, null, false); 1023 + } 1024 + 1025 + if (context.trail.has(record)) { 1026 + return recognizedUnrenderableEmbed(context, "cycle", "Embed cycle detected.", value, { cycle: true }); 1027 + } 1028 + 1029 + const explicitType = typeof record.$type === "string" ? record.$type : null; 1030 + const explicitKind = canonicalEmbedKindFromType(explicitType); 1031 + const inferredKind = explicitKind ? null : inferCanonicalEmbedKind(record); 1032 + const kind = explicitKind ?? inferredKind; 1033 + const inferred = !explicitKind && !!inferredKind; 1034 + if (!kind) { 1035 + return unknownNormalizedEmbed(value, context, explicitType, false); 1036 + } 1037 + 1038 + context.trail.add(record); 1039 + try { 1040 + return normalizeKnownEmbedKind(kind, record, context, { explicitType, inferred }); 1041 + } finally { 1042 + context.trail.delete(record); 1043 + } 1044 + } 1045 + 1046 + export function normalizeEmbed(value: unknown, options: NormalizeEmbedOptions = {}): NormalizedEmbed { 1047 + const context: NormalizeEmbedContext = { 1048 + depth: options.depth ?? 0, 1049 + maxDepth: options.maxDepth ?? DEFAULT_NORMALIZE_EMBED_MAX_DEPTH, 1050 + source: options.source ?? "top", 1051 + trail: options.trail ?? new WeakSet<object>(), 1052 + }; 1053 + return normalizeEmbedWithContext(value, context); 1054 + } 1055 + 1056 + function buildPublicRecordHref(author: Maybe<ProfileViewBasic>, uri: Maybe<string>, kind: QuotedRecordKind) { 1057 + const parts = atUriParts(uri); 1058 + const actor = normalizeHandle(author?.handle) ?? normalizeDid(author?.did) ?? normalizeDid(parts?.did); 1059 + if (kind === "labeler") { 1060 + if (!actor) { 1061 + return null; 1062 + } 1063 + return `https://bsky.app/profile/${encodeURIComponent(actor)}`; 1064 + } 1065 + 1066 + if (kind === "post") { 1067 + if (!parts?.rkey || !actor) { 1068 + return null; 1069 + } 1070 + return `https://bsky.app/profile/${encodeURIComponent(actor)}/post/${encodeURIComponent(parts.rkey)}`; 1071 + } 1072 + if (kind === "feed") { 1073 + if (!parts?.rkey || !actor) { 1074 + return null; 1075 + } 1076 + return `https://bsky.app/profile/${encodeURIComponent(actor)}/feed/${encodeURIComponent(parts.rkey)}`; 1077 + } 1078 + if (kind === "list") { 1079 + if (!parts?.rkey || !actor) { 1080 + return null; 1081 + } 1082 + return `https://bsky.app/profile/${encodeURIComponent(actor)}/lists/${encodeURIComponent(parts.rkey)}`; 1083 + } 1084 + if (kind === "starter-pack") { 1085 + if (!parts?.rkey) { 1086 + return null; 1087 + } 1088 + return `https://bsky.app/starter-pack/${encodeURIComponent(parts.did)}/${encodeURIComponent(parts.rkey)}`; 1089 + } 1090 + 1091 + return null; 1092 + } 1093 + 1094 + function quotedRecordUri(kind: QuotedRecordKind, uri: string | null) { 1095 + return kind === "post" ? uri : null; 1096 + } 1097 + 1098 + export function getQuotedPresentation(embed: Maybe<EmbedView>): QuotedRecordPresentation { 1099 + if (!embed) { 1100 + return { 1101 + author: null, 1102 + emptyText: "Quoted post", 1103 + facets: null, 1104 + href: null, 1105 + kind: "post", 1106 + normalizedEmbeds: [], 1107 + text: null, 1108 + title: "Quoted post", 1109 + unknownEmbeds: [], 1110 + uri: null, 1111 + }; 1112 + } 1113 + 1114 + const normalized = normalizeEmbed(embed, { source: "top" }); 1115 + const quoted = normalizedQuotedFromEmbed(normalized); 1116 + if (!quoted) { 1117 + return { 1118 + author: null, 1119 + emptyText: "Quoted post", 1120 + facets: null, 1121 + href: null, 1122 + kind: "post", 1123 + normalizedEmbeds: [], 1124 + text: null, 1125 + title: "Quoted post", 1126 + unknownEmbeds: [], 1127 + uri: null, 1128 + }; 1129 + } 1130 + 1131 + return toPresentation(quoted); 1132 + } 1133 + 315 1134 export function getQuotedText(embed: Maybe<EmbedView>) { 316 - const record = getQuotedRecord(embed); 317 - return asRecord(record?.value)?.text; 1135 + return getQuotedPresentation(embed).text; 318 1136 } 319 1137 320 1138 export function getQuotedAuthor(embed: Maybe<EmbedView>) { 321 - return getQuotedRecord(embed)?.author ?? null; 1139 + return getQuotedPresentation(embed).author; 322 1140 } 323 1141 324 1142 export function getQuotedUri(embed: Maybe<EmbedView>) { 325 - const uri = getQuotedRecord(embed)?.uri; 326 - return typeof uri === "string" && uri.trim() ? uri : null; 1143 + return getQuotedPresentation(embed).uri; 327 1144 } 328 1145 329 1146 export function getQuotedHref(embed: Maybe<EmbedView>) { 330 - const record = getQuotedRecord(embed); 331 - return buildPublicPostHref(record?.author ?? null, record?.uri); 1147 + return getQuotedPresentation(embed).href; 332 1148 } 333 1149 334 1150 export function patchFeedItems(items: FeedViewPost[], uri: string, updater: (post: PostView) => PostView) { ··· 394 1210 } 395 1211 396 1212 export function buildPublicPostUrl(post: Pick<PostView, "author" | "uri">) { 397 - return buildPublicPostHref(post.author, post.uri) ?? post.uri; 398 - } 399 - 400 - function buildPublicPostHref(author: Maybe<ProfileViewBasic>, uri: Maybe<string>) { 401 - if (!author || typeof uri !== "string") { 402 - return null; 403 - } 404 - 405 - const actor = normalizeHandle(author.handle) ?? normalizeDid(author.did); 406 - const segments = uri.split("/"); 407 - const rkey = segments.at(-1)?.trim(); 408 - 409 - if (actor && rkey) { 410 - return `https://bsky.app/profile/${encodeURIComponent(actor)}/post/${encodeURIComponent(rkey)}`; 411 - } 412 - 413 - return null; 1213 + return buildPublicRecordHref(post.author, post.uri, "post") ?? post.uri; 414 1214 } 415 1215 416 1216 function normalizeHandle(value: string | null | undefined) { ··· 432 1232 } 433 1233 434 1234 export function postRkeyFromUri(uri: string | null | undefined) { 435 - if (typeof uri !== "string") { 436 - return null; 437 - } 438 - 439 - const trimmed = uri.trim(); 440 - if (!trimmed.startsWith("at://")) { 441 - return null; 442 - } 443 - 444 - const rkey = trimmed.split("/").at(-1)?.trim(); 445 - return rkey || null; 1235 + return atUriParts(uri)?.rkey ?? null; 446 1236 }
+99
src/lib/feeds/type-guards.ts
··· 1 + import type { NormalizedEmbed } from "../feeds"; 2 + import { asRecord } from "../type-guards"; 3 + import type { 4 + BlockedPost, 5 + EmbedView, 6 + FeedReplyNode, 7 + FeedViewPost, 8 + Maybe, 9 + NotFoundPost, 10 + PostView, 11 + ProfileViewBasic, 12 + ThreadNode, 13 + ThreadViewPost, 14 + } from "../types"; 15 + 16 + export function isPostView(value: unknown): value is PostView { 17 + const record = asRecord(value); 18 + const author = asRecord(record?.author); 19 + const postRecord = asRecord(record?.record); 20 + 21 + return !!record 22 + && !!author 23 + && !!postRecord 24 + && typeof record.cid === "string" 25 + && typeof record.indexedAt === "string" 26 + && typeof record.uri === "string" 27 + && isProfileViewBasic(author); 28 + } 29 + 30 + export function isFeedViewPost(value: unknown): value is FeedViewPost { 31 + const record = asRecord(value); 32 + return !!record && isPostView(record.post); 33 + } 34 + 35 + export function isThreadNode(value: unknown): value is ThreadNode { 36 + const record = asRecord(value); 37 + if (!record || typeof record.$type !== "string") { 38 + return false; 39 + } 40 + 41 + if (record.$type === "app.bsky.feed.defs#threadViewPost") { 42 + return isPostView(record.post); 43 + } 44 + 45 + return record.$type === "app.bsky.feed.defs#blockedPost" || record.$type === "app.bsky.feed.defs#notFoundPost"; 46 + } 47 + 48 + export function isRepostReason(item: FeedViewPost) { 49 + return item.reason?.$type === "app.bsky.feed.defs#reasonRepost"; 50 + } 51 + 52 + export function isQuoteEmbed(embed: Maybe<EmbedView>) { 53 + return embed?.$type === "app.bsky.embed.record#view" || embed?.$type === "app.bsky.embed.recordWithMedia#view"; 54 + } 55 + 56 + export function isReplyItem(item: FeedViewPost) { 57 + if (item.reply) { 58 + return true; 59 + } 60 + 61 + const record = asRecord(item.post.record); 62 + return !!asRecord(record?.reply); 63 + } 64 + 65 + export function isReplyByUnfollowed(item: FeedViewPost) { 66 + return isReplyItem(item) && !item.post.author.viewer?.following; 67 + } 68 + 69 + export function isThreadViewPost(node: Maybe<ThreadNode>): node is ThreadViewPost { 70 + return !!node && node.$type === "app.bsky.feed.defs#threadViewPost"; 71 + } 72 + 73 + export function isBlockedNode(node: Maybe<ThreadNode | FeedReplyNode>): node is BlockedPost { 74 + return !!node && node.$type === "app.bsky.feed.defs#blockedPost"; 75 + } 76 + 77 + export function isNotFoundNode(node: Maybe<ThreadNode | FeedReplyNode>): node is NotFoundPost { 78 + return !!node && node.$type === "app.bsky.feed.defs#notFoundPost"; 79 + } 80 + 81 + export function isProfileViewBasic(value: unknown): value is ProfileViewBasic { 82 + const record = asRecord(value); 83 + return !!record && typeof record.did === "string" && typeof record.handle === "string"; 84 + } 85 + 86 + export function isNormalizedEmbed(value: unknown): value is NormalizedEmbed { 87 + if (!value || typeof value !== "object") { 88 + return false; 89 + } 90 + 91 + const kind = (value as { kind?: unknown }).kind; 92 + return kind === "external" 93 + || kind === "images" 94 + || kind === "record" 95 + || kind === "recordWithMedia" 96 + || kind === "recognized-unrenderable" 97 + || kind === "unknown" 98 + || kind === "video"; 99 + }
+2 -1
src/lib/profile.ts
··· 1 - import { isReplyItem, parseFeedResponse } from "$/lib/feeds"; 1 + import { parseFeedResponse } from "$/lib/feeds"; 2 + import { isReplyItem } from "$/lib/feeds/type-guards"; 2 3 import { asModerationLabels } from "$/lib/moderation"; 3 4 import type { 4 5 ActorListResponse,
+451 -1
src/lib/tests/feeds.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 2 import { 3 3 applyFeedPreferences, 4 4 buildPublicPostUrl, 5 5 buildThreadOverlayRoute, 6 6 decodeThreadRouteUri, 7 + getUnknownEmbedTelemetryForTests, 7 8 getFeedCommand, 9 + getQuotedPresentation, 8 10 getThreadOverlayUri, 11 + normalizeEmbed, 9 12 parseFeedResponse, 10 13 parseThreadResponse, 14 + resetUnknownEmbedTelemetryForTests, 15 + type NormalizedEmbed, 11 16 } from "../feeds"; 12 17 import type { FeedViewPost, FeedViewPrefItem, SavedFeedItem } from "../types"; 13 18 ··· 37 42 }; 38 43 } 39 44 45 + function walkNormalizedEmbeds(root: NormalizedEmbed, visit: (embed: NormalizedEmbed) => void) { 46 + visit(root); 47 + if (root.kind === "record") { 48 + for (const nested of root.quoted.normalizedEmbeds) { 49 + walkNormalizedEmbeds(nested, visit); 50 + } 51 + } 52 + if (root.kind === "recordWithMedia") { 53 + if (root.media) { 54 + walkNormalizedEmbeds(root.media, visit); 55 + } 56 + for (const nested of root.quoted.normalizedEmbeds) { 57 + walkNormalizedEmbeds(nested, visit); 58 + } 59 + } 60 + } 61 + 40 62 describe("feed helpers", () => { 63 + beforeEach(() => { 64 + resetUnknownEmbedTelemetryForTests(); 65 + }); 66 + 41 67 it("filters reposts, replies, quote posts, and low-like replies", () => { 42 68 const base = createFeedItem(); 43 69 const repost = createFeedItem({ ··· 146 172 147 173 it("builds public post urls from handles and post rkeys", () => { 148 174 expect(buildPublicPostUrl(createFeedItem().post)).toBe("https://bsky.app/profile/alice.test/post/1"); 175 + }); 176 + 177 + it("builds feed/list quoted-record presentations without thread URIs", () => { 178 + const feedPresentation = getQuotedPresentation({ 179 + $type: "app.bsky.embed.record#view", 180 + record: { 181 + $type: "app.bsky.feed.defs#generatorView", 182 + creator: { did: "did:plc:alice", handle: "alice.test" }, 183 + displayName: "For You", 184 + uri: "at://did:plc:alice/app.bsky.feed.generator/for-you", 185 + }, 186 + }); 187 + const listPresentation = getQuotedPresentation({ 188 + $type: "app.bsky.embed.record#view", 189 + record: { 190 + $type: "app.bsky.graph.defs#listView", 191 + creator: { did: "did:plc:alice", handle: "alice.test" }, 192 + name: "Reading List", 193 + uri: "at://did:plc:alice/app.bsky.graph.list/reading-list", 194 + }, 195 + }); 196 + 197 + expect(feedPresentation).toMatchObject({ 198 + href: "https://bsky.app/profile/alice.test/feed/for-you", 199 + kind: "feed", 200 + title: "Embedded feed", 201 + uri: null, 202 + }); 203 + expect(listPresentation).toMatchObject({ 204 + href: "https://bsky.app/profile/alice.test/lists/reading-list", 205 + kind: "list", 206 + title: "Embedded list", 207 + uri: null, 208 + }); 209 + }); 210 + 211 + it("keeps post quoted-record presentations thread-openable", () => { 212 + const postPresentation = getQuotedPresentation({ 213 + $type: "app.bsky.embed.record#view", 214 + record: { 215 + $type: "app.bsky.embed.record#viewRecord", 216 + author: { did: "did:plc:bob", handle: "bob.test" }, 217 + uri: "at://did:plc:bob/app.bsky.feed.post/123", 218 + value: { text: "quoted body" }, 219 + }, 220 + }); 221 + 222 + expect(postPresentation).toMatchObject({ 223 + href: "https://bsky.app/profile/bob.test/post/123", 224 + kind: "post", 225 + text: "quoted body", 226 + title: "Quoted post", 227 + uri: "at://did:plc:bob/app.bsky.feed.post/123", 228 + }); 229 + }); 230 + 231 + it("extracts quoted post embeds and keeps unknown custom embeds in the unknown list", () => { 232 + const presentation = getQuotedPresentation({ 233 + $type: "app.bsky.embed.record#view", 234 + record: { 235 + $type: "app.bsky.embed.record#viewRecord", 236 + author: { did: "did:plc:bob", handle: "bob.test" }, 237 + embeds: [ 238 + { 239 + $type: "app.bsky.embed.images#view", 240 + images: [{ fullsize: "https://cdn.example.com/quoted-image.png" }], 241 + }, 242 + { 243 + $type: "app.bsky.embed.video#view", 244 + playlist: "https://cdn.example.com/quoted-video.m3u8", 245 + }, 246 + { 247 + $type: "app.bsky.embed.external#view", 248 + external: { uri: "https://example.com", title: "External card" }, 249 + }, 250 + { $type: "app.bsky.embed.unsupported#view" }, 251 + ], 252 + uri: "at://did:plc:bob/app.bsky.feed.post/123", 253 + value: { text: "quoted body" }, 254 + }, 255 + }); 256 + 257 + expect(presentation.normalizedEmbeds).toHaveLength(4); 258 + expect(presentation.normalizedEmbeds.map((embed) => embed.kind)).toEqual([ 259 + "images", 260 + "video", 261 + "external", 262 + "unknown", 263 + ]); 264 + expect(presentation.unknownEmbeds).toHaveLength(1); 265 + }); 266 + 267 + it("normalizes every official top-level embed kind without treating them as unknown", () => { 268 + const fixtures = [ 269 + { 270 + expectedKind: "images", 271 + value: { 272 + $type: "app.bsky.embed.images#view", 273 + images: [{ fullsize: "https://cdn.example.com/top-image.png" }], 274 + }, 275 + }, 276 + { 277 + expectedKind: "video", 278 + value: { 279 + $type: "app.bsky.embed.video#view", 280 + playlist: "https://cdn.example.com/top-video.m3u8", 281 + }, 282 + }, 283 + { 284 + expectedKind: "external", 285 + value: { 286 + $type: "app.bsky.embed.external#view", 287 + external: { title: "External", uri: "https://example.com" }, 288 + }, 289 + }, 290 + { 291 + expectedKind: "record", 292 + value: { 293 + $type: "app.bsky.embed.record#view", 294 + record: { 295 + $type: "app.bsky.embed.record#viewRecord", 296 + author: { did: "did:plc:bob", handle: "bob.test" }, 297 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted-a", 298 + value: { text: "quoted a" }, 299 + }, 300 + }, 301 + }, 302 + { 303 + expectedKind: "recordWithMedia", 304 + value: { 305 + $type: "app.bsky.embed.recordWithMedia#view", 306 + media: { 307 + $type: "app.bsky.embed.images#view", 308 + images: [{ fullsize: "https://cdn.example.com/top-rwm-image.png" }], 309 + }, 310 + record: { 311 + $type: "app.bsky.embed.record#view", 312 + record: { 313 + $type: "app.bsky.embed.record#viewRecord", 314 + author: { did: "did:plc:bob", handle: "bob.test" }, 315 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted-b", 316 + value: { text: "quoted b" }, 317 + }, 318 + }, 319 + }, 320 + }, 321 + ] as const; 322 + 323 + for (const fixture of fixtures) { 324 + const normalized = normalizeEmbed(fixture.value, { source: "top" }); 325 + expect(normalized.kind).toBe(fixture.expectedKind); 326 + expect(normalized.kind).not.toBe("unknown"); 327 + if (normalized.kind === "record" || normalized.kind === "recordWithMedia") { 328 + expect(normalized.quoted.unknownEmbeds).toHaveLength(0); 329 + } 330 + } 331 + }); 332 + 333 + it("covers official quoted record union variants without emitting unknown embeds", () => { 334 + const fixtures = [ 335 + { 336 + expectedKind: "post", 337 + record: { 338 + $type: "app.bsky.embed.record#viewRecord", 339 + author: { did: "did:plc:bob", handle: "bob.test" }, 340 + uri: "at://did:plc:bob/app.bsky.feed.post/1", 341 + value: { text: "post record" }, 342 + }, 343 + }, 344 + { 345 + expectedKind: "not-found", 346 + record: { $type: "app.bsky.embed.record#viewNotFound", notFound: true, uri: "at://did:plc:bob/app.bsky.feed.post/2" }, 347 + }, 348 + { 349 + expectedKind: "blocked", 350 + record: { $type: "app.bsky.embed.record#viewBlocked", blocked: true, uri: "at://did:plc:bob/app.bsky.feed.post/3" }, 351 + }, 352 + { 353 + expectedKind: "detached", 354 + record: { $type: "app.bsky.embed.record#viewDetached", detached: true, uri: "at://did:plc:bob/app.bsky.feed.post/4" }, 355 + }, 356 + { 357 + expectedKind: "feed", 358 + record: { 359 + $type: "app.bsky.feed.defs#generatorView", 360 + creator: { did: "did:plc:bob", handle: "bob.test" }, 361 + uri: "at://did:plc:bob/app.bsky.feed.generator/following", 362 + }, 363 + }, 364 + { 365 + expectedKind: "list", 366 + record: { 367 + $type: "app.bsky.graph.defs#listView", 368 + creator: { did: "did:plc:bob", handle: "bob.test" }, 369 + uri: "at://did:plc:bob/app.bsky.graph.list/curated", 370 + }, 371 + }, 372 + { 373 + expectedKind: "labeler", 374 + record: { 375 + $type: "app.bsky.labeler.defs#labelerView", 376 + creator: { did: "did:plc:bob", handle: "bob.test" }, 377 + uri: "at://did:plc:bob/app.bsky.labeler.service/self", 378 + }, 379 + }, 380 + { 381 + expectedKind: "starter-pack", 382 + record: { 383 + $type: "app.bsky.graph.defs#starterPackViewBasic", 384 + creator: { did: "did:plc:bob", handle: "bob.test" }, 385 + record: { name: "Starter Pack" }, 386 + uri: "at://did:plc:bob/app.bsky.graph.starterpack/abc123", 387 + }, 388 + }, 389 + ] as const; 390 + 391 + for (const fixture of fixtures) { 392 + const presentation = getQuotedPresentation({ 393 + $type: "app.bsky.embed.record#view", 394 + record: fixture.record, 395 + }); 396 + 397 + expect(presentation.kind).toBe(fixture.expectedKind); 398 + expect(presentation.unknownEmbeds).toHaveLength(0); 399 + } 400 + }); 401 + 402 + it("infers malformed but recognizable embed shapes without adding unknown embeds", () => { 403 + const inferredImages = normalizeEmbed( 404 + { images: [{ fullsize: "https://cdn.example.com/inferred-image.png" }] }, 405 + { source: "quoted" }, 406 + ); 407 + const inferredVideo = normalizeEmbed( 408 + { playlist: "https://cdn.example.com/inferred-video.m3u8" }, 409 + { source: "quoted" }, 410 + ); 411 + const inferredExternal = normalizeEmbed( 412 + { external: { title: "Inferred external", uri: "https://example.com/inferred" } }, 413 + { source: "quoted" }, 414 + ); 415 + const inferredRecord = normalizeEmbed( 416 + { record: { uri: "at://did:plc:bob/app.bsky.feed.post/inferred" } }, 417 + { source: "quoted" }, 418 + ); 419 + const inferredRecordWithMedia = normalizeEmbed( 420 + { 421 + media: { images: [{ fullsize: "https://cdn.example.com/inferred-rwm.png" }] }, 422 + record: { uri: "at://did:plc:bob/app.bsky.feed.post/inferred-rwm" }, 423 + }, 424 + { source: "quoted" }, 425 + ); 426 + 427 + expect(inferredImages.kind).toBe("images"); 428 + expect(inferredVideo.kind).toBe("video"); 429 + expect(inferredExternal.kind).toBe("external"); 430 + expect(inferredRecord.kind).toBe("record"); 431 + expect(inferredRecordWithMedia.kind).toBe("recordWithMedia"); 432 + }); 433 + 434 + it("uses quoted embed extraction precedence: embeds > value.embed > value.embeds", () => { 435 + const fromEmbeds = getQuotedPresentation({ 436 + $type: "app.bsky.embed.record#view", 437 + record: { 438 + $type: "app.bsky.embed.record#viewRecord", 439 + embeds: [{ $type: "app.bsky.embed.images#view", images: [{ fullsize: "https://cdn.example.com/a.png" }] }], 440 + uri: "at://did:plc:bob/app.bsky.feed.post/a", 441 + value: { 442 + embed: { $type: "app.bsky.embed.video#view", playlist: "https://cdn.example.com/a.m3u8" }, 443 + embeds: [{ $type: "app.bsky.embed.external#view", external: { title: "A", uri: "https://example.com/a" } }], 444 + text: "a", 445 + }, 446 + }, 447 + }); 448 + const fromValueEmbed = getQuotedPresentation({ 449 + $type: "app.bsky.embed.record#view", 450 + record: { 451 + $type: "app.bsky.embed.record#viewRecord", 452 + uri: "at://did:plc:bob/app.bsky.feed.post/b", 453 + value: { 454 + embed: { $type: "app.bsky.embed.video#view", playlist: "https://cdn.example.com/b.m3u8" }, 455 + embeds: [{ $type: "app.bsky.embed.external#view", external: { title: "B", uri: "https://example.com/b" } }], 456 + text: "b", 457 + }, 458 + }, 459 + }); 460 + const fromValueEmbeds = getQuotedPresentation({ 461 + $type: "app.bsky.embed.record#view", 462 + record: { 463 + $type: "app.bsky.embed.record#viewRecord", 464 + uri: "at://did:plc:bob/app.bsky.feed.post/c", 465 + value: { 466 + embeds: [{ $type: "app.bsky.embed.external#view", external: { title: "C", uri: "https://example.com/c" } }], 467 + text: "c", 468 + }, 469 + }, 470 + }); 471 + 472 + expect(fromEmbeds.normalizedEmbeds).toHaveLength(1); 473 + expect(fromEmbeds.normalizedEmbeds[0]?.kind).toBe("images"); 474 + expect(fromEmbeds.normalizedEmbeds[0]?.meta.source).toBe("viewRecord.embeds"); 475 + 476 + expect(fromValueEmbed.normalizedEmbeds).toHaveLength(1); 477 + expect(fromValueEmbed.normalizedEmbeds[0]?.kind).toBe("video"); 478 + expect(fromValueEmbed.normalizedEmbeds[0]?.meta.source).toBe("value.embed"); 479 + 480 + expect(fromValueEmbeds.normalizedEmbeds).toHaveLength(1); 481 + expect(fromValueEmbeds.normalizedEmbeds[0]?.kind).toBe("external"); 482 + expect(fromValueEmbeds.normalizedEmbeds[0]?.meta.source).toBe("value.embeds"); 483 + }); 484 + 485 + it("keeps unknown custom embeds visible and aggregates telemetry by fingerprint", () => { 486 + const custom = { $type: "dev.example.embed#view", payload: { nested: { key: "value" } } }; 487 + const topUnknownA = normalizeEmbed(custom, { source: "top" }); 488 + const topUnknownB = normalizeEmbed(custom, { source: "top" }); 489 + const quoted = getQuotedPresentation({ 490 + $type: "app.bsky.embed.record#view", 491 + record: { 492 + $type: "app.bsky.embed.record#viewRecord", 493 + embeds: [ 494 + { $type: "app.bsky.embed.images#view", images: [{ fullsize: "https://cdn.example.com/known.png" }] }, 495 + custom, 496 + ], 497 + uri: "at://did:plc:bob/app.bsky.feed.post/custom", 498 + value: { text: "custom quote" }, 499 + }, 500 + }); 501 + 502 + expect(topUnknownA.kind).toBe("unknown"); 503 + expect(topUnknownB.kind).toBe("unknown"); 504 + expect(quoted.normalizedEmbeds.map((embed) => embed.kind)).toEqual(["images", "unknown"]); 505 + expect(quoted.unknownEmbeds).toHaveLength(1); 506 + 507 + const telemetry = [...getUnknownEmbedTelemetryForTests().values()]; 508 + expect(telemetry.length).toBeGreaterThan(0); 509 + expect(Math.max(...telemetry)).toBeGreaterThanOrEqual(2); 510 + }); 511 + 512 + it("guards against deep nesting and embed cycles", () => { 513 + const deep = { 514 + $type: "app.bsky.embed.record#view", 515 + record: { 516 + $type: "app.bsky.embed.record#viewRecord", 517 + embeds: [{ 518 + $type: "app.bsky.embed.record#view", 519 + record: { 520 + $type: "app.bsky.embed.record#viewRecord", 521 + embeds: [{ 522 + $type: "app.bsky.embed.record#view", 523 + record: { 524 + $type: "app.bsky.embed.record#viewRecord", 525 + embeds: [{ 526 + $type: "app.bsky.embed.record#view", 527 + record: { 528 + $type: "app.bsky.embed.record#viewRecord", 529 + embeds: [{ 530 + $type: "app.bsky.embed.record#view", 531 + record: { 532 + $type: "app.bsky.embed.record#viewRecord", 533 + uri: "at://did:plc:bob/app.bsky.feed.post/deep-leaf", 534 + value: { text: "leaf" }, 535 + }, 536 + }], 537 + uri: "at://did:plc:bob/app.bsky.feed.post/deep-4", 538 + value: { text: "deep-4" }, 539 + }, 540 + }], 541 + uri: "at://did:plc:bob/app.bsky.feed.post/deep-3", 542 + value: { text: "deep-3" }, 543 + }, 544 + }], 545 + uri: "at://did:plc:bob/app.bsky.feed.post/deep-2", 546 + value: { text: "deep-2" }, 547 + }, 548 + }], 549 + uri: "at://did:plc:bob/app.bsky.feed.post/deep-root", 550 + value: { text: "deep-root" }, 551 + }, 552 + }; 553 + const depthLimited = normalizeEmbed(deep, { maxDepth: 3, source: "top" }); 554 + const seen: NormalizedEmbed[] = []; 555 + walkNormalizedEmbeds(depthLimited, (embed) => seen.push(embed)); 556 + 557 + expect(seen.some((embed) => embed.meta.depthLimited)).toBe(true); 558 + 559 + const cycleRoot: Record<string, unknown> = { 560 + $type: "app.bsky.embed.record#view", 561 + record: { 562 + $type: "app.bsky.embed.record#viewRecord", 563 + uri: "at://did:plc:bob/app.bsky.feed.post/cycle-root", 564 + value: { text: "cycle root" }, 565 + }, 566 + }; 567 + const cycleRecord = cycleRoot.record as Record<string, unknown>; 568 + cycleRecord.embeds = [cycleRoot]; 569 + 570 + const cycleNormalized = normalizeEmbed(cycleRoot, { source: "top" }); 571 + expect(cycleNormalized.kind).toBe("record"); 572 + if (cycleNormalized.kind === "record") { 573 + expect(cycleNormalized.quoted.normalizedEmbeds).toHaveLength(1); 574 + expect(cycleNormalized.quoted.normalizedEmbeds[0]?.kind).toBe("recognized-unrenderable"); 575 + expect(cycleNormalized.quoted.normalizedEmbeds[0]?.meta.cycle).toBe(true); 576 + } 577 + }); 578 + 579 + it("builds starter-pack and labeler external links", () => { 580 + const starterPack = getQuotedPresentation({ 581 + $type: "app.bsky.embed.record#view", 582 + record: { 583 + $type: "app.bsky.graph.defs#starterPackViewBasic", 584 + creator: { did: "did:plc:alice", handle: "alice.test" }, 585 + uri: "at://did:plc:alice/app.bsky.graph.starterpack/3lxyx7z7p2f2u", 586 + }, 587 + }); 588 + const labeler = getQuotedPresentation({ 589 + $type: "app.bsky.embed.record#view", 590 + record: { 591 + $type: "app.bsky.labeler.defs#labelerView", 592 + creator: { did: "did:plc:labeler123", handle: "labeler.example" }, 593 + uri: "at://did:plc:labeler123/app.bsky.labeler.service/self", 594 + }, 595 + }); 596 + 597 + expect(starterPack.href).toBe("https://bsky.app/starter-pack/did%3Aplc%3Aalice/3lxyx7z7p2f2u"); 598 + expect(labeler.href).toBe("https://bsky.app/profile/labeler.example"); 149 599 }); 150 600 151 601 it("falls back to did-based post urls when handle is missing", () => {
+62 -12
src/lib/types.ts
··· 203 203 external: { description?: string; thumb?: string; title?: string; uri?: string }; 204 204 }; 205 205 206 - type EmbeddedQuoteRecord = { 207 - $type?: string; 206 + type VideoEmbedView = { 207 + $type: "app.bsky.embed.video#view"; 208 + alt?: string; 209 + aspectRatio?: { height: number; width: number }; 210 + playlist?: string; 211 + thumbnail?: string; 212 + }; 213 + 214 + type EmbeddedRecordViewRecord = { 215 + $type?: "app.bsky.embed.record#viewRecord"; 208 216 author?: ProfileViewBasic; 209 217 cid?: string; 210 218 embeds?: EmbedView[]; 219 + indexedAt?: string; 211 220 labels?: ModerationLabel[] | null; 212 221 uri?: string; 213 222 value?: Record<string, unknown>; 214 223 }; 215 224 216 - type RecordEmbedView = { $type: "app.bsky.embed.record#view"; record: EmbeddedQuoteRecord }; 225 + type EmbeddedRecordViewBlocked = { 226 + $type?: "app.bsky.embed.record#viewBlocked"; 227 + author?: ProfileViewBasic; 228 + blocked?: boolean; 229 + uri?: string; 230 + }; 231 + 232 + type EmbeddedRecordViewDetached = { $type?: "app.bsky.embed.record#viewDetached"; detached?: boolean; uri?: string }; 233 + 234 + type EmbeddedRecordViewNotFound = { $type?: "app.bsky.embed.record#viewNotFound"; notFound?: boolean; uri?: string }; 235 + 236 + type EmbeddedGeneratorView = { 237 + $type: "app.bsky.feed.defs#generatorView"; 238 + creator?: ProfileViewBasic; 239 + description?: string; 240 + displayName?: string; 241 + uri?: string; 242 + }; 243 + 244 + type EmbeddedListView = { 245 + $type: "app.bsky.graph.defs#listView"; 246 + creator?: ProfileViewBasic; 247 + description?: string; 248 + name?: string; 249 + uri?: string; 250 + }; 251 + 252 + type EmbeddedLabelerView = { $type: "app.bsky.labeler.defs#labelerView"; creator?: ProfileViewBasic; uri?: string }; 253 + 254 + type EmbeddedStarterPackView = { 255 + $type: "app.bsky.graph.defs#starterPackViewBasic"; 256 + creator?: ProfileViewBasic; 257 + record?: Record<string, unknown>; 258 + uri?: string; 259 + }; 260 + 261 + type EmbeddedUnknownRecord = { $type?: string; [key: string]: unknown }; 262 + 263 + export type EmbeddedRecordView = 264 + | EmbeddedGeneratorView 265 + | EmbeddedLabelerView 266 + | EmbeddedListView 267 + | EmbeddedRecordViewBlocked 268 + | EmbeddedRecordViewDetached 269 + | EmbeddedRecordViewNotFound 270 + | EmbeddedRecordViewRecord 271 + | EmbeddedStarterPackView 272 + | EmbeddedUnknownRecord; 273 + 274 + type RecordEmbedView = { $type: "app.bsky.embed.record#view"; record: EmbeddedRecordView }; 217 275 218 276 type RecordWithMediaEmbedView = { 219 277 $type: "app.bsky.embed.recordWithMedia#view"; 220 - media?: EmbedView; 278 + media?: ExternalEmbedView | ImagesEmbedView | VideoEmbedView | EmbeddedUnknownRecord; 221 279 record?: RecordEmbedView; 222 - }; 223 - 224 - type VideoEmbedView = { 225 - $type: "app.bsky.embed.video#view"; 226 - alt?: string; 227 - aspectRatio?: { height: number; width: number }; 228 - playlist?: string; 229 - thumbnail?: string; 230 280 }; 231 281 232 282 export type EmbedView =
+67
src/lib/utils/text.ts
··· 1 1 import type { LogEntry, Maybe } from "$/lib/types"; 2 2 3 + const MAX_JSON_PREVIEW_CHARS = 6000; 4 + 3 5 export function escapeForRegex(value: string) { 4 6 return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); 5 7 } ··· 102 104 export function clamp(value: number, min: number, max: number) { 103 105 return Math.min(Math.max(value, min), max); 104 106 } 107 + 108 + export function hashString(value: string) { 109 + let hash = 0x81_1C_9D_C5; 110 + for (let index = 0; index < value.length; index += 1) { 111 + hash ^= value.codePointAt(index)!; 112 + hash = Math.imul(hash, 0x01_00_01_93); 113 + } 114 + 115 + return (hash >>> 0).toString(16).padStart(8, "0"); 116 + } 117 + 118 + export function stringifyUnknown(value: unknown) { 119 + const seen = new WeakSet<object>(); 120 + 121 + try { 122 + const json = JSON.stringify(value, (_, current) => { 123 + if (typeof current !== "object" || current === null) { 124 + return current; 125 + } 126 + 127 + if (seen.has(current)) { 128 + return "[Circular]"; 129 + } 130 + seen.add(current); 131 + return current; 132 + }, 2); 133 + 134 + if (!json) { 135 + return "null"; 136 + } 137 + 138 + if (json.length <= MAX_JSON_PREVIEW_CHARS) { 139 + return json; 140 + } 141 + 142 + return `${json.slice(0, MAX_JSON_PREVIEW_CHARS)}\n...`; 143 + } catch { 144 + return String(value); 145 + } 146 + } 147 + 148 + export function formatRelativeTime(value: string) { 149 + const timestamp = new Date(value).getTime(); 150 + if (Number.isNaN(timestamp)) { 151 + return ""; 152 + } 153 + 154 + const deltaSeconds = Math.round((timestamp - Date.now()) / 1000); 155 + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 156 + const ranges = [ 157 + ["year", 60 * 60 * 24 * 365], 158 + ["month", 60 * 60 * 24 * 30], 159 + ["day", 60 * 60 * 24], 160 + ["hour", 60 * 60], 161 + ["minute", 60], 162 + ] as const; 163 + 164 + for (const [unit, seconds] of ranges) { 165 + if (Math.abs(deltaSeconds) >= seconds) { 166 + return formatter.format(Math.round(deltaSeconds / seconds), unit); 167 + } 168 + } 169 + 170 + return formatter.format(deltaSeconds, "second"); 171 + }
src/lib/utils/typing.ts

This is a binary file and will not be displayed.