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.

at main 746 lines 29 kB view raw
1import { buildPostRoute } from "$/lib/post-routes"; 2import { buildHashtagRoute } from "$/lib/search-routes"; 3import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 4import { beforeEach, describe, expect, it, vi } from "vitest"; 5import { PostCard } from "../PostCard"; 6 7const downloadImageMock = vi.hoisted(() => vi.fn()); 8const downloadVideoMock = vi.hoisted(() => vi.fn()); 9const listenMock = vi.hoisted(() => vi.fn()); 10const moderateContentMock = vi.hoisted(() => vi.fn()); 11const createReportMock = vi.hoisted(() => vi.fn()); 12const blockActorMock = vi.hoisted(() => vi.fn()); 13 14vi.mock( 15 "$/lib/api/media", 16 () => ({ MediaController: { downloadImage: downloadImageMock, downloadVideo: downloadVideoMock } }), 17); 18vi.mock( 19 "$/lib/api/moderation", 20 () => ({ 21 MODERATION_REASON_OPTIONS: [{ label: "Spam", value: "com.atproto.moderation.defs#reasonSpam" }, { 22 label: "Violation", 23 value: "com.atproto.moderation.defs#reasonViolation", 24 }], 25 ModerationController: { 26 moderateContent: moderateContentMock, 27 createReport: createReportMock, 28 blockActor: blockActorMock, 29 }, 30 }), 31); 32vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 33 34function createPost() { 35 return { 36 author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 37 cid: "cid-post", 38 indexedAt: "2026-03-28T12:00:00.000Z", 39 likeCount: 4, 40 quoteCount: 2, 41 record: { 42 createdAt: "2026-03-28T12:00:00.000Z", 43 facets: [{ 44 features: [{ $type: "app.bsky.richtext.facet#link", uri: "https://example.com" }], 45 index: { byteEnd: 25, byteStart: 6 }, 46 }, { 47 features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:bob" }], 48 index: { byteEnd: 35, byteStart: 26 }, 49 }, { features: [{ $type: "app.bsky.richtext.facet#tag", tag: "solid" }], index: { byteEnd: 42, byteStart: 36 } }], 50 text: "Visit https://example.com @bob.test #solid", 51 }, 52 replyCount: 2, 53 repostCount: 1, 54 uri: "at://did:plc:alice/app.bsky.feed.post/123", 55 viewer: {}, 56 } as const; 57} 58 59describe("PostCard", () => { 60 beforeEach(() => { 61 downloadImageMock.mockReset(); 62 downloadVideoMock.mockReset(); 63 listenMock.mockReset(); 64 moderateContentMock.mockReset(); 65 createReportMock.mockReset(); 66 blockActorMock.mockReset(); 67 listenMock.mockResolvedValue(() => {}); 68 moderateContentMock.mockResolvedValue({ 69 filter: false, 70 blur: "none", 71 alert: false, 72 inform: false, 73 noOverride: false, 74 }); 75 createReportMock.mockResolvedValue(1); 76 blockActorMock.mockResolvedValue({ uri: "at://did:plc:test/app.bsky.graph.block/1", cid: "cid-block" }); 77 }); 78 79 it("renders links, mentions, and hashtags from facets", () => { 80 render(() => <PostCard post={createPost()} />); 81 82 expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 83 expect(screen.getByRole("link", { name: "@bob.test" })).toHaveAttribute("href", "#/profile/did%3Aplc%3Abob"); 84 expect(screen.getByRole("link", { name: "#solid" })).toHaveAttribute("href", `#${buildHashtagRoute("solid")}`); 85 }); 86 87 it("opens the thread from the primary region on click and Enter", async () => { 88 const onOpenThread = vi.fn(); 89 render(() => <PostCard post={createPost()} onOpenThread={onOpenThread} />); 90 91 const primaryRegion = screen.getByRole("button", { name: "Open thread" }); 92 fireEvent.click(primaryRegion); 93 fireEvent.keyDown(primaryRegion, { key: "Enter" }); 94 95 expect(onOpenThread).toHaveBeenCalledTimes(2); 96 }); 97 98 it("keeps profile navigation avatar/handle-only and does not open thread on profile/action clicks", () => { 99 const onOpenThread = vi.fn(); 100 const onLike = vi.fn(); 101 render(() => <PostCard post={createPost()} onLike={onLike} onOpenThread={onOpenThread} />); 102 103 expect(screen.getByRole("link", { name: "View @alice.test" })).toBeInTheDocument(); 104 expect(screen.queryByRole("link", { name: "Alice" })).not.toBeInTheDocument(); 105 expect(screen.getByRole("link", { name: "@alice.test" })).toBeInTheDocument(); 106 107 fireEvent.click(screen.getByRole("link", { name: "View @alice.test" })); 108 fireEvent.click(screen.getByRole("link", { name: "@alice.test" })); 109 fireEvent.click(screen.getByRole("button", { name: "Like" })); 110 111 expect(onOpenThread).not.toHaveBeenCalled(); 112 expect(onLike).toHaveBeenCalledTimes(1); 113 }); 114 115 it("opens the thread when clicking the author text region", () => { 116 const onOpenThread = vi.fn(); 117 render(() => <PostCard post={createPost()} onOpenThread={onOpenThread} />); 118 119 fireEvent.click(screen.getByText("Alice")); 120 121 expect(onOpenThread).toHaveBeenCalledOnce(); 122 }); 123 124 it("opens the shared menu from the overflow trigger and from right click", async () => { 125 Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(void 0) } }); 126 const onOpenEngagement = vi.fn(); 127 128 render(() => <PostCard post={createPost()} onOpenEngagement={onOpenEngagement} onOpenThread={vi.fn()} />); 129 130 fireEvent.click(screen.getByRole("button", { name: "More actions" })); 131 expect(screen.getByRole("menu", { name: "Post actions" })).toBeInTheDocument(); 132 expect(screen.getByRole("menuitem", { name: "4 likes" })).toBeInTheDocument(); 133 expect(screen.getByRole("menuitem", { name: "1 repost" })).toBeInTheDocument(); 134 expect(screen.getByRole("menuitem", { name: "2 quotes" })).toBeInTheDocument(); 135 fireEvent.click(screen.getByRole("menuitem", { name: "4 likes" })); 136 expect(onOpenEngagement).toHaveBeenCalledWith("likes"); 137 138 fireEvent.pointerDown(document.body); 139 await waitFor(() => expect(screen.queryByRole("menu", { name: "Post actions" })).not.toBeInTheDocument()); 140 141 fireEvent.contextMenu(screen.getByRole("article")); 142 expect(screen.getByRole("menu", { name: "Post actions" })).toBeInTheDocument(); 143 expect(screen.getByRole("menuitem", { name: "Copy post link" })).toBeInTheDocument(); 144 }); 145 146 it("uses shift-click on like and quote to open engagement lists, but shift-click repost toggles repost", () => { 147 const onLike = vi.fn(); 148 const onQuote = vi.fn(); 149 const onRepost = vi.fn(); 150 const onOpenEngagement = vi.fn(); 151 render(() => ( 152 <PostCard 153 post={createPost()} 154 onLike={onLike} 155 onOpenEngagement={onOpenEngagement} 156 onQuote={onQuote} 157 onRepost={onRepost} /> 158 )); 159 160 fireEvent.click(screen.getByRole("button", { name: "Like" }), { shiftKey: true }); 161 fireEvent.click(screen.getByRole("button", { name: "Repost" }), { shiftKey: true }); 162 fireEvent.click(screen.getByRole("button", { name: "Quote" }), { shiftKey: true }); 163 164 expect(onOpenEngagement).toHaveBeenNthCalledWith(1, "likes"); 165 expect(onOpenEngagement).toHaveBeenNthCalledWith(2, "quotes"); 166 expect(onLike).not.toHaveBeenCalled(); 167 expect(onQuote).not.toHaveBeenCalled(); 168 expect(onRepost).toHaveBeenCalledTimes(1); 169 }); 170 171 it("opens a repost action menu from the repost button and supports repost/quote actions", () => { 172 const onRepost = vi.fn(); 173 const onQuote = vi.fn(); 174 175 render(() => <PostCard post={createPost()} onQuote={onQuote} onRepost={onRepost} />); 176 177 fireEvent.click(screen.getByRole("button", { name: "Repost" })); 178 179 expect(screen.getByRole("menu", { name: "Repost actions" })).toBeInTheDocument(); 180 expect(screen.getByRole("menuitem", { name: "Repost" })).toBeInTheDocument(); 181 expect(screen.getByRole("menuitem", { name: "Quote post" })).toBeInTheDocument(); 182 183 fireEvent.click(screen.getByRole("menuitem", { name: "Quote post" })); 184 expect(onQuote).toHaveBeenCalledTimes(1); 185 186 fireEvent.click(screen.getByRole("button", { name: "Repost" })); 187 fireEvent.click(screen.getByRole("menuitem", { name: "Repost" })); 188 expect(onRepost).toHaveBeenCalledTimes(1); 189 }); 190 191 it("hides Thread action when no known thread context exists", () => { 192 render(() => ( 193 <PostCard 194 post={{ ...createPost(), record: { ...createPost().record, reply: undefined }, replyCount: undefined }} 195 onOpenThread={vi.fn()} /> 196 )); 197 198 expect(screen.queryByRole("button", { name: "Thread" })).not.toBeInTheDocument(); 199 fireEvent.click(screen.getByRole("button", { name: "More actions" })); 200 expect(screen.queryByRole("menuitem", { name: "Open thread" })).not.toBeInTheDocument(); 201 }); 202 203 it("shows Thread action when reply count indicates known thread context", () => { 204 render(() => <PostCard post={{ ...createPost(), replyCount: 1 }} onOpenThread={vi.fn()} />); 205 206 expect(screen.getByRole("button", { name: "Thread" })).toBeInTheDocument(); 207 fireEvent.click(screen.getByRole("button", { name: "More actions" })); 208 expect(screen.getByRole("menuitem", { name: "Open thread" })).toBeInTheDocument(); 209 }); 210 211 it("shows reply context when the feed item is a reply", () => { 212 render(() => ( 213 <PostCard 214 item={{ 215 post: createPost(), 216 reply: { 217 parent: { 218 $type: "app.bsky.feed.defs#postView", 219 ...createPost(), 220 author: { ...createPost().author, handle: "bob.test" }, 221 }, 222 root: { $type: "app.bsky.feed.defs#postView", ...createPost() }, 223 }, 224 }} 225 post={createPost()} /> 226 )); 227 228 expect(screen.getByText("Replying to @bob.test")).toBeInTheDocument(); 229 }); 230 231 it("falls back to did in reply context when parent handle is missing", () => { 232 render(() => ( 233 <PostCard 234 item={{ 235 post: createPost(), 236 reply: { 237 parent: { 238 $type: "app.bsky.feed.defs#postView", 239 ...createPost(), 240 author: { did: "did:plc:bob", handle: undefined as unknown as string }, 241 }, 242 root: { $type: "app.bsky.feed.defs#postView", ...createPost() }, 243 }, 244 }} 245 post={createPost()} /> 246 )); 247 248 expect(screen.getByText("Replying to did:plc:bob")).toBeInTheDocument(); 249 }); 250 251 it("renders recordWithMedia embeds and opens quoted posts internally without bubbling", () => { 252 const onOpenThread = vi.fn(); 253 render(() => ( 254 <PostCard 255 post={{ 256 ...createPost(), 257 embed: { 258 $type: "app.bsky.embed.recordWithMedia#view", 259 media: { 260 $type: "app.bsky.embed.images#view", 261 images: [{ alt: "Preview image", fullsize: "https://cdn.example.com/image.png" }], 262 }, 263 record: { 264 $type: "app.bsky.embed.record#view", 265 record: { 266 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 267 uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 268 value: { text: "Quoted body" }, 269 }, 270 }, 271 }, 272 }} 273 onOpenThread={onOpenThread} /> 274 )); 275 276 expect(screen.getByAltText("Preview image")).toHaveAttribute("src", "https://cdn.example.com/image.png"); 277 expect(screen.getByText("Quoted post")).toBeInTheDocument(); 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).queryByAltText("Preview image")).not.toBeInTheDocument(); 282 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); 287 288 expect(onOpenThread).toHaveBeenCalledTimes(1); 289 expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/quoted"); 290 }); 291 292 it("uses outer post context for recordWithMedia media and keeps quoted embeds nested", async () => { 293 downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); 294 render(() => ( 295 <PostCard 296 post={{ 297 ...createPost(), 298 uri: "at://did:plc:alice/app.bsky.feed.post/outer-post", 299 embed: { 300 $type: "app.bsky.embed.recordWithMedia#view", 301 media: { 302 $type: "app.bsky.embed.images#view", 303 images: [{ alt: "Outer media image", fullsize: "https://cdn.example.com/outer-image.jpg" }], 304 }, 305 record: { 306 $type: "app.bsky.embed.record#view", 307 record: { 308 $type: "app.bsky.embed.record#viewRecord", 309 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 310 embeds: [{ 311 $type: "app.bsky.embed.images#view", 312 images: [{ alt: "Quoted nested image", fullsize: "https://cdn.example.com/quoted-image.jpg" }], 313 }], 314 uri: "at://did:plc:bob/app.bsky.feed.post/quoted-post", 315 value: { text: "Quoted body with nested media" }, 316 }, 317 }, 318 }, 319 }} /> 320 )); 321 322 const quotedCard = screen.getByText("Quoted post").closest(".ui-input-strong"); 323 expect(quotedCard).not.toBeNull(); 324 expect(within(quotedCard as HTMLElement).getByAltText("Quoted nested image")).toBeInTheDocument(); 325 expect(within(quotedCard as HTMLElement).queryByAltText("Outer media image")).not.toBeInTheDocument(); 326 327 fireEvent.contextMenu(screen.getByAltText("Outer media image")); 328 fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 329 await waitFor(() => 330 expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/outer-image.jpg", "outer-post") 331 ); 332 333 fireEvent.contextMenu(screen.getByAltText("Quoted nested image")); 334 fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 335 await waitFor(() => 336 expect(downloadImageMock).toHaveBeenLastCalledWith("https://cdn.example.com/quoted-image.jpg", "quoted-post") 337 ); 338 }); 339 340 it("renders quoted post image and video embeds from the quoted record", () => { 341 render(() => ( 342 <PostCard 343 post={{ 344 ...createPost(), 345 embed: { 346 $type: "app.bsky.embed.record#view", 347 record: { 348 $type: "app.bsky.embed.record#viewRecord", 349 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 350 embeds: [{ 351 $type: "app.bsky.embed.images#view", 352 images: [{ alt: "Quoted image", fullsize: "https://cdn.example.com/quoted-image.png" }], 353 }, { 354 $type: "app.bsky.embed.video#view", 355 alt: "Quoted clip", 356 playlist: "https://cdn.example.com/quoted-video.m3u8", 357 thumbnail: "https://cdn.example.com/quoted-video-thumb.jpg", 358 }], 359 uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 360 value: { text: "Quoted body with media" }, 361 }, 362 }, 363 }} /> 364 )); 365 366 expect(screen.getByAltText("Quoted image")).toHaveAttribute("src", "https://cdn.example.com/quoted-image.png"); 367 expect(screen.getByRole("button", { name: "Play video" })).toBeInTheDocument(); 368 expect(screen.getByText("Quoted clip")).toBeInTheDocument(); 369 }); 370 371 it("renders quoted postView media and opens that quoted thread", () => { 372 const onOpenThread = vi.fn(); 373 render(() => ( 374 <PostCard 375 post={{ 376 ...createPost(), 377 embed: { 378 $type: "app.bsky.embed.record#view", 379 record: { 380 $type: "app.bsky.feed.defs#postView", 381 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 382 record: { 383 text: "Quoted postView body", 384 embed: { 385 $type: "app.bsky.embed.images#view", 386 images: [{ alt: "Quoted postView image", fullsize: "https://cdn.example.com/postview-image.png" }], 387 }, 388 }, 389 uri: "at://did:plc:bob/app.bsky.feed.post/postview", 390 }, 391 }, 392 }} 393 onOpenThread={onOpenThread} /> 394 )); 395 396 expect(screen.getByAltText("Quoted postView image")).toHaveAttribute( 397 "src", 398 "https://cdn.example.com/postview-image.png", 399 ); 400 const quotedLink = screen.getByRole("link", { name: /quoted postview body/i }); 401 expect(quotedLink).toHaveAttribute("href", `#${buildPostRoute("at://did:plc:bob/app.bsky.feed.post/postview")}`); 402 403 fireEvent.click(quotedLink); 404 expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/postview"); 405 }); 406 407 it("renders blob-backed quoted record images and opens quoted thread uri", () => { 408 const onOpenThread = vi.fn(); 409 render(() => ( 410 <PostCard 411 post={{ 412 ...createPost(), 413 embed: { 414 $type: "app.bsky.embed.record#view", 415 record: { 416 $type: "app.bsky.feed.defs#postView", 417 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 418 record: { 419 embed: { 420 $type: "app.bsky.embed.images", 421 images: [{ 422 alt: "Blob-backed image", 423 image: { mimeType: "image/jpeg", ref: { $link: "bafyblobimg" } }, 424 }], 425 }, 426 text: "Blob-backed quote", 427 }, 428 uri: "at://did:plc:bob/app.bsky.feed.post/blob-post", 429 }, 430 }, 431 }} 432 onOpenThread={onOpenThread} /> 433 )); 434 435 expect(screen.getByAltText("Blob-backed image")).toHaveAttribute( 436 "src", 437 "https://cdn.bsky.app/img/feed_fullsize/plain/did%3Aplc%3Abob/bafyblobimg@jpeg", 438 ); 439 fireEvent.click(screen.getByRole("link", { name: /blob-backed quote/i })); 440 expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/blob-post"); 441 }); 442 443 it("renders quoted external card embeds from the quoted record", () => { 444 render(() => ( 445 <PostCard 446 post={{ 447 ...createPost(), 448 embed: { 449 $type: "app.bsky.embed.record#view", 450 record: { 451 $type: "app.bsky.embed.record#viewRecord", 452 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 453 embeds: [{ 454 $type: "app.bsky.embed.external#view", 455 external: { description: "Deep dive", title: "External article", uri: "https://example.com/article" }, 456 }], 457 uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 458 value: { text: "Quoted body with external card" }, 459 }, 460 }, 461 }} /> 462 )); 463 464 expect(screen.getByRole("link", { name: /external article/i })).toHaveAttribute( 465 "href", 466 "https://example.com/article", 467 ); 468 }); 469 470 it("renders feed generator record embeds with feed metadata and external links", () => { 471 const onOpenThread = vi.fn(); 472 render(() => ( 473 <PostCard 474 post={{ 475 ...createPost(), 476 embed: { 477 $type: "app.bsky.embed.record#view", 478 record: { 479 $type: "app.bsky.feed.defs#generatorView", 480 creator: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 481 description: "Prioritizes high-signal posts.", 482 displayName: "For You", 483 uri: "at://did:plc:alice/app.bsky.feed.generator/for-you", 484 }, 485 }, 486 }} 487 onOpenThread={onOpenThread} /> 488 )); 489 490 expect(screen.getByText("Embedded feed")).toBeInTheDocument(); 491 expect(screen.getByRole("link", { name: /for you/i })).toHaveAttribute( 492 "href", 493 "https://bsky.app/profile/alice.test/feed/for-you", 494 ); 495 fireEvent.click(screen.getByRole("link", { name: /for you/i })); 496 expect(onOpenThread).not.toHaveBeenCalled(); 497 }); 498 499 it("renders list record embeds with list metadata and external links", () => { 500 const onOpenThread = vi.fn(); 501 render(() => ( 502 <PostCard 503 post={{ 504 ...createPost(), 505 embed: { 506 $type: "app.bsky.embed.record#view", 507 record: { 508 $type: "app.bsky.graph.defs#listView", 509 creator: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 510 name: "Science Curators", 511 uri: "at://did:plc:alice/app.bsky.graph.list/science-curators", 512 }, 513 }, 514 }} 515 onOpenThread={onOpenThread} /> 516 )); 517 518 expect(screen.getByText("Embedded list")).toBeInTheDocument(); 519 expect(screen.getByRole("link", { name: /science curators/i })).toHaveAttribute( 520 "href", 521 "https://bsky.app/profile/alice.test/lists/science-curators", 522 ); 523 fireEvent.click(screen.getByRole("link", { name: /science curators/i })); 524 expect(onOpenThread).not.toHaveBeenCalled(); 525 }); 526 527 it("ignores non-media payloads inside recordWithMedia and avoids duplicate quote previews", () => { 528 render(() => ( 529 <PostCard 530 post={{ 531 ...createPost(), 532 embed: { 533 $type: "app.bsky.embed.recordWithMedia#view", 534 media: { 535 $type: "app.bsky.embed.record#view", 536 record: { 537 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 538 uri: "at://did:plc:bob/app.bsky.feed.post/nested", 539 value: { text: "Nested record" }, 540 }, 541 }, 542 record: { 543 $type: "app.bsky.embed.record#view", 544 record: { 545 author: { did: "did:plc:carol", handle: "carol.test", displayName: "Carol" }, 546 uri: "at://did:plc:carol/app.bsky.feed.post/outer", 547 value: { text: "Outer quote" }, 548 }, 549 }, 550 }, 551 }} /> 552 )); 553 554 expect(screen.getByText("Outer quote")).toBeInTheDocument(); 555 expect(screen.queryByText("Nested record")).not.toBeInTheDocument(); 556 expect(screen.getAllByText("Quoted post")).toHaveLength(1); 557 expect(screen.queryByText("This recognized media type is not valid in recordWithMedia.media.")).not 558 .toBeInTheDocument(); 559 }); 560 561 it("does not show unsupported embed fallback cards for custom quoted embeds", () => { 562 render(() => ( 563 <PostCard 564 post={{ 565 ...createPost(), 566 embed: { 567 $type: "app.bsky.embed.record#view", 568 record: { 569 $type: "app.bsky.embed.record#viewRecord", 570 author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 571 embeds: [{ $type: "app.bsky.embed.unsupported#view" }], 572 uri: "at://did:plc:bob/app.bsky.feed.post/quoted", 573 value: { text: "Quoted body" }, 574 }, 575 }, 576 }} /> 577 )); 578 579 expect(screen.queryByText("Unsupported custom embed type.")).not.toBeInTheDocument(); 580 expect(screen.queryByText("View JSON")).not.toBeInTheDocument(); 581 expect(screen.getByText("Quoted body")).toBeInTheDocument(); 582 }); 583 584 it("renders inline video embed player for video attachments", () => { 585 render(() => ( 586 <PostCard 587 post={{ 588 ...createPost(), 589 embed: { 590 $type: "app.bsky.embed.video#view", 591 alt: "Attached clip", 592 playlist: "https://cdn.example.com/video/master.m3u8", 593 thumbnail: "https://cdn.example.com/video/thumb.jpg", 594 }, 595 }} /> 596 )); 597 598 expect(screen.getByRole("button", { name: "Play video" })).toBeInTheDocument(); 599 expect(screen.getByText("Attached clip")).toBeInTheDocument(); 600 }); 601 602 it("shows one moderation overlay when post and embed are both hidden", async () => { 603 moderateContentMock.mockImplementation(async (_labels, context) => { 604 if (context === "contentList") { 605 return { filter: false, blur: "content", alert: false, inform: false, noOverride: false }; 606 } 607 608 if (context === "contentMedia") { 609 return { filter: false, blur: "media", alert: false, inform: false, noOverride: false }; 610 } 611 612 return { filter: false, blur: "none", alert: false, inform: false, noOverride: false }; 613 }); 614 615 render(() => ( 616 <PostCard 617 post={{ 618 ...createPost(), 619 labels: [{ src: "did:plc:labeler", val: "sexual" }], 620 embed: { 621 $type: "app.bsky.embed.images#view", 622 images: [{ alt: "Inline image", fullsize: "https://cdn.example.com/post-image.jpg" }], 623 }, 624 }} /> 625 )); 626 627 await waitFor(() => expect(screen.getAllByText("Content blurred")).toHaveLength(1)); 628 expect(screen.getAllByRole("button", { name: "Show content" })).toHaveLength(1); 629 }); 630 631 it("renders author profile labels in post cards when the author is labeled", async () => { 632 render(() => ( 633 <PostCard 634 post={{ 635 ...createPost(), 636 author: { ...createPost().author, labels: [{ src: "did:plc:labeler", val: "profile-label" }] }, 637 }} /> 638 )); 639 640 expect(await screen.findByText(/profile-label/i)).toBeInTheDocument(); 641 }); 642 643 it("renders post labels in post cards when post labels are present", async () => { 644 render(() => <PostCard post={{ ...createPost(), labels: [{ src: "did:plc:labeler", val: "post-label" }] }} />); 645 646 expect(await screen.findByText(/post-label/i)).toBeInTheDocument(); 647 }); 648 649 it("opens gallery on image click and supports right-click save", async () => { 650 downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); 651 render(() => ( 652 <PostCard 653 post={{ 654 ...createPost(), 655 embed: { 656 $type: "app.bsky.embed.images#view", 657 images: [{ alt: "Inline image", fullsize: "https://cdn.example.com/post-image.jpg" }], 658 }, 659 }} /> 660 )); 661 662 const inlineImage = screen.getByAltText("Inline image"); 663 fireEvent.click(inlineImage); 664 expect(await screen.findByText("1 / 1")).toBeInTheDocument(); 665 666 fireEvent.contextMenu(inlineImage); 667 fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 668 669 await waitFor(() => 670 expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image.jpg", "123") 671 ); 672 }); 673 674 it("uses parent post rkey for video downloads", async () => { 675 downloadVideoMock.mockResolvedValue({ bytes: 200, path: "/tmp/123.mp4" }); 676 render(() => ( 677 <PostCard 678 post={{ 679 ...createPost(), 680 embed: { $type: "app.bsky.embed.video#view", playlist: "https://cdn.example.com/video/master.m3u8" }, 681 }} /> 682 )); 683 684 fireEvent.click(screen.getByRole("button", { name: "Download video" })); 685 686 await waitFor(() => 687 expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8", "123") 688 ); 689 }); 690 691 it("uses indexed parent post rkeys for multi-image downloads", async () => { 692 downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); 693 render(() => ( 694 <PostCard 695 post={{ 696 ...createPost(), 697 embed: { 698 $type: "app.bsky.embed.images#view", 699 images: [{ alt: "Inline image one", fullsize: "https://cdn.example.com/post-image-one.jpg" }, { 700 alt: "Inline image two", 701 fullsize: "https://cdn.example.com/post-image-two.jpg", 702 }], 703 }, 704 }} /> 705 )); 706 707 fireEvent.contextMenu(screen.getByAltText("Inline image two")); 708 fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 709 710 await waitFor(() => 711 expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image-two.jpg", "123_2") 712 ); 713 }); 714 715 it("submits a report for the current post", async () => { 716 render(() => <PostCard post={createPost()} onOpenThread={vi.fn()} />); 717 718 fireEvent.click(screen.getByRole("button", { name: "More actions" })); 719 fireEvent.click(screen.getByRole("menuitem", { name: "Report post" })); 720 721 expect(await screen.findByText("Report content")).toBeInTheDocument(); 722 fireEvent.input(screen.getByPlaceholderText("Add context for moderators"), { 723 target: { value: "misleading link" }, 724 }); 725 fireEvent.click(screen.getByRole("button", { name: "Submit report" })); 726 727 await waitFor(() => 728 expect(createReportMock).toHaveBeenCalledWith( 729 { type: "record", uri: "at://did:plc:alice/app.bsky.feed.post/123", cid: "cid-post" }, 730 "com.atproto.moderation.defs#reasonSpam", 731 "misleading link", 732 ) 733 ); 734 }); 735 736 it("blocks the post author from the context menu", async () => { 737 const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true); 738 render(() => <PostCard post={createPost()} onOpenThread={vi.fn()} />); 739 740 fireEvent.click(screen.getByRole("button", { name: "More actions" })); 741 fireEvent.click(screen.getByRole("menuitem", { name: "Block @alice.test" })); 742 743 await waitFor(() => expect(blockActorMock).toHaveBeenCalledWith("did:plc:alice")); 744 confirmSpy.mockRestore(); 745 }); 746});