BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
at main 729 lines 27 kB view raw
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}