BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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});