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.

fix: quote nesting & linking

+575 -264
+3 -3
docs/todo.md
··· 1 1 --- 2 2 title: "To-Do List/Parking Lot" 3 - updated: 2026-04-12 3 + updated: 2026-04-13 4 4 --- 5 5 6 6 ## Pre-release 7 7 8 8 ### Bugs 9 9 10 - - [ ] Quoted posts should link to the original post 11 - - [ ] They should also nest properly 10 + - [x] Quoted posts should link to the original post 11 + - [x] They should also nest properly 12 12 13 13 ### High Priority Updates 14 14
+1 -1
src/components/feeds/FeedContent.tsx
··· 65 65 onFocus={() => props.onFocusIndex(index())} 66 66 onLike={() => void props.onLike(item.post)} 67 67 onOpenEngagement={(tab) => void props.onOpenEngagement(item.post.uri, tab)} 68 - onOpenThread={() => void props.onOpenThread(item.post.uri)} 68 + onOpenThread={(uri) => void props.onOpenThread(uri)} 69 69 onQuote={() => props.onQuote(item.post)} 70 70 onReply={() => props.onReply(item.post, getReplyRootPost(item))} 71 71 onRepost={() => void props.onRepost(item.post)}
+8 -5
src/components/feeds/PostCard.tsx
··· 335 335 onFocus?: () => void; 336 336 onLike?: () => void; 337 337 onOpenEngagement?: (tab: PostEngagementTab) => void; 338 - onOpenThread?: () => void; 338 + onOpenThread?: (uri: string) => void; 339 339 onQuote?: () => void; 340 340 onReply?: () => void; 341 341 onRepost?: () => void; ··· 380 380 const mergedPostDecision = createMemo(() => mergeModerationDecisions(contentDecision(), mediaDecision())); 381 381 const hasPostText = createMemo(() => postText().trim().length > 0); 382 382 const showThreadAction = createMemo(() => hasKnownThreadContext(view.post, view.item)); 383 + const openThread = (uri: string = view.post.uri) => { 384 + interactions.onOpenThread?.(uri); 385 + }; 383 386 const reasonLabel = createMemo(() => { 384 387 const reason = view.item?.reason; 385 388 if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { ··· 454 457 }); 455 458 456 459 if (interactions.onOpenThread && showThreadAction()) { 457 - items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: interactions.onOpenThread }); 460 + items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: () => openThread() }); 458 461 } 459 462 460 463 if (interactions.onOpenEngagement) { ··· 625 628 </div> 626 629 627 630 <div class="min-w-0 flex-1"> 628 - <PostPrimaryRegion onFocus={interactions.onFocus} onOpenThread={interactions.onOpenThread}> 631 + <PostPrimaryRegion onFocus={interactions.onFocus} onOpenThread={() => openThread()}> 629 632 <PostHeader 630 633 authorName={authorName()} 631 634 authorHandle={authorHandle()} ··· 644 647 mediaLabels={mediaLabels()} 645 648 mergeBodyAndEmbedModeration={mergeBodyAndEmbedModeration()} 646 649 mergedPostDecision={mergedPostDecision()} 647 - onOpenPost={interactions.onOpenThread} 650 + onOpenPost={openThread} 648 651 post={view.post} 649 652 text={postText()} /> 650 653 </PostPrimaryRegion> ··· 661 664 662 665 interactions.onLike?.(); 663 666 }, 664 - onOpenThread: interactions.onOpenThread, 667 + onOpenThread: () => openThread(), 665 668 onQuote: (event) => { 666 669 if (event.shiftKey && interactions.onOpenEngagement) { 667 670 interactions.onOpenEngagement("quotes");
+26 -37
src/components/feeds/embeds/ContentEmbed.tsx
··· 28 28 const uri = quotedUri(); 29 29 return uri ? `#${buildPostRoute(uri)}` : null; 30 30 }); 31 + const quotedHref = createMemo(() => quotedInternalHref() ?? quotedExternalHref()); 31 32 32 33 const openQuotedPost = () => { 33 34 const uri = quotedUri(); ··· 54 55 }); 55 56 56 57 return ( 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} /> 58 + <QuotedPostPreview 59 + author={props.quoted.author} 60 + emptyText={props.quoted.emptyText} 61 + facets={props.quoted.facets} 62 + href={quotedHref()} 63 + onOpenPost={quotedUri() && props.onOpenPost ? openQuotedPost : undefined} 64 + text={props.quoted.text} 65 + title={props.quoted.title}> 66 66 <Show when={quotedPostForEmbeds()}> 67 67 {(quotedPost) => ( 68 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> 69 + <For each={props.quoted.normalizedEmbeds}> 70 + {(embed) => ( 71 + <EmbedContent depth={props.depth + 1} embed={embed} onOpenPost={props.onOpenPost} post={quotedPost()} /> 72 + )} 73 + </For> 80 74 </Show> 81 75 )} 82 76 </Show> 83 - </> 77 + </QuotedPostPreview> 84 78 ); 85 79 } 86 80 ··· 131 125 ); 132 126 } 133 127 case "recordWithMedia": { 128 + const quotedWithMedia: QuotedRecordPresentation = { 129 + ...embed.quoted, 130 + normalizedEmbeds: embed.media 131 + ? [embed.media, ...embed.quoted.normalizedEmbeds] 132 + : embed.quoted.normalizedEmbeds, 133 + }; 134 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> 135 + <RenderQuotedPreview 136 + depth={depth()} 137 + post={props.post} 138 + quoted={quotedWithMedia} 139 + onOpenPost={props.onOpenPost} /> 151 140 ); 152 141 } 153 142 default: {
+97 -21
src/components/feeds/tests/PostCard.test.tsx
··· 1 + import { buildPostRoute } from "$/lib/post-routes"; 1 2 import { buildHashtagRoute } from "$/lib/search-routes"; 2 - import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 3 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 5 import { PostCard } from "../PostCard"; 5 6 ··· 275 276 expect(screen.getByAltText("Preview image")).toHaveAttribute("src", "https://cdn.example.com/image.png"); 276 277 expect(screen.getByText("Quoted post")).toBeInTheDocument(); 277 278 expect(screen.getByText("Quoted body")).toBeInTheDocument(); 279 + const quotedCard = screen.getByText("Quoted post").closest(".ui-input-strong"); 280 + expect(quotedCard).not.toBeNull(); 281 + expect(within(quotedCard as HTMLElement).getByAltText("Preview image")).toBeInTheDocument(); 278 282 279 - fireEvent.click(screen.getByRole("button", { name: /quoted body/i })); 283 + const quotedLink = screen.getByRole("link", { name: /quoted body/i }); 284 + expect(quotedLink).toHaveAttribute("href", `#${buildPostRoute("at://did:plc:bob/app.bsky.feed.post/quoted")}`); 285 + 286 + fireEvent.click(quotedLink); 280 287 281 288 expect(onOpenThread).toHaveBeenCalledTimes(1); 282 289 expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/quoted"); ··· 292 299 record: { 293 300 $type: "app.bsky.embed.record#viewRecord", 294 301 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 - ], 302 + embeds: [{ 303 + $type: "app.bsky.embed.images#view", 304 + images: [{ alt: "Quoted image", fullsize: "https://cdn.example.com/quoted-image.png" }], 305 + }, { 306 + $type: "app.bsky.embed.video#view", 307 + alt: "Quoted clip", 308 + playlist: "https://cdn.example.com/quoted-video.m3u8", 309 + thumbnail: "https://cdn.example.com/quoted-video-thumb.jpg", 310 + }], 307 311 uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 308 312 value: { text: "Quoted body with media" }, 309 313 }, ··· 316 320 expect(screen.getByText("Quoted clip")).toBeInTheDocument(); 317 321 }); 318 322 323 + it("renders quoted postView media and opens that quoted thread", () => { 324 + const onOpenThread = vi.fn(); 325 + render(() => ( 326 + <PostCard 327 + post={{ 328 + ...createPost(), 329 + embed: { 330 + $type: "app.bsky.embed.record#view", 331 + record: { 332 + $type: "app.bsky.feed.defs#postView", 333 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 334 + record: { 335 + text: "Quoted postView body", 336 + embed: { 337 + $type: "app.bsky.embed.images#view", 338 + images: [{ alt: "Quoted postView image", fullsize: "https://cdn.example.com/postview-image.png" }], 339 + }, 340 + }, 341 + uri: "at://did:plc:bob/app.bsky.feed.post/postview", 342 + }, 343 + }, 344 + }} 345 + onOpenThread={onOpenThread} /> 346 + )); 347 + 348 + expect(screen.getByAltText("Quoted postView image")).toHaveAttribute( 349 + "src", 350 + "https://cdn.example.com/postview-image.png", 351 + ); 352 + const quotedLink = screen.getByRole("link", { name: /quoted postview body/i }); 353 + expect(quotedLink).toHaveAttribute("href", `#${buildPostRoute("at://did:plc:bob/app.bsky.feed.post/postview")}`); 354 + 355 + fireEvent.click(quotedLink); 356 + expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/postview"); 357 + }); 358 + 359 + it("renders blob-backed quoted record images and opens quoted thread uri", () => { 360 + const onOpenThread = vi.fn(); 361 + render(() => ( 362 + <PostCard 363 + post={{ 364 + ...createPost(), 365 + embed: { 366 + $type: "app.bsky.embed.record#view", 367 + record: { 368 + $type: "app.bsky.feed.defs#postView", 369 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 370 + record: { 371 + embed: { 372 + $type: "app.bsky.embed.images", 373 + images: [{ 374 + alt: "Blob-backed image", 375 + image: { mimeType: "image/jpeg", ref: { $link: "bafyblobimg" } }, 376 + }], 377 + }, 378 + text: "Blob-backed quote", 379 + }, 380 + uri: "at://did:plc:bob/app.bsky.feed.post/blob-post", 381 + }, 382 + }, 383 + }} 384 + onOpenThread={onOpenThread} /> 385 + )); 386 + 387 + expect(screen.getByAltText("Blob-backed image")).toHaveAttribute( 388 + "src", 389 + "https://cdn.bsky.app/img/feed_fullsize/plain/did%3Aplc%3Abob/bafyblobimg@jpeg", 390 + ); 391 + fireEvent.click(screen.getByRole("link", { name: /blob-backed quote/i })); 392 + expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/blob-post"); 393 + }); 394 + 319 395 it("renders quoted external card embeds from the quoted record", () => { 320 396 render(() => ( 321 397 <PostCard ··· 328 404 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 329 405 embeds: [{ 330 406 $type: "app.bsky.embed.external#view", 331 - external: { 332 - description: "Deep dive", 333 - title: "External article", 334 - uri: "https://example.com/article", 335 - }, 407 + external: { description: "Deep dive", title: "External article", uri: "https://example.com/article" }, 336 408 }], 337 409 uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 338 410 value: { text: "Quoted body with external card" }, ··· 341 413 }} /> 342 414 )); 343 415 344 - expect(screen.getByRole("link", { name: /external article/i })).toHaveAttribute("href", "https://example.com/article"); 416 + expect(screen.getByRole("link", { name: /external article/i })).toHaveAttribute( 417 + "href", 418 + "https://example.com/article", 419 + ); 345 420 }); 346 421 347 422 it("renders feed generator record embeds with feed metadata and external links", () => { ··· 431 506 expect(screen.getByText("Outer quote")).toBeInTheDocument(); 432 507 expect(screen.queryByText("Nested record")).not.toBeInTheDocument(); 433 508 expect(screen.getAllByText("Quoted post")).toHaveLength(1); 434 - expect(screen.queryByText("This recognized media type is not valid in recordWithMedia.media.")).not.toBeInTheDocument(); 509 + expect(screen.queryByText("This recognized media type is not valid in recordWithMedia.media.")).not 510 + .toBeInTheDocument(); 435 511 }); 436 512 437 513 it("does not show unsupported embed fallback cards for custom quoted embeds", () => {
+3 -3
src/components/posts/PostPanel.tsx
··· 214 214 onBookmark={() => props.onBookmark(parent.post)} 215 215 onLike={() => props.onLike(parent.post)} 216 216 onOpenEngagement={(tab) => props.onOpenEngagement(parent.post.uri, tab)} 217 - onOpenThread={() => props.onOpenPost(parent.post.uri)} 217 + onOpenThread={(uri) => props.onOpenPost(uri)} 218 218 onRepost={() => props.onRepost(parent.post)} 219 219 post={parent.post} 220 220 repostPending={!!props.repostPendingByUri[parent.post.uri]} ··· 230 230 onBookmark={() => props.onBookmark(focused().post)} 231 231 onLike={() => props.onLike(focused().post)} 232 232 onOpenEngagement={(tab) => props.onOpenEngagement(focused().post.uri, tab)} 233 - onOpenThread={() => props.onOpenPost(focused().post.uri)} 233 + onOpenThread={(uri) => props.onOpenPost(uri)} 234 234 onRepost={() => props.onRepost(focused().post)} 235 235 post={focused().post} 236 236 repostPending={!!props.repostPendingByUri[focused().post.uri]} /> ··· 292 292 onBookmark={() => props.onBookmark(current().post)} 293 293 onLike={() => props.onLike(current().post)} 294 294 onOpenEngagement={(tab) => props.onOpenEngagement(current().post.uri, tab)} 295 - onOpenThread={() => props.onOpenPost(current().post.uri)} 295 + onOpenThread={(uri) => props.onOpenPost(uri)} 296 296 onRepost={() => props.onRepost(current().post)} 297 297 post={current().post} 298 298 repostPending={!!props.repostPendingByUri[current().post.uri]} />
+1 -1
src/components/posts/ThreadDrawer.tsx
··· 238 238 onBookmark={() => props.onBookmark(threadNode().post)} 239 239 onLike={() => props.onLike(threadNode().post)} 240 240 onOpenEngagement={(tab) => props.onOpenEngagement(threadNode().post.uri, tab)} 241 - onOpenThread={() => props.onOpenThread(threadNode().post.uri)} 241 + onOpenThread={(uri) => props.onOpenThread(uri)} 242 242 onRepost={() => props.onRepost(threadNode().post)} 243 243 post={threadNode().post} 244 244 repostPending={!!props.repostPendingByUri[threadNode().post.uri]} />
+68 -3
src/components/posts/tests/PostPanel.test.tsx
··· 1 - import { decodePostRouteUri } from "$/lib/post-routes"; 1 + import { buildPostRoute, decodePostRouteUri } from "$/lib/post-routes"; 2 2 import { AppTestProviders } from "$/test/providers"; 3 3 import { HashRouter, Route, useParams } from "@solidjs/router"; 4 - import { render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 5 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 6 6 import { PostPanel } from "../PostPanel"; 7 7 ··· 9 9 10 10 vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 11 11 12 - function createThreadPayload(uri: string, text: string) { 12 + function createThreadPayload(uri: string, text: string, embed?: Record<string, unknown>) { 13 13 return { 14 14 thread: { 15 15 $type: "app.bsky.feed.defs#threadViewPost", ··· 23 23 repostCount: 0, 24 24 uri, 25 25 viewer: {}, 26 + ...(embed ? { embed } : {}), 26 27 }, 27 28 replies: [], 28 29 }, ··· 131 132 132 133 await waitFor(() => expect(screen.queryByText("Post A")).not.toBeInTheDocument()); 133 134 expect(screen.getByText("Post B")).toBeInTheDocument(); 135 + }); 136 + 137 + it("renders nested quoted posts in post views and links quotes to their original post route", async () => { 138 + const uri = "at://did:plc:alice/app.bsky.feed.post/a"; 139 + const quotedUri = "at://did:plc:bob/app.bsky.feed.post/quoted"; 140 + const nestedQuotedUri = "at://did:plc:carol/app.bsky.feed.post/nested"; 141 + const embed = { 142 + $type: "app.bsky.embed.record#view", 143 + record: { 144 + $type: "app.bsky.embed.record#viewRecord", 145 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 146 + embeds: [{ 147 + $type: "app.bsky.embed.record#view", 148 + record: { 149 + $type: "app.bsky.embed.record#viewRecord", 150 + author: { did: "did:plc:carol", handle: "carol.test", displayName: "Carol" }, 151 + uri: nestedQuotedUri, 152 + value: { text: "Nested quoted post" }, 153 + }, 154 + }], 155 + uri: quotedUri, 156 + value: { text: "Quoted post body" }, 157 + }, 158 + } as const; 159 + 160 + invokeMock.mockImplementation((command: string, args?: { uri?: string }) => { 161 + if (command !== "get_post_thread") { 162 + throw new Error(`unexpected invoke: ${command}`); 163 + } 164 + 165 + if (args?.uri === uri) { 166 + return Promise.resolve(createThreadPayload(uri, "Root post", embed)); 167 + } 168 + 169 + if (args?.uri === quotedUri) { 170 + return Promise.resolve(createThreadPayload(quotedUri, "Opened quoted thread")); 171 + } 172 + 173 + throw new Error(`unexpected uri: ${args?.uri}`); 174 + }); 175 + 176 + globalThis.location.hash = `#/post/${encodeURIComponent(uri)}`; 177 + 178 + render(() => ( 179 + <AppTestProviders 180 + session={{ 181 + activeDid: "did:plc:alice", 182 + activeHandle: "alice.test", 183 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 184 + }}> 185 + <HashRouter> 186 + <Route path="/post/:encodedUri" component={TestPostRoute} /> 187 + </HashRouter> 188 + </AppTestProviders> 189 + )); 190 + 191 + expect(await screen.findByText("Quoted post body")).toBeInTheDocument(); 192 + expect(screen.getByText("Nested quoted post")).toBeInTheDocument(); 193 + const quotedLink = screen.getByRole("link", { name: /quoted post body/i }); 194 + expect(quotedLink).toHaveAttribute("href", `#${buildPostRoute(quotedUri)}`); 195 + 196 + globalThis.location.hash = `#${buildPostRoute(uri)}`; 197 + fireEvent.click(quotedLink); 198 + expect(await screen.findByText("Opened quoted thread")).toBeInTheDocument(); 134 199 }); 135 200 }); 136 201
+1 -1
src/components/profile/ProfileFeed.tsx
··· 31 31 onOpenEngagement={(tab) => props.onOpenEngagement(item.post.uri, tab)} 32 32 post={item.post} 33 33 item={item} 34 - onOpenThread={() => props.onOpenThread(item.post.uri)} 34 + onOpenThread={(uri) => props.onOpenThread(uri)} 35 35 onRepost={() => props.onRepost(item.post)} 36 36 repostPending={!!props.repostPendingByUri[item.post.uri]} /> 37 37 )}
+1 -1
src/components/saved/SavedPostsPanel.tsx
··· 635 635 <PostCard 636 636 post={toSavedPost(result)} 637 637 showActions={false} 638 - onOpenThread={() => props.onOpenThread(result.uri)} /> 638 + onOpenThread={(uri) => props.onOpenThread(uri)} /> 639 639 </Motion.div> 640 640 )} 641 641 </For>
+1 -1
src/components/search/HashtagPanel.tsx
··· 153 153 <PostCard 154 154 post={post} 155 155 showActions={false} 156 - onOpenThread={() => props.onOpenThread(post.uri)} /> 156 + onOpenThread={(uri) => props.onOpenThread(uri)} /> 157 157 </Motion.div> 158 158 )} 159 159 </For>
+1 -1
src/components/search/SearchPanel.tsx
··· 715 715 <PostCard 716 716 post={post} 717 717 showActions={false} 718 - onOpenThread={() => props.onOpenThread(post.uri)} /> 718 + onOpenThread={(uri) => props.onOpenThread(uri)} /> 719 719 </Motion.div> 720 720 )} 721 721 </For>
+59 -43
src/components/shared/QuotedPostPreview.tsx
··· 2 2 import { getDisplayName } from "$/lib/feeds"; 3 3 import type { ProfileViewBasic, RichTextFacet } from "$/lib/types"; 4 4 import { formatHandle } from "$/lib/utils/text"; 5 - import { createMemo, Show } from "solid-js"; 5 + import { createMemo, type ParentProps, Show } from "solid-js"; 6 6 7 7 function QuotedText(props: { facets?: RichTextFacet[] | null; text: string; truncated: boolean }) { 8 8 return ( ··· 66 66 ); 67 67 } 68 68 69 - type QuotedPostPreviewProps = { 70 - author: ProfileViewBasic | null; 71 - class?: string; 72 - emptyText?: string; 73 - facets?: RichTextFacet[] | null; 74 - href?: string | null; 75 - onOpenPost?: () => void; 76 - text?: unknown; 77 - title: string; 78 - truncate?: boolean; 79 - }; 69 + type QuotedPostPreviewProps = ParentProps< 70 + { 71 + author: ProfileViewBasic | null; 72 + class?: string; 73 + emptyText?: string; 74 + facets?: RichTextFacet[] | null; 75 + href?: string | null; 76 + onOpenPost?: () => void; 77 + text?: unknown; 78 + title: string; 79 + truncate?: boolean; 80 + } 81 + >; 82 + 83 + function shouldHandleWithCallback(event: MouseEvent) { 84 + return event.button === 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey; 85 + } 80 86 81 87 export function QuotedPostPreview(props: QuotedPostPreviewProps) { 82 88 const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 89 + const href = createMemo(() => (typeof props.href === "string" && props.href.trim().length > 0 ? props.href : null)); 83 90 const openInNewTab = createMemo(() => !!props.href && /^https?:\/\//i.test(props.href)); 84 91 const truncated = createMemo(() => props.truncate ?? false); 85 92 ··· 87 94 <div class={props.class ?? "ui-input-strong rounded-2xl p-4 shadow-(--inset-shadow)"}> 88 95 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 89 96 <Show 90 - when={props.onOpenPost} 97 + when={href()} 91 98 fallback={ 92 99 <Show 93 - when={props.href} 100 + when={props.onOpenPost} 94 101 fallback={ 95 102 <QuotedPreviewContent 96 103 author={props.author} 97 104 emptyText={props.emptyText} 105 + facets={props.facets} 98 106 preview={preview()} 99 107 truncated={truncated()} /> 100 108 }> 101 - {(href) => ( 102 - <a 103 - class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-surface-bright" 104 - href={href()} 105 - rel={openInNewTab() ? "noreferrer" : undefined} 106 - target={openInNewTab() ? "_blank" : undefined} 107 - onClick={(event) => event.stopPropagation()}> 108 - <QuotedPreviewContent 109 - author={props.author} 110 - emptyText={props.emptyText} 111 - facets={props.facets} 112 - preview={preview()} 113 - truncated={truncated()} /> 114 - </a> 115 - )} 109 + <button 110 + class="mt-2 block w-full rounded-xl border-0 bg-transparent px-1 py-1 text-left text-inherit transition duration-150 ease-out hover:bg-surface-bright" 111 + type="button" 112 + onClick={(event) => { 113 + event.stopPropagation(); 114 + props.onOpenPost?.(); 115 + }}> 116 + <QuotedPreviewContent 117 + author={props.author} 118 + emptyText={props.emptyText} 119 + facets={props.facets} 120 + preview={preview()} 121 + truncated={truncated()} /> 122 + </button> 116 123 </Show> 117 124 }> 118 - <button 119 - class="mt-2 block w-full rounded-xl border-0 bg-transparent px-1 py-1 text-left text-inherit transition duration-150 ease-out hover:bg-surface-bright" 120 - type="button" 121 - onClick={(event) => { 122 - event.stopPropagation(); 123 - props.onOpenPost?.(); 124 - }}> 125 - <QuotedPreviewContent 126 - author={props.author} 127 - emptyText={props.emptyText} 128 - facets={props.facets} 129 - preview={preview()} 130 - truncated={truncated()} /> 131 - </button> 125 + {(quotedHref) => ( 126 + <a 127 + class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-surface-bright" 128 + href={quotedHref()} 129 + rel={openInNewTab() ? "noreferrer" : undefined} 130 + target={openInNewTab() ? "_blank" : undefined} 131 + onClick={(event) => { 132 + event.stopPropagation(); 133 + if (!props.onOpenPost || !shouldHandleWithCallback(event)) { 134 + return; 135 + } 136 + event.preventDefault(); 137 + props.onOpenPost(); 138 + }}> 139 + <QuotedPreviewContent 140 + author={props.author} 141 + emptyText={props.emptyText} 142 + facets={props.facets} 143 + preview={preview()} 144 + truncated={truncated()} /> 145 + </a> 146 + )} 132 147 </Show> 148 + <Show when={props.children}>{(children) => <div class="mt-3 grid gap-2">{children()}</div>}</Show> 133 149 </div> 134 150 ); 135 151 }
+145 -10
src/lib/feeds.ts
··· 540 540 return { $type: "app.bsky.embed.images#view", images: normalizedImages } as const; 541 541 } 542 542 543 + function blobCidFromRecord(record: Record<string, unknown> | null) { 544 + if (!record) { 545 + return null; 546 + } 547 + 548 + if (typeof record.$link === "string" && record.$link.trim().length > 0) { 549 + return record.$link.trim(); 550 + } 551 + 552 + if (typeof record.ref === "string" && record.ref.trim().length > 0) { 553 + return record.ref.trim(); 554 + } 555 + 556 + const ref = asRecord(record.ref); 557 + if (ref && typeof ref.$link === "string" && ref.$link.trim().length > 0) { 558 + return ref.$link.trim(); 559 + } 560 + 561 + return null; 562 + } 563 + 564 + function imageFormatFromMimeType(mimeType: unknown) { 565 + if (typeof mimeType !== "string") { 566 + return "jpeg"; 567 + } 568 + 569 + const normalized = mimeType.trim().toLowerCase(); 570 + if (normalized === "image/png") { 571 + return "png"; 572 + } 573 + if (normalized === "image/webp") { 574 + return "webp"; 575 + } 576 + if (normalized === "image/gif") { 577 + return "gif"; 578 + } 579 + 580 + return "jpeg"; 581 + } 582 + 583 + function withBlobBackedImageUrls(value: unknown, authorDid: string | null) { 584 + if (!authorDid) { 585 + return value; 586 + } 587 + 588 + const record = asRecord(value); 589 + if (!record) { 590 + return value; 591 + } 592 + 593 + const explicitType = typeof record.$type === "string" ? record.$type : null; 594 + if (explicitType && explicitType !== "app.bsky.embed.images" && explicitType !== "app.bsky.embed.images#view") { 595 + return value; 596 + } 597 + 598 + const images = asArray(record.images); 599 + if (!images || images.length === 0) { 600 + return value; 601 + } 602 + 603 + let changed = false; 604 + const resolvedImages = images.map((entry) => { 605 + const imageRecord = asRecord(entry); 606 + if (!imageRecord) { 607 + return entry; 608 + } 609 + 610 + const hasViewUrls = typeof imageRecord.fullsize === "string" || typeof imageRecord.thumb === "string"; 611 + if (hasViewUrls) { 612 + return imageRecord; 613 + } 614 + 615 + const blobRecord = asRecord(imageRecord.image); 616 + const cid = blobCidFromRecord(blobRecord); 617 + if (!cid) { 618 + return imageRecord; 619 + } 620 + 621 + changed = true; 622 + const format = imageFormatFromMimeType(blobRecord?.mimeType); 623 + const encodedDid = encodeURIComponent(authorDid); 624 + const encodedCid = encodeURIComponent(cid); 625 + 626 + return { 627 + ...imageRecord, 628 + fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${encodedDid}/${encodedCid}@${format}`, 629 + thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/${encodedDid}/${encodedCid}@${format}`, 630 + }; 631 + }); 632 + 633 + if (!changed) { 634 + return value; 635 + } 636 + 637 + return { ...record, images: resolvedImages }; 638 + } 639 + 543 640 function normalizeExternalEmbedView(record: Record<string, unknown>) { 544 641 const external = asRecord(record.external); 545 642 if (!external) { ··· 651 748 652 749 function quotedRecordText(kind: QuotedRecordKind, record: Record<string, unknown>) { 653 750 if (kind === "post") { 654 - const text = asRecord(record.value)?.text; 751 + const valueText = asRecord(record.value)?.text; 752 + if (typeof valueText === "string" && valueText.trim().length > 0) { 753 + return valueText; 754 + } 755 + 756 + const postRecordText = asRecord(record.record)?.text; 757 + const text = typeof postRecordText === "string" ? postRecordText : record.text; 655 758 return typeof text === "string" && text.trim().length > 0 ? text : null; 656 759 } 657 760 if (kind === "feed") { ··· 729 832 return null; 730 833 } 731 834 732 - const facets = asRecord(record.value)?.facets; 835 + const facets = asRecord(record.value)?.facets ?? asRecord(record.record)?.facets; 733 836 return Array.isArray(facets) ? (facets as RichTextFacet[]) : null; 734 837 } 735 838 ··· 741 844 return { source: "viewRecord.embeds", values: direct ?? (record.embeds === undefined ? [] : [record.embeds]) }; 742 845 } 743 846 847 + if (Object.prototype.hasOwnProperty.call(record, "embed")) { 848 + if (record.embed === null || record.embed === undefined) { 849 + return { source: "value.embed", values: [] }; 850 + } 851 + return { source: "value.embed", values: [record.embed] }; 852 + } 853 + 744 854 const value = asRecord(record.value); 745 - if (!value) { 855 + if (value) { 856 + if (Object.prototype.hasOwnProperty.call(value, "embed")) { 857 + if (value.embed === null || value.embed === undefined) { 858 + return { source: "value.embed", values: [] }; 859 + } 860 + return { source: "value.embed", values: [value.embed] }; 861 + } 862 + 863 + if (Object.prototype.hasOwnProperty.call(value, "embeds")) { 864 + const embeds = asArray(value.embeds); 865 + return { source: "value.embeds", values: embeds ?? (value.embeds === undefined ? [] : [value.embeds]) }; 866 + } 867 + } 868 + 869 + const postRecord = asRecord(record.record); 870 + if (!postRecord) { 746 871 return null; 747 872 } 748 873 749 - if (Object.prototype.hasOwnProperty.call(value, "embed")) { 750 - if (value.embed === null || value.embed === undefined) { 874 + if (Object.prototype.hasOwnProperty.call(postRecord, "embed")) { 875 + if (postRecord.embed === null || postRecord.embed === undefined) { 751 876 return { source: "value.embed", values: [] }; 752 877 } 753 - return { source: "value.embed", values: [value.embed] }; 878 + return { source: "value.embed", values: [postRecord.embed] }; 754 879 } 755 880 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]) }; 881 + if (Object.prototype.hasOwnProperty.call(postRecord, "embeds")) { 882 + const embeds = asArray(postRecord.embeds); 883 + return { source: "value.embeds", values: embeds ?? (postRecord.embeds === undefined ? [] : [postRecord.embeds]) }; 759 884 } 760 885 761 886 return null; ··· 835 960 return { normalizedEmbeds: [] as NormalizedEmbed[], unknownEmbeds: [] as UnknownEmbedEntry[] }; 836 961 } 837 962 963 + const authorDid = (() => { 964 + const author = asRecord(record.author); 965 + if (author && typeof author.did === "string" && author.did.trim().length > 0) { 966 + return author.did.trim(); 967 + } 968 + 969 + const parts = atUriParts(typeof record.uri === "string" ? record.uri : null); 970 + return parts?.did ?? null; 971 + })(); 972 + 838 973 const normalizedEmbeds = extraction.values.map((value) => 839 - normalizeEmbed(value, { 974 + normalizeEmbed(withBlobBackedImageUrls(value, authorDid), { 840 975 depth: context.depth + 1, 841 976 maxDepth: context.maxDepth, 842 977 source: extraction.source,
+160 -133
src/lib/tests/feeds.test.ts
··· 4 4 buildPublicPostUrl, 5 5 buildThreadOverlayRoute, 6 6 decodeThreadRouteUri, 7 - getUnknownEmbedTelemetryForTests, 8 7 getFeedCommand, 9 8 getQuotedPresentation, 10 9 getThreadOverlayUri, 10 + getUnknownEmbedTelemetryForTests, 11 + type NormalizedEmbed, 11 12 normalizeEmbed, 12 13 parseFeedResponse, 13 14 parseThreadResponse, 14 15 resetUnknownEmbedTelemetryForTests, 15 - type NormalizedEmbed, 16 16 } from "../feeds"; 17 17 import type { FeedViewPost, FeedViewPrefItem, SavedFeedItem } from "../types"; 18 18 ··· 228 228 }); 229 229 }); 230 230 231 - it("extracts quoted post embeds and keeps unknown custom embeds in the unknown list", () => { 231 + it("extracts text, facets, and embed media from quoted postView records", () => { 232 232 const presentation = getQuotedPresentation({ 233 233 $type: "app.bsky.embed.record#view", 234 234 record: { 235 - $type: "app.bsky.embed.record#viewRecord", 235 + $type: "app.bsky.feed.defs#postView", 236 236 author: { did: "did:plc:bob", handle: "bob.test" }, 237 - embeds: [ 238 - { 237 + record: { 238 + text: "quoted postView body", 239 + facets: [{ 240 + features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 241 + index: { byteEnd: 20, byteStart: 0 }, 242 + }], 243 + embed: { 239 244 $type: "app.bsky.embed.images#view", 240 - images: [{ fullsize: "https://cdn.example.com/quoted-image.png" }], 245 + images: [{ fullsize: "https://cdn.example.com/postview-image.png" }], 241 246 }, 242 - { 243 - $type: "app.bsky.embed.video#view", 244 - playlist: "https://cdn.example.com/quoted-video.m3u8", 247 + }, 248 + uri: "at://did:plc:bob/app.bsky.feed.post/postview", 249 + }, 250 + }); 251 + 252 + expect(presentation).toMatchObject({ 253 + href: "https://bsky.app/profile/bob.test/post/postview", 254 + kind: "post", 255 + text: "quoted postView body", 256 + uri: "at://did:plc:bob/app.bsky.feed.post/postview", 257 + }); 258 + expect(presentation.facets).toHaveLength(1); 259 + expect(presentation.normalizedEmbeds.map((embed) => embed.kind)).toEqual(["images"]); 260 + }); 261 + 262 + it("hydrates quoted record image blobs into renderable CDN URLs", () => { 263 + const presentation = getQuotedPresentation({ 264 + $type: "app.bsky.embed.record#view", 265 + record: { 266 + $type: "app.bsky.feed.defs#postView", 267 + author: { did: "did:plc:bob", handle: "bob.test" }, 268 + record: { 269 + embed: { 270 + $type: "app.bsky.embed.images", 271 + images: [{ alt: "Blob image", image: { mimeType: "image/jpeg", ref: { $link: "bafyblobimg" } } }], 245 272 }, 246 - { 247 - $type: "app.bsky.embed.external#view", 248 - external: { uri: "https://example.com", title: "External card" }, 249 - }, 273 + text: "", 274 + }, 275 + uri: "at://did:plc:bob/app.bsky.feed.post/blob-post", 276 + }, 277 + }); 278 + 279 + expect(presentation.normalizedEmbeds).toHaveLength(1); 280 + expect(presentation.normalizedEmbeds[0]?.kind).toBe("images"); 281 + if (presentation.normalizedEmbeds[0]?.kind === "images") { 282 + expect(presentation.normalizedEmbeds[0].embed.images[0]).toMatchObject({ 283 + fullsize: "https://cdn.bsky.app/img/feed_fullsize/plain/did%3Aplc%3Abob/bafyblobimg@jpeg", 284 + thumb: "https://cdn.bsky.app/img/feed_thumbnail/plain/did%3Aplc%3Abob/bafyblobimg@jpeg", 285 + }); 286 + } 287 + }); 288 + 289 + it("extracts quoted post embeds and keeps unknown custom embeds in the unknown list", () => { 290 + const presentation = getQuotedPresentation({ 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" }, 295 + embeds: [ 296 + { $type: "app.bsky.embed.images#view", images: [{ fullsize: "https://cdn.example.com/quoted-image.png" }] }, 297 + { $type: "app.bsky.embed.video#view", playlist: "https://cdn.example.com/quoted-video.m3u8" }, 298 + { $type: "app.bsky.embed.external#view", external: { uri: "https://example.com", title: "External card" } }, 250 299 { $type: "app.bsky.embed.unsupported#view" }, 251 300 ], 252 301 uri: "at://did:plc:bob/app.bsky.feed.post/123", ··· 265 314 }); 266 315 267 316 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" }], 317 + const fixtures = [{ 318 + expectedKind: "images", 319 + value: { $type: "app.bsky.embed.images#view", images: [{ fullsize: "https://cdn.example.com/top-image.png" }] }, 320 + }, { 321 + expectedKind: "video", 322 + value: { $type: "app.bsky.embed.video#view", playlist: "https://cdn.example.com/top-video.m3u8" }, 323 + }, { 324 + expectedKind: "external", 325 + value: { $type: "app.bsky.embed.external#view", external: { title: "External", uri: "https://example.com" } }, 326 + }, { 327 + expectedKind: "record", 328 + value: { 329 + $type: "app.bsky.embed.record#view", 330 + record: { 331 + $type: "app.bsky.embed.record#viewRecord", 332 + author: { did: "did:plc:bob", handle: "bob.test" }, 333 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted-a", 334 + value: { text: "quoted a" }, 274 335 }, 275 336 }, 276 - { 277 - expectedKind: "video", 278 - value: { 279 - $type: "app.bsky.embed.video#view", 280 - playlist: "https://cdn.example.com/top-video.m3u8", 337 + }, { 338 + expectedKind: "recordWithMedia", 339 + value: { 340 + $type: "app.bsky.embed.recordWithMedia#view", 341 + media: { 342 + $type: "app.bsky.embed.images#view", 343 + images: [{ fullsize: "https://cdn.example.com/top-rwm-image.png" }], 281 344 }, 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: { 345 + record: { 293 346 $type: "app.bsky.embed.record#view", 294 347 record: { 295 348 $type: "app.bsky.embed.record#viewRecord", 296 349 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" }, 350 + uri: "at://did:plc:bob/app.bsky.feed.post/quoted-b", 351 + value: { text: "quoted b" }, 299 352 }, 300 353 }, 301 354 }, 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; 355 + }] as const; 322 356 323 357 for (const fixture of fixtures) { 324 358 const normalized = normalizeEmbed(fixture.value, { source: "top" }); ··· 331 365 }); 332 366 333 367 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 - }, 368 + const fixtures = [{ 369 + expectedKind: "post", 370 + record: { 371 + $type: "app.bsky.embed.record#viewRecord", 372 + author: { did: "did:plc:bob", handle: "bob.test" }, 373 + uri: "at://did:plc:bob/app.bsky.feed.post/1", 374 + value: { text: "post record" }, 343 375 }, 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" }, 376 + }, { 377 + expectedKind: "not-found", 378 + record: { 379 + $type: "app.bsky.embed.record#viewNotFound", 380 + notFound: true, 381 + uri: "at://did:plc:bob/app.bsky.feed.post/2", 347 382 }, 348 - { 349 - expectedKind: "blocked", 350 - record: { $type: "app.bsky.embed.record#viewBlocked", blocked: true, uri: "at://did:plc:bob/app.bsky.feed.post/3" }, 383 + }, { 384 + expectedKind: "blocked", 385 + record: { 386 + $type: "app.bsky.embed.record#viewBlocked", 387 + blocked: true, 388 + uri: "at://did:plc:bob/app.bsky.feed.post/3", 351 389 }, 352 - { 353 - expectedKind: "detached", 354 - record: { $type: "app.bsky.embed.record#viewDetached", detached: true, uri: "at://did:plc:bob/app.bsky.feed.post/4" }, 390 + }, { 391 + expectedKind: "detached", 392 + record: { 393 + $type: "app.bsky.embed.record#viewDetached", 394 + detached: true, 395 + uri: "at://did:plc:bob/app.bsky.feed.post/4", 355 396 }, 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 - }, 397 + }, { 398 + expectedKind: "feed", 399 + record: { 400 + $type: "app.bsky.feed.defs#generatorView", 401 + creator: { did: "did:plc:bob", handle: "bob.test" }, 402 + uri: "at://did:plc:bob/app.bsky.feed.generator/following", 363 403 }, 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 - }, 404 + }, { 405 + expectedKind: "list", 406 + record: { 407 + $type: "app.bsky.graph.defs#listView", 408 + creator: { did: "did:plc:bob", handle: "bob.test" }, 409 + uri: "at://did:plc:bob/app.bsky.graph.list/curated", 371 410 }, 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 - }, 411 + }, { 412 + expectedKind: "labeler", 413 + record: { 414 + $type: "app.bsky.labeler.defs#labelerView", 415 + creator: { did: "did:plc:bob", handle: "bob.test" }, 416 + uri: "at://did:plc:bob/app.bsky.labeler.service/self", 379 417 }, 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 - }, 418 + }, { 419 + expectedKind: "starter-pack", 420 + record: { 421 + $type: "app.bsky.graph.defs#starterPackViewBasic", 422 + creator: { did: "did:plc:bob", handle: "bob.test" }, 423 + record: { name: "Starter Pack" }, 424 + uri: "at://did:plc:bob/app.bsky.graph.starterpack/abc123", 388 425 }, 389 - ] as const; 426 + }] as const; 390 427 391 428 for (const fixture of fixtures) { 392 - const presentation = getQuotedPresentation({ 393 - $type: "app.bsky.embed.record#view", 394 - record: fixture.record, 395 - }); 429 + const presentation = getQuotedPresentation({ $type: "app.bsky.embed.record#view", record: fixture.record }); 396 430 397 431 expect(presentation.kind).toBe(fixture.expectedKind); 398 432 expect(presentation.unknownEmbeds).toHaveLength(0); ··· 400 434 }); 401 435 402 436 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 - ); 437 + const inferredImages = normalizeEmbed({ images: [{ fullsize: "https://cdn.example.com/inferred-image.png" }] }, { 438 + source: "quoted", 439 + }); 440 + const inferredVideo = normalizeEmbed({ playlist: "https://cdn.example.com/inferred-video.m3u8" }, { 441 + source: "quoted", 442 + }); 443 + const inferredExternal = normalizeEmbed({ 444 + external: { title: "Inferred external", uri: "https://example.com/inferred" }, 445 + }, { source: "quoted" }); 446 + const inferredRecord = normalizeEmbed({ record: { uri: "at://did:plc:bob/app.bsky.feed.post/inferred" } }, { 447 + source: "quoted", 448 + }); 449 + const inferredRecordWithMedia = normalizeEmbed({ 450 + media: { images: [{ fullsize: "https://cdn.example.com/inferred-rwm.png" }] }, 451 + record: { uri: "at://did:plc:bob/app.bsky.feed.post/inferred-rwm" }, 452 + }, { source: "quoted" }); 426 453 427 454 expect(inferredImages.kind).toBe("images"); 428 455 expect(inferredVideo.kind).toBe("video");