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.

refactor: split up embeds

+450 -464
+183 -460
src/components/feeds/PostCard.tsx
··· 1 - import { ImageGallery } from "$/components/feeds/ImageGallery"; 2 - import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 3 - import { VideoEmbed } from "$/components/feeds/VideoEmbed"; 4 1 import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 5 2 import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay"; 6 3 import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; ··· 9 6 import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; 10 7 import { Icon } from "$/components/shared/Icon"; 11 8 import { PostRichText } from "$/components/shared/PostRichText"; 12 - import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 13 - import { MediaController } from "$/lib/api/media"; 14 9 import { ModerationController } from "$/lib/api/moderation"; 15 10 import { 16 11 buildPublicPostUrl, ··· 20 15 getPostCreatedAt, 21 16 getPostFacets, 22 17 getPostText, 23 - getQuotedAuthor, 24 - getQuotedHref, 25 - getQuotedText, 26 18 isReplyItem, 27 19 } from "$/lib/feeds"; 28 20 import { collectModerationLabels } from "$/lib/moderation"; 29 21 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 30 22 import type { 31 - EmbedView, 32 23 FeedViewPost, 33 - ImagesEmbedView, 34 24 ModerationLabel, 35 25 ModerationReasonType, 36 26 ModerationUiDecision, 37 27 PostView, 38 - ProfileViewBasic, 39 - ReportSubjectInput, 40 28 RichTextFacet, 41 29 } from "$/lib/types"; 42 30 import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; 43 31 import * as logger from "@tauri-apps/plugin-log"; 44 - import { revealItemInDir } from "@tauri-apps/plugin-opener"; 45 - import { createMemo, createSignal, For, Match, onCleanup, type ParentProps, Show, Switch } from "solid-js"; 32 + import { createMemo, createSignal, type ParentProps, Show } from "solid-js"; 46 33 import { Motion } from "solid-motionone"; 34 + import { EmbedContent } from "./embeds/ContentEmbed"; 35 + import type { ReportTarget } from "./types"; 36 + 37 + function isInteractiveTarget(target: EventTarget | null) { 38 + return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']"); 39 + } 40 + 41 + function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string; profileHref: string }) { 42 + return ( 43 + <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 44 + <a 45 + class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface no-underline transition hover:text-primary" 46 + href={`#${props.profileHref}`} 47 + onClick={(event) => event.stopPropagation()}> 48 + {props.authorName} 49 + </a> 50 + <a 51 + class="break-all text-xs text-on-surface-variant no-underline transition hover:text-primary" 52 + href={`#${props.profileHref}`} 53 + onClick={(event) => event.stopPropagation()}> 54 + {props.authorHandle} 55 + </a> 56 + <span class="text-xs text-on-surface-variant">{props.createdAt}</span> 57 + </header> 58 + ); 59 + } 60 + 61 + function PostPrimaryRegion(props: ParentProps<{ onFocus?: () => void; onOpenThread?: () => void }>) { 62 + const interactive = () => !!props.onOpenThread; 63 + 64 + return ( 65 + <div 66 + class="min-w-0 rounded-2xl p-2 outline-none transition duration-150 ease-out" 67 + classList={{ 68 + "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 69 + interactive(), 70 + }} 71 + aria-label={interactive() ? "Open thread" : undefined} 72 + role={interactive() ? "button" : undefined} 73 + tabIndex={interactive() ? 0 : undefined} 74 + onClick={() => props.onOpenThread?.()} 75 + onFocus={() => props.onFocus?.()} 76 + onKeyDown={(event) => { 77 + if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) { 78 + event.preventDefault(); 79 + props.onOpenThread(); 80 + } 81 + }}> 82 + {props.children} 83 + </div> 84 + ); 85 + } 86 + 87 + type PostActionButtonProps = { 88 + active?: boolean; 89 + busy?: boolean; 90 + icon: string; 91 + iconActive?: string; 92 + label: string; 93 + onClick?: () => void; 94 + pulse?: boolean; 95 + }; 96 + 97 + function PostActionButton(props: PostActionButtonProps) { 98 + return ( 99 + <button 100 + aria-label={props.label} 101 + 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-white/5 hover:text-primary disabled:cursor-wait disabled:opacity-70 max-[520px]:px-2.5" 102 + classList={{ "text-primary": !!props.active }} 103 + type="button" 104 + disabled={props.busy} 105 + onClick={(event) => { 106 + event.stopPropagation(); 107 + props.onClick?.(); 108 + }}> 109 + <Motion.span 110 + class="flex items-center" 111 + animate={{ scale: props.pulse ? [1, 1.3, 1] : 1 }} 112 + transition={{ duration: 0.28 }}> 113 + <Icon aria-hidden="true" iconClass={props.active ? props.iconActive ?? props.icon : props.icon} /> 114 + </Motion.span> 115 + <span class="max-w-24 truncate">{props.busy ? "..." : props.label}</span> 116 + </button> 117 + ); 118 + } 119 + 120 + // FIXME: this is an absurdly large number of props 121 + type PostActionsProps = { 122 + bookmarkPending: boolean; 123 + isBookmarked: boolean; 124 + isLiked: boolean; 125 + isReposted: boolean; 126 + likeCount: string; 127 + likePending: boolean; 128 + menuOpen: boolean; 129 + pulseLike: boolean; 130 + pulseRepost: boolean; 131 + replyCount: string; 132 + repostCount: string; 133 + repostPending: boolean; 134 + triggerRef: (element: HTMLButtonElement) => void; 135 + onBookmark?: () => void; 136 + onLike?: () => void; 137 + onMenuOpen: (element: HTMLButtonElement) => void; 138 + onOpenThread?: () => void; 139 + onQuote?: () => void; 140 + onReply?: () => void; 141 + onRepost?: () => void; 142 + }; 143 + 144 + function PostActions(props: PostActionsProps) { 145 + return ( 146 + <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 147 + <PostActionButton 148 + active={props.isLiked} 149 + busy={props.likePending} 150 + icon="i-ri-heart-3-line" 151 + iconActive="i-ri-heart-3-fill" 152 + label={props.likeCount} 153 + pulse={props.pulseLike} 154 + onClick={props.onLike} /> 155 + <PostActionButton icon="i-ri-chat-1-line" label={props.replyCount} onClick={props.onReply} /> 156 + <PostActionButton 157 + active={props.isReposted} 158 + busy={props.repostPending} 159 + icon="i-ri-repeat-2-line" 160 + iconActive="i-ri-repeat-2-fill" 161 + label={props.repostCount} 162 + pulse={props.pulseRepost} 163 + onClick={props.onRepost} /> 164 + <PostActionButton 165 + active={props.isBookmarked} 166 + busy={props.bookmarkPending} 167 + icon="i-ri-bookmark-line" 168 + iconActive="i-ri-bookmark-fill" 169 + label={props.isBookmarked ? "Saved" : "Save"} 170 + onClick={props.onBookmark} /> 171 + <PostActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 172 + <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 173 + <button 174 + aria-label="More actions" 175 + ref={(element) => props.triggerRef(element)} 176 + aria-expanded={props.menuOpen} 177 + aria-haspopup="menu" 178 + 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-white/5 hover:text-primary max-[520px]:px-2.5" 179 + type="button" 180 + onClick={(event) => { 181 + event.stopPropagation(); 182 + props.onMenuOpen(event.currentTarget); 183 + }}> 184 + <Icon aria-hidden="true" iconClass="i-ri-more-fill" /> 185 + </button> 186 + </footer> 187 + ); 188 + } 189 + 190 + function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { 191 + return ( 192 + <Show when={props.text.trim().length > 0}> 193 + <PostRichText class="m-0" facets={props.facets} text={props.text} /> 194 + </Show> 195 + ); 196 + } 197 + 198 + function ModeratedPostBody( 199 + props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView; text: string }, 200 + ) { 201 + return ( 202 + <Show when={props.text.trim().length > 0}> 203 + <ModeratedBlurOverlay decision={props.decision} labels={props.labels} class="mt-3"> 204 + <PostBodyText facets={getPostFacets(props.post)} text={props.text} /> 205 + </ModeratedBlurOverlay> 206 + </Show> 207 + ); 208 + } 47 209 48 210 type PostCardProps = { 49 211 bookmarkPending?: boolean; ··· 64 226 repostPending?: boolean; 65 227 showActions?: boolean; 66 228 }; 67 - 68 - type ReportTarget = { subject: ReportSubjectInput; subjectLabel: string }; 69 229 70 230 export function PostCard(props: PostCardProps) { 71 231 const authorName = createMemo(() => getDisplayName(props.post.author)); ··· 284 444 post={props.post} 285 445 text={postText()} /> 286 446 287 - <PostEmbeds decision={mediaDecision()} labels={mediaLabels()} post={props.post} /> 447 + <Show when={props.post.embed}> 448 + {(current) => ( 449 + <ModeratedBlurOverlay decision={mediaDecision()} labels={mediaLabels()} class="mt-4"> 450 + <EmbedContent embed={current()} post={props.post} /> 451 + </ModeratedBlurOverlay> 452 + )} 453 + </Show> 288 454 </PostPrimaryRegion> 289 455 290 456 <Show when={props.showActions !== false}> ··· 331 497 </article> 332 498 ); 333 499 } 334 - 335 - function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string; profileHref: string }) { 336 - return ( 337 - <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 338 - <a 339 - class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface no-underline transition hover:text-primary" 340 - href={`#${props.profileHref}`} 341 - onClick={(event) => event.stopPropagation()}> 342 - {props.authorName} 343 - </a> 344 - <a 345 - class="break-all text-xs text-on-surface-variant no-underline transition hover:text-primary" 346 - href={`#${props.profileHref}`} 347 - onClick={(event) => event.stopPropagation()}> 348 - {props.authorHandle} 349 - </a> 350 - <span class="text-xs text-on-surface-variant">{props.createdAt}</span> 351 - </header> 352 - ); 353 - } 354 - 355 - function PostPrimaryRegion(props: ParentProps<{ onFocus?: () => void; onOpenThread?: () => void }>) { 356 - const interactive = () => !!props.onOpenThread; 357 - 358 - return ( 359 - <div 360 - class="min-w-0 rounded-2xl p-2 outline-none transition duration-150 ease-out" 361 - classList={{ 362 - "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 363 - interactive(), 364 - }} 365 - aria-label={interactive() ? "Open thread" : undefined} 366 - role={interactive() ? "button" : undefined} 367 - tabIndex={interactive() ? 0 : undefined} 368 - onClick={() => props.onOpenThread?.()} 369 - onFocus={() => props.onFocus?.()} 370 - onKeyDown={(event) => { 371 - if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) { 372 - event.preventDefault(); 373 - props.onOpenThread(); 374 - } 375 - }}> 376 - {props.children} 377 - </div> 378 - ); 379 - } 380 - 381 - function PostActions( 382 - props: { 383 - bookmarkPending: boolean; 384 - isBookmarked: boolean; 385 - isLiked: boolean; 386 - isReposted: boolean; 387 - likeCount: string; 388 - likePending: boolean; 389 - menuOpen: boolean; 390 - pulseLike: boolean; 391 - pulseRepost: boolean; 392 - replyCount: string; 393 - repostCount: string; 394 - repostPending: boolean; 395 - triggerRef: (element: HTMLButtonElement) => void; 396 - onBookmark?: () => void; 397 - onLike?: () => void; 398 - onMenuOpen: (element: HTMLButtonElement) => void; 399 - onOpenThread?: () => void; 400 - onQuote?: () => void; 401 - onReply?: () => void; 402 - onRepost?: () => void; 403 - }, 404 - ) { 405 - return ( 406 - <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 407 - <ActionButton 408 - active={props.isLiked} 409 - busy={props.likePending} 410 - icon="i-ri-heart-3-line" 411 - iconActive="i-ri-heart-3-fill" 412 - label={props.likeCount} 413 - pulse={props.pulseLike} 414 - onClick={props.onLike} /> 415 - <ActionButton icon="i-ri-chat-1-line" label={props.replyCount} onClick={props.onReply} /> 416 - <ActionButton 417 - active={props.isReposted} 418 - busy={props.repostPending} 419 - icon="i-ri-repeat-2-line" 420 - iconActive="i-ri-repeat-2-fill" 421 - label={props.repostCount} 422 - pulse={props.pulseRepost} 423 - onClick={props.onRepost} /> 424 - <ActionButton 425 - active={props.isBookmarked} 426 - busy={props.bookmarkPending} 427 - icon="i-ri-bookmark-line" 428 - iconActive="i-ri-bookmark-fill" 429 - label={props.isBookmarked ? "Saved" : "Save"} 430 - onClick={props.onBookmark} /> 431 - <ActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 432 - <ActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 433 - <button 434 - aria-label="More actions" 435 - ref={(element) => props.triggerRef(element)} 436 - aria-expanded={props.menuOpen} 437 - aria-haspopup="menu" 438 - 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-white/5 hover:text-primary max-[520px]:px-2.5" 439 - type="button" 440 - onClick={(event) => { 441 - event.stopPropagation(); 442 - props.onMenuOpen(event.currentTarget); 443 - }}> 444 - <Icon aria-hidden="true" iconClass="i-ri-more-fill" /> 445 - </button> 446 - </footer> 447 - ); 448 - } 449 - 450 - function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { 451 - return ( 452 - <Show when={props.text.trim().length > 0}> 453 - <PostRichText class="m-0" facets={props.facets} text={props.text} /> 454 - </Show> 455 - ); 456 - } 457 - 458 - function ModeratedPostBody( 459 - props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView; text: string }, 460 - ) { 461 - return ( 462 - <Show when={props.text.trim().length > 0}> 463 - <ModeratedBlurOverlay decision={props.decision} labels={props.labels} class="mt-3"> 464 - <PostBodyText facets={getPostFacets(props.post)} text={props.text} /> 465 - </ModeratedBlurOverlay> 466 - </Show> 467 - ); 468 - } 469 - 470 - function ActionButton( 471 - props: { 472 - active?: boolean; 473 - busy?: boolean; 474 - icon: string; 475 - iconActive?: string; 476 - label: string; 477 - onClick?: () => void; 478 - pulse?: boolean; 479 - }, 480 - ) { 481 - return ( 482 - <button 483 - aria-label={props.label} 484 - 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-white/5 hover:text-primary disabled:cursor-wait disabled:opacity-70 max-[520px]:px-2.5" 485 - classList={{ "text-primary": !!props.active }} 486 - type="button" 487 - disabled={props.busy} 488 - onClick={(event) => { 489 - event.stopPropagation(); 490 - props.onClick?.(); 491 - }}> 492 - <Motion.span 493 - class="flex items-center" 494 - animate={{ scale: props.pulse ? [1, 1.3, 1] : 1 }} 495 - transition={{ duration: 0.28 }}> 496 - <Icon aria-hidden="true" iconClass={props.active ? props.iconActive ?? props.icon : props.icon} /> 497 - </Motion.span> 498 - <span class="max-w-24 truncate">{props.busy ? "..." : props.label}</span> 499 - </button> 500 - ); 501 - } 502 - 503 - function PostEmbeds(props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView }) { 504 - return ( 505 - <Show when={props.post.embed}> 506 - {(current) => ( 507 - <ModeratedBlurOverlay decision={props.decision} labels={props.labels} class="mt-4"> 508 - <EmbedContent embed={current()} post={props.post} /> 509 - </ModeratedBlurOverlay> 510 - )} 511 - </Show> 512 - ); 513 - } 514 - 515 - function EmbedContent(props: { embed: EmbedView; post: PostView }) { 516 - const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 517 - 518 - return ( 519 - <Switch> 520 - <Match when={props.embed.$type === "app.bsky.embed.images#view"}> 521 - <ImageEmbed embed={props.embed as ImagesEmbedView} post={props.post} /> 522 - </Match> 523 - <Match when={props.embed.$type === "app.bsky.embed.external#view"}> 524 - <ExternalEmbed 525 - description={(props.embed as { external: { description?: string } }).external.description} 526 - thumb={(props.embed as { external: { thumb?: string } }).external.thumb} 527 - title={(props.embed as { external: { title?: string } }).external.title} 528 - uri={(props.embed as { external: { uri?: string } }).external.uri} /> 529 - </Match> 530 - <Match when={props.embed.$type === "app.bsky.embed.video#view"}> 531 - <VideoEmbed 532 - alt={(props.embed as { alt?: string }).alt} 533 - aspectRatio={(props.embed as { aspectRatio?: { height: number; width: number } }).aspectRatio} 534 - downloadFilename={postRkey() ?? undefined} 535 - playlist={(props.embed as { playlist?: string }).playlist} 536 - thumbnail={(props.embed as { thumbnail?: string }).thumbnail} /> 537 - </Match> 538 - <Match when={props.embed.$type === "app.bsky.embed.record#view"}> 539 - <RecordEmbedContent embed={props.embed} /> 540 - </Match> 541 - <Match when={props.embed.$type === "app.bsky.embed.recordWithMedia#view"}> 542 - <RecordWithMediaEmbedContent embed={props.embed} post={props.post} /> 543 - </Match> 544 - </Switch> 545 - ); 546 - } 547 - 548 - function RecordEmbedContent(props: { embed: EmbedView }) { 549 - return ( 550 - <QuoteEmbed 551 - author={getQuotedAuthor(props.embed)} 552 - href={getQuotedHref(props.embed)} 553 - text={getQuotedText(props.embed)} 554 - title="Quoted post" /> 555 - ); 556 - } 557 - 558 - function RecordWithMediaEmbedContent(props: { embed: EmbedView; post: PostView }) { 559 - const media = () => ("media" in props.embed ? props.embed.media : null); 560 - 561 - return ( 562 - <div class="grid gap-3"> 563 - <Show when={media()}>{(current) => <EmbedContent embed={current() as EmbedView} post={props.post} />}</Show> 564 - <QuoteEmbed 565 - author={getQuotedAuthor(props.embed)} 566 - href={getQuotedHref(props.embed)} 567 - text={getQuotedText(props.embed)} 568 - title="Quoted post" /> 569 - </div> 570 - ); 571 - } 572 - 573 - function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { 574 - const images = createMemo(() => props.embed.images.slice(0, 4)); 575 - const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 576 - const [galleryStartIndex, setGalleryStartIndex] = createSignal<number | null>(null); 577 - const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 578 - const [menuOpen, setMenuOpen] = createSignal(false); 579 - const [menuImageIndex, setMenuImageIndex] = createSignal<number | null>(null); 580 - const [menuImageUrl, setMenuImageUrl] = createSignal<string | null>(null); 581 - const [downloadPending, setDownloadPending] = createSignal(false); 582 - const [notice, setNotice] = createSignal<MediaNotice | null>(null); 583 - let noticeTimer: ReturnType<typeof setTimeout> | null = null; 584 - 585 - const postText = createMemo(() => getPostText(props.post)); 586 - const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); 587 - const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 588 - const menuItems = createMemo<ContextMenuItem[]>( 589 - () => [{ 590 - disabled: !menuImageUrl() || downloadPending(), 591 - icon: downloadPending() ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line", 592 - label: downloadPending() ? "Saving..." : "Save image", 593 - onSelect: () => void downloadFromContextMenu(), 594 - }] 595 - ); 596 - 597 - onCleanup(() => { 598 - if (noticeTimer !== null) { 599 - clearTimeout(noticeTimer); 600 - } 601 - }); 602 - 603 - function dismissNotice() { 604 - setNotice(null); 605 - if (noticeTimer !== null) { 606 - clearTimeout(noticeTimer); 607 - noticeTimer = null; 608 - } 609 - } 610 - 611 - function queueNotice(next: MediaNotice) { 612 - dismissNotice(); 613 - setNotice(next); 614 - noticeTimer = setTimeout(() => { 615 - setNotice(null); 616 - noticeTimer = null; 617 - }, 6000); 618 - } 619 - 620 - function closeMenu() { 621 - setMenuOpen(false); 622 - setMenuAnchor(null); 623 - setMenuImageIndex(null); 624 - setMenuImageUrl(null); 625 - } 626 - 627 - function openGallery(index: number, event: MouseEvent) { 628 - event.stopPropagation(); 629 - setGalleryStartIndex(index); 630 - } 631 - 632 - function openImageMenu(event: MouseEvent, url: string | undefined, imageIndex: number) { 633 - event.preventDefault(); 634 - event.stopPropagation(); 635 - 636 - setMenuImageIndex(imageIndex); 637 - setMenuImageUrl(url ?? null); 638 - setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); 639 - setMenuOpen(true); 640 - } 641 - 642 - async function downloadFromContextMenu() { 643 - const url = menuImageUrl(); 644 - const imageIndex = menuImageIndex(); 645 - if (!url || downloadPending()) { 646 - return; 647 - } 648 - 649 - setDownloadPending(true); 650 - try { 651 - const requestedFilename = buildImageFilename(postRkey(), images().length, imageIndex)?.trim(); 652 - const result = await MediaController.downloadImage(url, requestedFilename ?? null); 653 - 654 - queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 655 - } catch (error) { 656 - queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); 657 - } finally { 658 - setDownloadPending(false); 659 - } 660 - } 661 - 662 - return ( 663 - <> 664 - <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 665 - <For each={images()}> 666 - {(image, index) => ( 667 - <button 668 - type="button" 669 - class="overflow-hidden rounded-[1.2rem] border-0 bg-black/30 p-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 670 - onClick={(event) => openGallery(index(), event)} 671 - onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb, index())}> 672 - <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> 673 - </button> 674 - )} 675 - </For> 676 - </div> 677 - 678 - <ImageGallery 679 - authorHandle={authorHandle()} 680 - authorHref={profileHref()} 681 - images={images()} 682 - open={galleryStartIndex() !== null} 683 - postText={postText()} 684 - startIndex={galleryStartIndex() ?? 0} 685 - downloadFilenameForIndex={(imageIndex) => buildImageFilename(postRkey(), images().length, imageIndex)} 686 - onClose={() => setGalleryStartIndex(null)} /> 687 - 688 - <ContextMenu 689 - anchor={menuAnchor()} 690 - items={menuItems()} 691 - label="Image actions" 692 - open={menuOpen()} 693 - onClose={closeMenu} /> 694 - 695 - <MediaNoticeToast notice={notice()} onDismiss={dismissNotice} onOpenPath={revealItemInDir} /> 696 - </> 697 - ); 698 - } 699 - 700 - function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 701 - return ( 702 - <a 703 - class="grid min-w-0 gap-3 overflow-hidden rounded-2xl bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 704 - href={props.uri} 705 - rel="noreferrer" 706 - target="_blank" 707 - onClick={(event) => event.stopPropagation()}> 708 - <Show when={props.thumb}> 709 - {(thumb) => <img class="max-h-64 w-full rounded-2xl object-cover" src={thumb()} alt="" />} 710 - </Show> 711 - <div class="grid gap-1"> 712 - <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface">{props.title || "External link"}</p> 713 - <Show when={props.description}> 714 - {(description) => ( 715 - <p class="m-0 wrap-break-word text-sm leading-[1.55] text-on-surface-variant">{description()}</p> 716 - )} 717 - </Show> 718 - <Show when={props.uri}> 719 - {(uri) => ( 720 - <p class="m-0 break-all text-xs uppercase tracking-[0.08em] text-primary"> 721 - {uri().replace(/^https?:\/\//, "")} 722 - </p> 723 - )} 724 - </Show> 725 - </div> 726 - </a> 727 - ); 728 - } 729 - 730 - function QuoteEmbed(props: { author: ProfileViewBasic | null; href?: string | null; text?: unknown; title: string }) { 731 - return <QuotedPostPreview author={props.author} href={props.href} text={props.text} title={props.title} />; 732 - } 733 - 734 - function isInteractiveTarget(target: EventTarget | null) { 735 - return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']"); 736 - } 737 - 738 - function postRkeyFromUri(uri: string | null | undefined) { 739 - if (typeof uri !== "string") { 740 - return null; 741 - } 742 - 743 - const trimmed = uri.trim(); 744 - if (!trimmed.startsWith("at://")) { 745 - return null; 746 - } 747 - 748 - const rkey = trimmed.split("/").at(-1)?.trim(); 749 - return rkey || null; 750 - } 751 - 752 - function buildImageFilename(postRkey: string | null, imageCount: number, imageIndex: number | null) { 753 - if (!postRkey) { 754 - return null; 755 - } 756 - 757 - if (imageCount > 1 && imageIndex !== null && imageIndex >= 0) { 758 - return `${postRkey}_${imageIndex + 1}`; 759 - } 760 - 761 - return postRkey; 762 - } 763 - 764 - function filenameFromPath(path: string) { 765 - const parts = path.split(/[/\\]/u); 766 - return parts.at(-1) || "downloaded file"; 767 - } 768 - 769 - function toDownloadErrorMessage(error: unknown) { 770 - const message = normalizeError(error); 771 - if (/download folder|writable|save|directory|exists/iu.test(message)) { 772 - return "Couldn't save — check that the download folder exists."; 773 - } 774 - 775 - return "Couldn't save this image right now."; 776 - }
src/components/feeds/VideoEmbed.tsx src/components/feeds/embeds/VideoEmbed.tsx
+52
src/components/feeds/embeds/ContentEmbed.tsx
··· 1 + import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 2 + import { getQuotedAuthor, getQuotedHref, getQuotedText, postRkeyFromUri } from "$/lib/feeds"; 3 + import type { EmbedView, ImagesEmbedView, PostView } from "$/lib/types"; 4 + import { createMemo, Match, Show, Switch } from "solid-js"; 5 + import { ExternalEmbed } from "./ExternalEmbed"; 6 + import { ImageEmbed } from "./ImageEmbed"; 7 + import { VideoEmbed } from "./VideoEmbed"; 8 + 9 + export function EmbedContent(props: { embed: EmbedView; post: PostView }) { 10 + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 11 + const media = () => ("media" in props.embed ? props.embed.media : null); 12 + 13 + return ( 14 + <Switch> 15 + <Match when={props.embed.$type === "app.bsky.embed.images#view"}> 16 + <ImageEmbed embed={props.embed as ImagesEmbedView} post={props.post} /> 17 + </Match> 18 + <Match when={props.embed.$type === "app.bsky.embed.external#view"}> 19 + <ExternalEmbed 20 + description={(props.embed as { external: { description?: string } }).external.description} 21 + thumb={(props.embed as { external: { thumb?: string } }).external.thumb} 22 + title={(props.embed as { external: { title?: string } }).external.title} 23 + uri={(props.embed as { external: { uri?: string } }).external.uri} /> 24 + </Match> 25 + <Match when={props.embed.$type === "app.bsky.embed.video#view"}> 26 + <VideoEmbed 27 + alt={(props.embed as { alt?: string }).alt} 28 + aspectRatio={(props.embed as { aspectRatio?: { height: number; width: number } }).aspectRatio} 29 + downloadFilename={postRkey() ?? undefined} 30 + playlist={(props.embed as { playlist?: string }).playlist} 31 + thumbnail={(props.embed as { thumbnail?: string }).thumbnail} /> 32 + </Match> 33 + <Match when={props.embed.$type === "app.bsky.embed.record#view"}> 34 + <QuotedPostPreview 35 + author={getQuotedAuthor(props.embed)} 36 + href={getQuotedHref(props.embed)} 37 + text={getQuotedText(props.embed)} 38 + title="Quoted post" /> 39 + </Match> 40 + <Match when={props.embed.$type === "app.bsky.embed.recordWithMedia#view"}> 41 + <div class="grid gap-3"> 42 + <Show when={media()}>{(current) => <EmbedContent embed={current() as EmbedView} post={props.post} />}</Show> 43 + <QuotedPostPreview 44 + author={getQuotedAuthor(props.embed)} 45 + href={getQuotedHref(props.embed)} 46 + text={getQuotedText(props.embed)} 47 + title="Quoted post" /> 48 + </div> 49 + </Match> 50 + </Switch> 51 + ); 52 + }
+31
src/components/feeds/embeds/ExternalEmbed.tsx
··· 1 + import { Show } from "solid-js"; 2 + 3 + export function ExternalEmbed(props: { description?: string; thumb?: string; title?: string; uri?: string }) { 4 + return ( 5 + <a 6 + class="grid min-w-0 gap-3 overflow-hidden rounded-2xl bg-black/30 p-3 text-inherit no-underline shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)] transition duration-150 ease-out hover:bg-black/40" 7 + href={props.uri} 8 + rel="noreferrer" 9 + target="_blank" 10 + onClick={(event) => event.stopPropagation()}> 11 + <Show when={props.thumb}> 12 + {(thumb) => <img class="max-h-64 w-full rounded-2xl object-cover" src={thumb()} alt="" />} 13 + </Show> 14 + <div class="grid gap-1"> 15 + <p class="m-0 wrap-break-word text-sm font-semibold text-on-surface">{props.title || "External link"}</p> 16 + <Show when={props.description}> 17 + {(description) => ( 18 + <p class="m-0 wrap-break-word text-sm leading-[1.55] text-on-surface-variant">{description()}</p> 19 + )} 20 + </Show> 21 + <Show when={props.uri}> 22 + {(uri) => ( 23 + <p class="m-0 break-all text-xs uppercase tracking-[0.08em] text-primary"> 24 + {uri().replace(/^https?:\/\//, "")} 25 + </p> 26 + )} 27 + </Show> 28 + </div> 29 + </a> 30 + ); 31 + }
+163
src/components/feeds/embeds/ImageEmbed.tsx
··· 1 + import { ImageGallery } from "$/components/feeds/ImageGallery"; 2 + import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 3 + import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; 4 + import { MediaController } from "$/lib/api/media"; 5 + import { getPostText, postRkeyFromUri } from "$/lib/feeds"; 6 + import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 7 + import type { ImagesEmbedView, PostView } from "$/lib/types"; 8 + import { formatHandle, normalizeError } from "$/lib/utils/text"; 9 + import { revealItemInDir } from "@tauri-apps/plugin-opener"; 10 + import { createMemo, createSignal, For, onCleanup } from "solid-js"; 11 + 12 + function buildImageFilename(postRkey: string | null, imageCount: number, imageIndex: number | null) { 13 + if (!postRkey) { 14 + return null; 15 + } 16 + 17 + if (imageCount > 1 && imageIndex !== null && imageIndex >= 0) { 18 + return `${postRkey}_${imageIndex + 1}`; 19 + } 20 + 21 + return postRkey; 22 + } 23 + 24 + function filenameFromPath(path: string) { 25 + const parts = path.split(/[/\\]/u); 26 + return parts.at(-1) || "downloaded file"; 27 + } 28 + 29 + function toDownloadErrorMessage(error: unknown) { 30 + const message = normalizeError(error); 31 + if (/download folder|writable|save|directory|exists/iu.test(message)) { 32 + return "Couldn't save — check that the download folder exists."; 33 + } 34 + 35 + return "Couldn't save this image right now."; 36 + } 37 + 38 + export function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { 39 + const images = createMemo(() => props.embed.images.slice(0, 4)); 40 + const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 41 + const [galleryStartIndex, setGalleryStartIndex] = createSignal<number | null>(null); 42 + const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 43 + const [menuOpen, setMenuOpen] = createSignal(false); 44 + const [menuImageIndex, setMenuImageIndex] = createSignal<number | null>(null); 45 + const [menuImageUrl, setMenuImageUrl] = createSignal<string | null>(null); 46 + const [downloadPending, setDownloadPending] = createSignal(false); 47 + const [notice, setNotice] = createSignal<MediaNotice | null>(null); 48 + let noticeTimer: ReturnType<typeof setTimeout> | null = null; 49 + 50 + const postText = createMemo(() => getPostText(props.post)); 51 + const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); 52 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 53 + const menuItems = createMemo<ContextMenuItem[]>( 54 + () => [{ 55 + disabled: !menuImageUrl() || downloadPending(), 56 + icon: downloadPending() ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line", 57 + label: downloadPending() ? "Saving..." : "Save image", 58 + onSelect: () => void downloadFromContextMenu(), 59 + }] 60 + ); 61 + 62 + onCleanup(() => { 63 + if (noticeTimer !== null) { 64 + clearTimeout(noticeTimer); 65 + } 66 + }); 67 + 68 + function dismissNotice() { 69 + setNotice(null); 70 + if (noticeTimer !== null) { 71 + clearTimeout(noticeTimer); 72 + noticeTimer = null; 73 + } 74 + } 75 + 76 + function queueNotice(next: MediaNotice) { 77 + dismissNotice(); 78 + setNotice(next); 79 + noticeTimer = setTimeout(() => { 80 + setNotice(null); 81 + noticeTimer = null; 82 + }, 6000); 83 + } 84 + 85 + function closeMenu() { 86 + setMenuOpen(false); 87 + setMenuAnchor(null); 88 + setMenuImageIndex(null); 89 + setMenuImageUrl(null); 90 + } 91 + 92 + function openGallery(index: number, event: MouseEvent) { 93 + event.stopPropagation(); 94 + setGalleryStartIndex(index); 95 + } 96 + 97 + function openImageMenu(event: MouseEvent, url: string | undefined, imageIndex: number) { 98 + event.preventDefault(); 99 + event.stopPropagation(); 100 + 101 + setMenuImageIndex(imageIndex); 102 + setMenuImageUrl(url ?? null); 103 + setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); 104 + setMenuOpen(true); 105 + } 106 + 107 + async function downloadFromContextMenu() { 108 + const url = menuImageUrl(); 109 + const imageIndex = menuImageIndex(); 110 + if (!url || downloadPending()) { 111 + return; 112 + } 113 + 114 + setDownloadPending(true); 115 + try { 116 + const requestedFilename = buildImageFilename(postRkey(), images().length, imageIndex)?.trim(); 117 + const result = await MediaController.downloadImage(url, requestedFilename ?? null); 118 + 119 + queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 120 + } catch (error) { 121 + queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); 122 + } finally { 123 + setDownloadPending(false); 124 + } 125 + } 126 + 127 + return ( 128 + <> 129 + <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 130 + <For each={images()}> 131 + {(image, index) => ( 132 + <button 133 + type="button" 134 + class="overflow-hidden rounded-[1.2rem] border-0 bg-black/30 p-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 135 + onClick={(event) => openGallery(index(), event)} 136 + onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb, index())}> 137 + <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> 138 + </button> 139 + )} 140 + </For> 141 + </div> 142 + 143 + <ImageGallery 144 + authorHandle={authorHandle()} 145 + authorHref={profileHref()} 146 + images={images()} 147 + open={galleryStartIndex() !== null} 148 + postText={postText()} 149 + startIndex={galleryStartIndex() ?? 0} 150 + downloadFilenameForIndex={(imageIndex) => buildImageFilename(postRkey(), images().length, imageIndex)} 151 + onClose={() => setGalleryStartIndex(null)} /> 152 + 153 + <ContextMenu 154 + anchor={menuAnchor()} 155 + items={menuItems()} 156 + label="Image actions" 157 + open={menuOpen()} 158 + onClose={closeMenu} /> 159 + 160 + <MediaNoticeToast notice={notice()} onDismiss={dismissNotice} onOpenPath={revealItemInDir} /> 161 + </> 162 + ); 163 + }
+1 -1
src/components/feeds/tests/FeedContent.test.tsx
··· 1 1 import { render } from "@solidjs/testing-library"; 2 2 import { createSignal } from "solid-js"; 3 3 import { describe, expect, it, vi } from "vitest"; 4 - import { FeedContent } from "./FeedContent"; 4 + import { FeedContent } from "../FeedContent"; 5 5 6 6 function createFeedItem(id: string) { 7 7 return {
+1 -1
src/components/feeds/tests/FeedWorkspace.test.tsx
··· 2 2 import { HashRouter, Route } from "@solidjs/router"; 3 3 import { fireEvent, render, screen } from "@solidjs/testing-library"; 4 4 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 5 - import { FeedWorkspace } from "./FeedWorkspace"; 5 + import { FeedWorkspace } from "../FeedWorkspace"; 6 6 7 7 const invokeMock = vi.hoisted(() => vi.fn()); 8 8 const listenMock = vi.hoisted(() => vi.fn());
+1 -1
src/components/feeds/tests/ImageGallery.test.tsx
··· 1 1 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 - import { ImageGallery } from "./ImageGallery"; 3 + import { ImageGallery } from "../ImageGallery"; 4 4 5 5 const downloadImageMock = vi.hoisted(() => vi.fn()); 6 6 const revealItemInDirMock = vi.hoisted(() => vi.fn());
+1 -1
src/components/feeds/tests/VideoEmbed.test.tsx
··· 1 1 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 - import { VideoEmbed } from "../VideoEmbed"; 3 + import { VideoEmbed } from "../embeds/VideoEmbed"; 4 4 5 5 const downloadVideoMock = vi.hoisted(() => vi.fn()); 6 6 const listenMock = vi.hoisted(() => vi.fn());
+3
src/components/feeds/types.ts
··· 3 3 FeedViewPost, 4 4 FeedViewPrefItem, 5 5 PostView, 6 + ReportSubjectInput, 6 7 StrongRefInput, 7 8 UserPreferences, 8 9 } from "$/lib/types"; ··· 46 47 showDraftsList: boolean; 47 48 showFeedsDrawer: boolean; 48 49 }; 50 + 51 + export type ReportTarget = { subject: ReportSubjectInput; subjectLabel: string };
+14
src/lib/feeds.ts
··· 413 413 const normalized = value.trim(); 414 414 return normalized || null; 415 415 } 416 + 417 + export function postRkeyFromUri(uri: string | null | undefined) { 418 + if (typeof uri !== "string") { 419 + return null; 420 + } 421 + 422 + const trimmed = uri.trim(); 423 + if (!trimmed.startsWith("at://")) { 424 + return null; 425 + } 426 + 427 + const rkey = trimmed.split("/").at(-1)?.trim(); 428 + return rkey || null; 429 + }