BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision";
2import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar";
3import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay";
4import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow";
5import { ReportDialog } from "$/components/moderation/ReportDialog";
6import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu";
7import { Icon } from "$/components/shared/Icon";
8import { PostRichText } from "$/components/shared/PostRichText";
9import { ModerationController } from "$/lib/api/moderation";
10import {
11 buildPublicPostUrl,
12 getAvatarLabel,
13 getDisplayName,
14 getPostCreatedAt,
15 getPostFacets,
16 getPostText,
17 hasKnownThreadContext,
18} from "$/lib/feeds";
19import { isReplyItem } from "$/lib/feeds/type-guards";
20import { collectModerationLabels } from "$/lib/moderation";
21import type { PostEngagementTab } from "$/lib/post-engagement-routes";
22import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile";
23import type {
24 EmbedView,
25 FeedViewPost,
26 ModerationLabel,
27 ModerationReasonType,
28 ModerationUiDecision,
29 PostView,
30 RichTextFacet,
31} from "$/lib/types";
32import { formatRelativeTime } from "$/lib/utils/text";
33import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text";
34import * as logger from "@tauri-apps/plugin-log";
35import { createMemo, createSignal, type ParentProps, Show, splitProps } from "solid-js";
36import { Motion } from "solid-motionone";
37import { EmbedContent } from "./embeds/ContentEmbed";
38import type { ReportTarget } from "./types";
39
40function isInteractiveTarget(target: EventTarget | null) {
41 return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']");
42}
43
44function isDecisionHidden(decision: ModerationUiDecision) {
45 return decision.filter || decision.blur !== "none";
46}
47
48function mergeModerationDecisions(
49 contentDecision: ModerationUiDecision,
50 mediaDecision: ModerationUiDecision,
51): ModerationUiDecision {
52 return {
53 alert: contentDecision.alert || mediaDecision.alert,
54 blur: contentDecision.blur !== "none" ? contentDecision.blur : mediaDecision.blur,
55 filter: contentDecision.filter || mediaDecision.filter,
56 inform: contentDecision.inform || mediaDecision.inform,
57 noOverride: contentDecision.noOverride || mediaDecision.noOverride,
58 };
59}
60
61function PostHeader(props: { authorHandle: string; authorHref: string; authorName: string; createdAt: string }) {
62 return (
63 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1">
64 <span class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface">{props.authorName}</span>
65 <a
66 class="break-all text-xs text-primary no-underline transition hover:underline"
67 href={`#${props.authorHref}`}
68 onClick={(event) => event.stopPropagation()}>
69 {props.authorHandle}
70 </a>
71 <span class="text-xs text-on-surface-variant">{props.createdAt}</span>
72 </header>
73 );
74}
75
76function PostPrimaryRegion(props: ParentProps<{ onFocus?: () => void; onOpenThread?: () => void }>) {
77 const interactive = () => !!props.onOpenThread;
78
79 return (
80 <div
81 class="min-w-0 rounded-2xl p-2 outline-none transition duration-150 ease-out"
82 classList={{
83 "cursor-pointer hover:bg-surface-bright focus-visible:bg-surface-bright focus-visible:ring-1 focus-visible:ring-primary/30":
84 interactive(),
85 }}
86 aria-label={interactive() ? "Open thread" : undefined}
87 role={interactive() ? "button" : undefined}
88 tabIndex={interactive() ? 0 : undefined}
89 onClick={(event) => {
90 if (isInteractiveTarget(event.target)) {
91 return;
92 }
93
94 props.onOpenThread?.();
95 }}
96 onFocus={() => props.onFocus?.()}
97 onKeyDown={(event) => {
98 if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) {
99 event.preventDefault();
100 props.onOpenThread();
101 }
102 }}>
103 {props.children}
104 </div>
105 );
106}
107
108type PostActionButtonProps = {
109 active?: boolean;
110 ariaExpanded?: boolean;
111 ariaHasPopup?: "menu";
112 ariaLabel?: string;
113 busy?: boolean;
114 icon: string;
115 iconActive?: string;
116 label: string;
117 onClick?: (event: MouseEvent) => void;
118 pulse?: boolean;
119};
120
121function PostActionButton(props: PostActionButtonProps) {
122 return (
123 <button
124 aria-expanded={props.ariaExpanded}
125 aria-haspopup={props.ariaHasPopup}
126 aria-label={props.ariaLabel ?? props.label}
127 class="inline-flex min-w-0 items-center gap-1.5 rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-primary disabled:cursor-wait disabled:opacity-70 max-[520px]:px-2.5"
128 classList={{ "text-primary": !!props.active }}
129 type="button"
130 disabled={props.busy}
131 onClick={(event) => {
132 event.stopPropagation();
133 props.onClick?.(event);
134 }}>
135 <Motion.span
136 class="flex items-center"
137 animate={{ scale: props.pulse ? [1, 1.3, 1] : 1 }}
138 transition={{ duration: 0.28 }}>
139 <Icon aria-hidden iconClass={props.active ? props.iconActive ?? props.icon : props.icon} />
140 </Motion.span>
141 <span class="max-w-24 truncate">{props.busy ? "..." : props.label}</span>
142 </button>
143 );
144}
145
146type PostActionStatus = {
147 bookmarkPending: boolean;
148 isBookmarked: boolean;
149 isLiked: boolean;
150 isReposted: boolean;
151 likeCount: string;
152 likePending: boolean;
153 pulseLike: boolean;
154 pulseRepost: boolean;
155 quoteCount: string;
156 replyCount: string;
157 repostCount: string;
158 repostPending: boolean;
159};
160
161type PostActionHandlers = {
162 onBookmark?: (event: MouseEvent) => void;
163 onLike?: (event: MouseEvent) => void;
164 onOpenEngagement?: (tab: PostEngagementTab) => void;
165 onOpenThread?: () => void;
166 onQuote?: (event: MouseEvent) => void;
167 onReply?: (event: MouseEvent) => void;
168 onRepost?: (event: MouseEvent) => void;
169};
170
171type PostActionsProps = {
172 handlers: PostActionHandlers;
173 menu: {
174 open: boolean;
175 onOpen: (element: HTMLButtonElement) => void;
176 triggerRef: (element: HTMLButtonElement) => void;
177 };
178 repostMenuOpen: boolean;
179 showThreadAction: boolean;
180 state: PostActionStatus;
181};
182
183function PostActions(props: PostActionsProps) {
184 const [status, menu, actions, visibility] = splitProps(props, ["state"], ["menu"], ["handlers"], [
185 "repostMenuOpen",
186 "showThreadAction",
187 ]);
188
189 return (
190 <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1">
191 <PostActionButton
192 active={status.state.isLiked}
193 ariaLabel="Like"
194 busy={status.state.likePending}
195 icon="i-ri-heart-3-line"
196 iconActive="i-ri-heart-3-fill"
197 label={status.state.likeCount}
198 pulse={status.state.pulseLike}
199 onClick={actions.handlers.onLike} />
200 <PostActionButton
201 ariaLabel="Reply"
202 icon="i-ri-chat-1-line"
203 label={status.state.replyCount}
204 onClick={actions.handlers.onReply} />
205 <PostActionButton
206 active={status.state.isReposted}
207 ariaExpanded={visibility.repostMenuOpen}
208 ariaHasPopup="menu"
209 ariaLabel="Repost"
210 busy={status.state.repostPending}
211 icon="i-ri-repeat-2-line"
212 iconActive="i-ri-repeat-2-fill"
213 label={status.state.repostCount}
214 pulse={status.state.pulseRepost}
215 onClick={actions.handlers.onRepost} />
216 <PostActionButton
217 active={status.state.isBookmarked}
218 ariaLabel={status.state.isBookmarked ? "Unsave" : "Save"}
219 busy={status.state.bookmarkPending}
220 icon="i-ri-bookmark-line"
221 iconActive="i-ri-bookmark-fill"
222 label={status.state.isBookmarked ? "Saved" : "Save"}
223 onClick={actions.handlers.onBookmark} />
224 <PostActionButton
225 ariaLabel="Quote"
226 icon="i-ri-chat-quote-line"
227 label={status.state.quoteCount}
228 onClick={actions.handlers.onQuote} />
229 <Show when={visibility.showThreadAction}>
230 <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={actions.handlers.onOpenThread} />
231 </Show>
232 <button
233 aria-label="More actions"
234 ref={(element) => menu.menu.triggerRef(element)}
235 aria-expanded={menu.menu.open}
236 aria-haspopup="menu"
237 class="inline-flex items-center justify-center rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-primary max-[520px]:px-2.5"
238 type="button"
239 onClick={(event) => {
240 event.stopPropagation();
241 menu.menu.onOpen(event.currentTarget);
242 }}>
243 <Icon aria-hidden iconClass="i-ri-more-fill" />
244 </button>
245 </footer>
246 );
247}
248
249function PostBodyText(props: { facets: RichTextFacet[]; text: string }) {
250 return (
251 <Show when={props.text.trim().length > 0}>
252 <PostRichText class="m-0" facets={props.facets} text={props.text} />
253 </Show>
254 );
255}
256
257function ModeratedPostBody(
258 props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView; text: string },
259) {
260 return (
261 <Show when={props.text.trim().length > 0}>
262 <ModeratedBlurOverlay decision={props.decision} labels={props.labels} class="mt-3">
263 <PostBodyText facets={getPostFacets(props.post)} text={props.text} />
264 </ModeratedBlurOverlay>
265 </Show>
266 );
267}
268
269function PostEmbedContent(
270 props: { embed: EmbedView; onOpenPost?: (uri: string) => void; post: PostView; withTopMargin?: boolean },
271) {
272 return (
273 <div classList={{ "mt-4": !!props.withTopMargin }}>
274 <EmbedContent embed={props.embed} onOpenPost={props.onOpenPost} post={props.post} />
275 </div>
276 );
277}
278
279function PostModeratedContent(
280 props: {
281 contentDecision: ModerationUiDecision;
282 contentLabels: ModerationLabel[];
283 hasPostText: boolean;
284 mediaDecision: ModerationUiDecision;
285 mediaLabels: ModerationLabel[];
286 mergeBodyAndEmbedModeration: boolean;
287 mergedPostDecision: ModerationUiDecision;
288 onOpenPost?: (uri: string) => void;
289 post: PostView;
290 text: string;
291 },
292) {
293 return (
294 <Show
295 when={props.mergeBodyAndEmbedModeration}
296 fallback={
297 <>
298 <ModeratedPostBody
299 decision={props.contentDecision}
300 labels={props.contentLabels}
301 post={props.post}
302 text={props.text} />
303 <Show when={props.post.embed}>
304 {(current) => (
305 <ModeratedBlurOverlay decision={props.mediaDecision} labels={props.mediaLabels} class="mt-4">
306 <PostEmbedContent embed={current()} onOpenPost={props.onOpenPost} post={props.post} />
307 </ModeratedBlurOverlay>
308 )}
309 </Show>
310 </>
311 }>
312 <ModeratedBlurOverlay decision={props.mergedPostDecision} labels={props.mediaLabels} class="mt-3">
313 <PostBodyText facets={getPostFacets(props.post)} text={props.text} />
314 <Show when={props.post.embed}>
315 {(current) => (
316 <PostEmbedContent
317 embed={current()}
318 onOpenPost={props.onOpenPost}
319 post={props.post}
320 withTopMargin={props.hasPostText} />
321 )}
322 </Show>
323 </ModeratedBlurOverlay>
324 </Show>
325 );
326}
327
328type PostCardProps = {
329 bookmarkPending?: boolean;
330 focused?: boolean;
331 item?: FeedViewPost;
332 likePending?: boolean;
333 onBookmark?: () => void;
334 onFocus?: () => void;
335 onLike?: () => void;
336 onOpenEngagement?: (tab: PostEngagementTab) => void;
337 onOpenThread?: (uri: string) => void;
338 onQuote?: () => void;
339 onReply?: () => void;
340 onRepost?: () => void;
341 post: PostView;
342 pulseLike?: boolean;
343 pulseRepost?: boolean;
344 registerRef?: (element: HTMLElement) => void;
345 repostPending?: boolean;
346 showActions?: boolean;
347};
348
349export function PostCard(props: PostCardProps) {
350 const [view, interactions, actionFlags] = splitProps(
351 props,
352 ["focused", "item", "post", "registerRef", "showActions"],
353 ["onBookmark", "onFocus", "onLike", "onOpenEngagement", "onOpenThread", "onQuote", "onReply", "onRepost"],
354 ["bookmarkPending", "likePending", "pulseLike", "pulseRepost", "repostPending"],
355 );
356
357 const authorName = createMemo(() => getDisplayName(view.post.author));
358 const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(view.post)));
359 const isBookmarked = createMemo(() => !!view.post.viewer?.bookmarked);
360 const isLiked = createMemo(() => !!view.post.viewer?.like);
361 const isReposted = createMemo(() => !!view.post.viewer?.repost);
362 const likeCount = createMemo(() => formatCount(view.post.likeCount));
363 const postText = createMemo(() => getPostText(view.post));
364 const quoteCount = createMemo(() => formatCount(view.post.quoteCount));
365 const replyCount = createMemo(() => formatCount(view.post.replyCount));
366 const repostCount = createMemo(() => formatCount(view.post.repostCount));
367 const authorHandle = createMemo(() => formatHandle(view.post.author.handle, view.post.author.did));
368 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(view.post.author)));
369 const contentLabels = () => collectModerationLabels(view.post);
370 const mediaLabels = () => collectModerationLabels(view.post, view.post.embed);
371 const authorLabels = () => collectModerationLabels(view.post.author);
372 const contentDecision = useModerationDecision(contentLabels, "contentList");
373 const mediaDecision = useModerationDecision(mediaLabels, "contentMedia");
374 const avatarDecision = useModerationDecision(authorLabels, "avatar");
375 const authorDecision = useModerationDecision(authorLabels, "profileList");
376 const contentHidden = createMemo(() => isDecisionHidden(contentDecision()));
377 const mediaHidden = createMemo(() => isDecisionHidden(mediaDecision()));
378 const mergeBodyAndEmbedModeration = createMemo(() => contentHidden() && mediaHidden());
379 const mergedPostDecision = createMemo(() => mergeModerationDecisions(contentDecision(), mediaDecision()));
380 const hasPostText = createMemo(() => postText().trim().length > 0);
381 const showThreadAction = createMemo(() => hasKnownThreadContext(view.post, view.item));
382 const openThread = (uri: string = view.post.uri) => {
383 interactions.onOpenThread?.(uri);
384 };
385 const reasonLabel = createMemo(() => {
386 const reason = view.item?.reason;
387 if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") {
388 return null;
389 }
390
391 return `${getDisplayName(reason.by)} reposted`;
392 });
393
394 const replyLabel = createMemo(() => {
395 const item = view.item;
396 if (!item || !isReplyItem(item)) {
397 return null;
398 }
399
400 const parent = item.reply?.parent;
401 if (parent?.$type === "app.bsky.feed.defs#postView") {
402 return `Replying to ${formatHandle(parent.author.handle, parent.author.did)}`;
403 }
404
405 return "Reply in thread";
406 });
407
408 const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null);
409 const [menuOpen, setMenuOpen] = createSignal(false);
410 const [repostMenuAnchor, setRepostMenuAnchor] = createSignal<ContextMenuAnchor | null>(null);
411 const [repostMenuOpen, setRepostMenuOpen] = createSignal(false);
412 const [reportOpen, setReportOpen] = createSignal(false);
413 const [reportTarget, setReportTarget] = createSignal<ReportTarget | null>(null);
414 let menuTriggerRef: HTMLButtonElement | undefined;
415 let repostMenuTriggerRef: HTMLButtonElement | undefined;
416
417 const menuItems = createMemo<ContextMenuItem[]>(() => {
418 const items: ContextMenuItem[] = [];
419
420 if (interactions.onReply) {
421 items.push({ icon: "i-ri-chat-1-line", label: "Reply", onSelect: interactions.onReply });
422 }
423
424 if (interactions.onQuote) {
425 items.push({ icon: "i-ri-chat-quote-line", label: "Quote", onSelect: interactions.onQuote });
426 }
427
428 if (interactions.onLike) {
429 items.push({
430 icon: isLiked() ? "i-ri-heart-3-fill" : "i-ri-heart-3-line",
431 label: isLiked() ? "Unlike" : "Like",
432 onSelect: interactions.onLike,
433 });
434 }
435
436 if (interactions.onRepost) {
437 items.push({
438 icon: isReposted() ? "i-ri-repeat-2-fill" : "i-ri-repeat-2-line",
439 label: isReposted() ? "Undo repost" : "Repost",
440 onSelect: interactions.onRepost,
441 });
442 }
443
444 if (interactions.onBookmark) {
445 items.push({
446 icon: isBookmarked() ? "i-ri-bookmark-fill" : "i-ri-bookmark-line",
447 label: isBookmarked() ? "Unsave" : "Save",
448 onSelect: interactions.onBookmark,
449 });
450 }
451
452 items.push({
453 icon: "i-ri-link-m",
454 label: "Copy post link",
455 onSelect: () => void navigator.clipboard?.writeText(buildPublicPostUrl(view.post)),
456 });
457
458 if (interactions.onOpenThread && showThreadAction()) {
459 items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: () => openThread() });
460 }
461
462 if (interactions.onOpenEngagement) {
463 items.push({
464 icon: "i-ri-heart-3-line",
465 label: `${formatCount(view.post.likeCount)} ${view.post.likeCount === 1 ? "like" : "likes"}`,
466 onSelect: () => interactions.onOpenEngagement?.("likes"),
467 }, {
468 icon: "i-ri-repeat-2-line",
469 label: `${formatCount(view.post.repostCount)} ${view.post.repostCount === 1 ? "repost" : "reposts"}`,
470 onSelect: () => interactions.onOpenEngagement?.("reposts"),
471 }, {
472 icon: "i-ri-chat-quote-line",
473 label: `${formatCount(view.post.quoteCount)} ${view.post.quoteCount === 1 ? "quote" : "quotes"}`,
474 onSelect: () => interactions.onOpenEngagement?.("quotes"),
475 });
476 }
477
478 items.push({
479 icon: "i-ri-flag-line",
480 label: "Report post",
481 onSelect: () => {
482 setReportTarget({
483 subject: { type: "record", uri: view.post.uri, cid: view.post.cid },
484 subjectLabel: `Post by @${view.post.author.handle}`,
485 });
486 setReportOpen(true);
487 },
488 }, {
489 icon: "i-ri-flag-2-line",
490 label: "Report account",
491 onSelect: () => {
492 setReportTarget({
493 subject: { type: "repo", did: view.post.author.did },
494 subjectLabel: `Account @${view.post.author.handle}`,
495 });
496 setReportOpen(true);
497 },
498 }, { icon: "i-ri-forbid-2-line", label: `Block @${view.post.author.handle}`, onSelect: () => void blockAuthor() });
499
500 return items;
501 });
502
503 const repostMenuItems = createMemo<ContextMenuItem[]>(() => {
504 const items: ContextMenuItem[] = [];
505
506 if (interactions.onRepost) {
507 items.push({
508 icon: isReposted() ? "i-ri-repeat-2-fill" : "i-ri-repeat-2-line",
509 label: isReposted() ? "Undo repost" : "Repost",
510 onSelect: interactions.onRepost,
511 });
512 }
513
514 if (interactions.onQuote) {
515 items.push({ icon: "i-ri-chat-quote-line", label: "Quote post", onSelect: interactions.onQuote });
516 }
517
518 return items;
519 });
520
521 function closeContextMenu() {
522 setMenuOpen(false);
523 setMenuAnchor(null);
524 }
525
526 function closeRepostMenu() {
527 setRepostMenuOpen(false);
528 setRepostMenuAnchor(null);
529 }
530
531 function openContextMenuFromTrigger(element: HTMLButtonElement) {
532 closeRepostMenu();
533 setMenuAnchor({ kind: "element", rect: element.getBoundingClientRect() });
534 setMenuOpen(true);
535 }
536
537 function openContextMenuFromPointer(event: MouseEvent) {
538 event.preventDefault();
539 closeRepostMenu();
540 setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY });
541 setMenuOpen(true);
542 }
543
544 function openRepostMenuFromTrigger(element: HTMLButtonElement) {
545 if (repostMenuItems().length === 0) {
546 return;
547 }
548
549 closeContextMenu();
550 repostMenuTriggerRef = element;
551 setRepostMenuAnchor({ kind: "element", rect: element.getBoundingClientRect() });
552 setRepostMenuOpen(true);
553 }
554
555 async function submitReport(input: { reasonType: ModerationReasonType; reason: string }) {
556 const target = reportTarget();
557 if (!target) {
558 return;
559 }
560
561 try {
562 await ModerationController.createReport(target.subject, input.reasonType, input.reason);
563 } catch (error) {
564 logger.error("failed to submit report", { keyValues: { error: normalizeError(error) } });
565 }
566 }
567
568 async function blockAuthor() {
569 const confirmed = globalThis.confirm
570 ? globalThis.confirm(`Block @${view.post.author.handle}? You can unblock from Bluesky settings.`)
571 : true;
572
573 if (!confirmed) {
574 return;
575 }
576
577 try {
578 await ModerationController.blockActor(view.post.author.did);
579 } catch (error) {
580 logger.error("failed to block account", { keyValues: { error: normalizeError(error) } });
581 }
582 }
583
584 return (
585 <article
586 ref={(element) => view.registerRef?.(element)}
587 class="tone-muted group min-w-0 overflow-hidden rounded-3xl px-4 py-4 shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright max-[760px]:px-3.5 max-[760px]:py-3.5 max-[520px]:rounded-3xl max-[520px]:px-3 max-[520px]:py-3"
588 classList={{
589 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]":
590 !!view.focused,
591 }}
592 role="article"
593 onContextMenu={(event) => {
594 if (menuItems().length === 0 || isInteractiveTarget(event.target)) {
595 return;
596 }
597
598 openContextMenuFromPointer(event);
599 }}>
600 <Show when={reasonLabel()}>
601 <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-primary">
602 <Icon aria-hidden iconClass="i-ri-repeat-2-line" />
603 <span>{reasonLabel()}</span>
604 </div>
605 </Show>
606 <Show when={replyLabel()}>
607 <div class="mb-3 flex items-center gap-2 text-xs font-medium tracking-[0.04em] text-on-surface-variant">
608 <Icon aria-hidden iconClass="i-ri-corner-down-right-line" />
609 <span>{replyLabel()}</span>
610 </div>
611 </Show>
612
613 <div class="flex min-w-0 gap-3">
614 <div class="shrink-0">
615 <a
616 aria-label={`View @${view.post.author.handle}`}
617 class="no-underline"
618 href={`#${profileHref()}`}
619 onClick={(event) => event.stopPropagation()}>
620 <ModeratedAvatar
621 avatar={view.post.author.avatar}
622 class="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] shadow-[0_0_0_2px_var(--surface-container),0_0_0_3px_rgba(125,175,255,0.28)]"
623 hidden={avatarDecision().filter || avatarDecision().blur !== "none"}
624 label={getAvatarLabel(view.post.author)}
625 fallbackClass="text-sm font-semibold text-on-primary-fixed" />
626 </a>
627 </div>
628
629 <div class="min-w-0 flex-1">
630 <PostPrimaryRegion onFocus={interactions.onFocus} onOpenThread={() => openThread()}>
631 <PostHeader
632 authorName={authorName()}
633 authorHandle={authorHandle()}
634 authorHref={profileHref()}
635 createdAt={createdAt()} />
636 <ModerationBadgeRow decision={authorDecision()} labels={authorLabels()} />
637 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} />
638 <PostModeratedContent
639 contentDecision={contentDecision()}
640 contentLabels={contentLabels()}
641 hasPostText={hasPostText()}
642 mediaDecision={mediaDecision()}
643 mediaLabels={mediaLabels()}
644 mergeBodyAndEmbedModeration={mergeBodyAndEmbedModeration()}
645 mergedPostDecision={mergedPostDecision()}
646 onOpenPost={openThread}
647 post={view.post}
648 text={postText()} />
649 </PostPrimaryRegion>
650 <Show when={view.showActions !== false}>
651 <PostActions
652 handlers={{
653 onBookmark: () => interactions.onBookmark?.(),
654 onLike: (event) => {
655 if (event.shiftKey && interactions.onOpenEngagement) {
656 interactions.onOpenEngagement("likes");
657 return;
658 }
659
660 interactions.onLike?.();
661 },
662 onOpenThread: () => openThread(),
663 onQuote: (event) => {
664 if (event.shiftKey && interactions.onOpenEngagement) {
665 interactions.onOpenEngagement("quotes");
666 return;
667 }
668
669 interactions.onQuote?.();
670 },
671 onReply: () => interactions.onReply?.(),
672 onRepost: (event) => {
673 if (event.shiftKey) {
674 interactions.onRepost?.();
675 return;
676 }
677
678 openRepostMenuFromTrigger(event.currentTarget as HTMLButtonElement);
679 },
680 }}
681 menu={{
682 open: menuOpen(),
683 onOpen: openContextMenuFromTrigger,
684 triggerRef: (element) => {
685 menuTriggerRef = element;
686 },
687 }}
688 repostMenuOpen={repostMenuOpen()}
689 showThreadAction={showThreadAction()}
690 state={{
691 bookmarkPending: !!actionFlags.bookmarkPending,
692 isBookmarked: isBookmarked(),
693 isLiked: isLiked(),
694 isReposted: isReposted(),
695 likeCount: likeCount(),
696 likePending: !!actionFlags.likePending,
697 pulseLike: !!actionFlags.pulseLike,
698 pulseRepost: !!actionFlags.pulseRepost,
699 quoteCount: quoteCount(),
700 replyCount: replyCount(),
701 repostCount: repostCount(),
702 repostPending: !!actionFlags.repostPending,
703 }} />
704 </Show>
705 </div>
706 </div>
707
708 <ContextMenu
709 anchor={menuAnchor()}
710 items={menuItems()}
711 label="Post actions"
712 open={menuOpen()}
713 returnFocusTo={menuTriggerRef}
714 onClose={closeContextMenu} />
715 <ContextMenu
716 anchor={repostMenuAnchor()}
717 items={repostMenuItems()}
718 label="Repost actions"
719 open={repostMenuOpen()}
720 returnFocusTo={repostMenuTriggerRef}
721 onClose={closeRepostMenu} />
722 <ReportDialog
723 open={reportOpen()}
724 subjectLabel={reportTarget()?.subjectLabel ?? "Report content"}
725 onClose={() => setReportOpen(false)}
726 onSubmit={submitReport} />
727 </article>
728 );
729}