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.

feat: post engagement screen & history

+894 -58
+2
src/App.tsx
··· 11 11 import { MessagesPanel } from "./components/messages/MessagesPanel"; 12 12 import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; 13 13 import { HeaderPanel } from "./components/panels/Header"; 14 + import { PostEngagementPanel } from "./components/posts/PostEngagementPanel"; 14 15 import { PostPanel } from "./components/posts/PostPanel"; 15 16 import { ThreadDrawer } from "./components/posts/ThreadDrawer"; 16 17 import { ProfilePanel } from "./components/profile/ProfilePanel"; ··· 86 87 renderComposer={() => <ComposerWindow />} 87 88 renderMessages={(props) => <MessagesPanel memberDid={props.memberDid} />} 88 89 renderNotifications={() => <NotificationsPanel />} 90 + renderPostEngagement={(props) => <PostEngagementPanel uri={props.uri} />} 89 91 renderPost={(props) => <PostPanel uri={props.uri} />} 90 92 renderProfile={(props) => <ProfilePanel actor={props.actor} />} 91 93 renderShell={AppShell}
+3
src/components/deck/DeckColumn.tsx
··· 1 1 import { ExplorerPanel } from "$/components/explorer/ExplorerPanel"; 2 2 import { FeedContent } from "$/components/feeds/FeedContent"; 3 3 import { MessagesPanel } from "$/components/messages/MessagesPanel"; 4 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 4 5 import { ProfilePanel } from "$/components/profile/ProfilePanel"; 5 6 import { SearchPanel } from "$/components/search/SearchPanel"; 6 7 import type { Column, ColumnWidth } from "$/lib/api/types/columns"; ··· 157 158 type FeedBodyContentProps = { feed: SavedFeedItem; onOpenThread: (uri: string) => void }; 158 159 159 160 function FeedBodyContent(props: FeedBodyContentProps) { 161 + const postNavigation = usePostNavigation(); 160 162 const { 161 163 bookmarkPendingByUri, 162 164 likePendingByUri, ··· 187 189 onBookmark={(post: PostView) => toggleBookmark(post)} 188 190 onFocusIndex={() => void 0} 189 191 onLike={(post: PostView) => toggleLike(post)} 192 + onOpenEngagement={(uri, tab) => Promise.resolve(postNavigation.openPostEngagement(uri, tab))} 190 193 onOpenThread={(uri: string) => Promise.resolve(props.onOpenThread(uri))} 191 194 onQuote={() => void 0} 192 195 onReply={() => void 0}
+3
src/components/feeds/FeedContent.tsx
··· 1 1 import { getReplyRootPost } from "$/lib/feeds"; 2 + import type { PostEngagementTab } from "$/lib/post-engagement-routes"; 2 3 import type { FeedViewPost, PostView } from "$/lib/types"; 3 4 import { For, Show } from "solid-js"; 4 5 import { EmptyFeedState, FeedSkeleton, LoadingMoreIndicator } from "./FeedEmpty"; ··· 38 39 onFocusIndex: (index: number) => void; 39 40 onBookmark: (post: PostView) => Promise<void> | void; 40 41 onLike: (post: PostView) => Promise<void> | void; 42 + onOpenEngagement: (uri: string, tab: PostEngagementTab) => Promise<void> | void; 41 43 onOpenThread: (uri: string) => Promise<void> | void; 42 44 onQuote: (post: PostView) => void; 43 45 onReply: (post: PostView, root: PostView) => void; ··· 62 64 onBookmark={() => void props.onBookmark(item.post)} 63 65 onFocus={() => props.onFocusIndex(index())} 64 66 onLike={() => void props.onLike(item.post)} 67 + onOpenEngagement={(tab) => void props.onOpenEngagement(item.post.uri, tab)} 65 68 onOpenThread={() => void props.onOpenThread(item.post.uri)} 66 69 onQuote={() => props.onQuote(item.post)} 67 70 onReply={() => props.onReply(item.post, getReplyRootPost(item))}
+1
src/components/feeds/FeedPane.tsx
··· 51 51 onBookmark={props.controller.toggleBookmark} 52 52 onFocusIndex={props.controller.setFocusedIndex} 53 53 onLike={props.controller.toggleLike} 54 + onOpenEngagement={props.controller.openPostEngagement} 54 55 onOpenThread={props.controller.openThread} 55 56 onQuote={props.controller.openQuoteComposer} 56 57 onReply={props.controller.openReplyComposer}
+3
src/components/feeds/FeedWorkspace.tsx
··· 1 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 1 2 import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 3 import { Icon } from "$/components/shared/Icon"; 3 4 import { useAppSession } from "$/contexts/app-session"; ··· 12 13 13 14 export function FeedWorkspace() { 14 15 const session = useAppSession(); 16 + const postNavigation = usePostNavigation(); 15 17 const threadOverlay = useThreadOverlayNavigation(); 16 18 const activeSession = () => { 17 19 if (!session.activeSession) { ··· 23 25 const controller = useFeedWorkspaceController({ 24 26 activeSession: activeSession(), 25 27 onError: session.reportError, 28 + onOpenPostEngagement: (uri, tab) => void postNavigation.openPostEngagement(uri, tab), 26 29 onOpenThread: (uri) => void threadOverlay.openThread(uri), 27 30 }); 28 31
+104 -32
src/components/feeds/PostCard.tsx
··· 19 19 isReplyItem, 20 20 } from "$/lib/feeds"; 21 21 import { collectModerationLabels } from "$/lib/moderation"; 22 + import type { PostEngagementTab } from "$/lib/post-engagement-routes"; 22 23 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 23 24 import type { 24 25 EmbedView, ··· 57 58 }; 58 59 } 59 60 60 - function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string }) { 61 + function PostHeader(props: { authorHandle: string; authorHref: string; authorName: string; createdAt: string }) { 61 62 return ( 62 63 <header class="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1"> 63 64 <span class="wrap-break-word text-base font-semibold tracking-[-0.01em] text-on-surface">{props.authorName}</span> 64 - <span class="break-all text-xs text-on-surface-variant">{props.authorHandle}</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> 65 71 <span class="text-xs text-on-surface-variant">{props.createdAt}</span> 66 72 </header> 67 73 ); ··· 80 86 aria-label={interactive() ? "Open thread" : undefined} 81 87 role={interactive() ? "button" : undefined} 82 88 tabIndex={interactive() ? 0 : undefined} 83 - onClick={() => props.onOpenThread?.()} 89 + onClick={(event) => { 90 + if (isInteractiveTarget(event.target)) { 91 + return; 92 + } 93 + 94 + props.onOpenThread?.(); 95 + }} 84 96 onFocus={() => props.onFocus?.()} 85 97 onKeyDown={(event) => { 86 98 if ((event.key === "Enter" || event.key === " ") && props.onOpenThread) { ··· 95 107 96 108 type PostActionButtonProps = { 97 109 active?: boolean; 110 + ariaLabel?: string; 98 111 busy?: boolean; 99 112 icon: string; 100 113 iconActive?: string; 101 114 label: string; 102 - onClick?: () => void; 115 + onClick?: (event: MouseEvent) => void; 103 116 pulse?: boolean; 104 117 }; 105 118 106 119 function PostActionButton(props: PostActionButtonProps) { 107 120 return ( 108 121 <button 109 - aria-label={props.label} 122 + aria-label={props.ariaLabel ?? props.label} 110 123 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" 111 124 classList={{ "text-primary": !!props.active }} 112 125 type="button" 113 126 disabled={props.busy} 114 127 onClick={(event) => { 115 128 event.stopPropagation(); 116 - props.onClick?.(); 129 + props.onClick?.(event); 117 130 }}> 118 131 <Motion.span 119 132 class="flex items-center" ··· 135 148 likePending: boolean; 136 149 pulseLike: boolean; 137 150 pulseRepost: boolean; 151 + quoteCount: string; 138 152 replyCount: string; 139 153 repostCount: string; 140 154 repostPending: boolean; 141 155 }; 142 156 143 157 type PostActionHandlers = { 144 - onBookmark?: () => void; 145 - onLike?: () => void; 158 + onBookmark?: (event: MouseEvent) => void; 159 + onLike?: (event: MouseEvent) => void; 160 + onOpenEngagement?: (tab: PostEngagementTab) => void; 146 161 onOpenThread?: () => void; 147 - onQuote?: () => void; 148 - onReply?: () => void; 149 - onRepost?: () => void; 162 + onQuote?: (event: MouseEvent) => void; 163 + onReply?: (event: MouseEvent) => void; 164 + onRepost?: (event: MouseEvent) => void; 150 165 }; 151 166 152 167 type PostActionsProps = { ··· 169 184 <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 170 185 <PostActionButton 171 186 active={status.state.isLiked} 187 + ariaLabel="Like" 172 188 busy={status.state.likePending} 173 189 icon="i-ri-heart-3-line" 174 190 iconActive="i-ri-heart-3-fill" 175 191 label={status.state.likeCount} 176 192 pulse={status.state.pulseLike} 177 193 onClick={actions.handlers.onLike} /> 178 - <PostActionButton icon="i-ri-chat-1-line" label={status.state.replyCount} onClick={actions.handlers.onReply} /> 194 + <PostActionButton 195 + ariaLabel="Reply" 196 + icon="i-ri-chat-1-line" 197 + label={status.state.replyCount} 198 + onClick={actions.handlers.onReply} /> 179 199 <PostActionButton 180 200 active={status.state.isReposted} 201 + ariaLabel="Repost" 181 202 busy={status.state.repostPending} 182 203 icon="i-ri-repeat-2-line" 183 204 iconActive="i-ri-repeat-2-fill" ··· 186 207 onClick={actions.handlers.onRepost} /> 187 208 <PostActionButton 188 209 active={status.state.isBookmarked} 210 + ariaLabel={status.state.isBookmarked ? "Unsave" : "Save"} 189 211 busy={status.state.bookmarkPending} 190 212 icon="i-ri-bookmark-line" 191 213 iconActive="i-ri-bookmark-fill" 192 214 label={status.state.isBookmarked ? "Saved" : "Save"} 193 215 onClick={actions.handlers.onBookmark} /> 194 - <PostActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={actions.handlers.onQuote} /> 216 + <PostActionButton 217 + ariaLabel="Quote" 218 + icon="i-ri-chat-quote-line" 219 + label={status.state.quoteCount} 220 + onClick={actions.handlers.onQuote} /> 195 221 <Show when={visibility.showThreadAction}> 196 222 <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={actions.handlers.onOpenThread} /> 197 223 </Show> ··· 300 326 onBookmark?: () => void; 301 327 onFocus?: () => void; 302 328 onLike?: () => void; 329 + onOpenEngagement?: (tab: PostEngagementTab) => void; 303 330 onOpenThread?: () => void; 304 331 onQuote?: () => void; 305 332 onReply?: () => void; ··· 316 343 const [view, interactions, actionFlags] = splitProps( 317 344 props, 318 345 ["focused", "item", "post", "registerRef", "showActions"], 319 - ["onBookmark", "onFocus", "onLike", "onOpenThread", "onQuote", "onReply", "onRepost"], 346 + ["onBookmark", "onFocus", "onLike", "onOpenEngagement", "onOpenThread", "onQuote", "onReply", "onRepost"], 320 347 ["bookmarkPending", "likePending", "pulseLike", "pulseRepost", "repostPending"], 321 348 ); 322 349 ··· 327 354 const isReposted = createMemo(() => !!view.post.viewer?.repost); 328 355 const likeCount = createMemo(() => formatCount(view.post.likeCount)); 329 356 const postText = createMemo(() => getPostText(view.post)); 357 + const quoteCount = createMemo(() => formatCount(view.post.quoteCount)); 330 358 const replyCount = createMemo(() => formatCount(view.post.replyCount)); 331 359 const repostCount = createMemo(() => formatCount(view.post.repostCount)); 332 360 const authorHandle = createMemo(() => formatHandle(view.post.author.handle, view.post.author.did)); ··· 417 445 items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: interactions.onOpenThread }); 418 446 } 419 447 448 + if (interactions.onOpenEngagement) { 449 + items.push({ 450 + icon: "i-ri-heart-3-line", 451 + label: `${formatCount(view.post.likeCount)} ${view.post.likeCount === 1 ? "like" : "likes"}`, 452 + onSelect: () => interactions.onOpenEngagement?.("likes"), 453 + }, { 454 + icon: "i-ri-repeat-2-line", 455 + label: `${formatCount(view.post.repostCount)} ${view.post.repostCount === 1 ? "repost" : "reposts"}`, 456 + onSelect: () => interactions.onOpenEngagement?.("reposts"), 457 + }, { 458 + icon: "i-ri-chat-quote-line", 459 + label: `${formatCount(view.post.quoteCount)} ${view.post.quoteCount === 1 ? "quote" : "quotes"}`, 460 + onSelect: () => interactions.onOpenEngagement?.("quotes"), 461 + }); 462 + } 463 + 420 464 items.push({ 421 465 icon: "i-ri-flag-line", 422 466 label: "Report post", ··· 517 561 </Show> 518 562 519 563 <div class="flex min-w-0 gap-3"> 520 - <a 521 - aria-label={`View @${view.post.author.handle}`} 522 - class="shrink-0 no-underline" 523 - href={`#${profileHref()}`} 524 - onClick={(event) => event.stopPropagation()}> 525 - <ModeratedAvatar 526 - avatar={view.post.author.avatar} 527 - class="relative mt-0.5 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_rgba(14,14,14,1),0_0_0_3px_rgba(125,175,255,0.28)]" 528 - hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 529 - label={getAvatarLabel(view.post.author)} 530 - fallbackClass="text-sm font-semibold text-on-primary-fixed" /> 531 - </a> 564 + <div class="shrink-0"> 565 + <a 566 + aria-label={`View @${view.post.author.handle}`} 567 + class="no-underline" 568 + href={`#${profileHref()}`} 569 + onClick={(event) => event.stopPropagation()}> 570 + <ModeratedAvatar 571 + avatar={view.post.author.avatar} 572 + 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_rgba(14,14,14,1),0_0_0_3px_rgba(125,175,255,0.28)]" 573 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 574 + label={getAvatarLabel(view.post.author)} 575 + fallbackClass="text-sm font-semibold text-on-primary-fixed" /> 576 + </a> 577 + </div> 532 578 533 579 <div class="min-w-0 flex-1"> 534 580 <PostPrimaryRegion onFocus={interactions.onFocus} onOpenThread={interactions.onOpenThread}> 535 - <PostHeader authorName={authorName()} authorHandle={authorHandle()} createdAt={createdAt()} /> 581 + <PostHeader 582 + authorName={authorName()} 583 + authorHandle={authorHandle()} 584 + authorHref={profileHref()} 585 + createdAt={createdAt()} /> 536 586 537 587 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} /> 538 588 ··· 552 602 <Show when={view.showActions !== false}> 553 603 <PostActions 554 604 handlers={{ 555 - onBookmark: interactions.onBookmark, 556 - onLike: interactions.onLike, 605 + onBookmark: () => interactions.onBookmark?.(), 606 + onLike: (event) => { 607 + if (event.shiftKey && interactions.onOpenEngagement) { 608 + interactions.onOpenEngagement("likes"); 609 + return; 610 + } 611 + 612 + interactions.onLike?.(); 613 + }, 557 614 onOpenThread: interactions.onOpenThread, 558 - onQuote: interactions.onQuote, 559 - onReply: interactions.onReply, 560 - onRepost: interactions.onRepost, 615 + onQuote: (event) => { 616 + if (event.shiftKey && interactions.onOpenEngagement) { 617 + interactions.onOpenEngagement("quotes"); 618 + return; 619 + } 620 + 621 + interactions.onQuote?.(); 622 + }, 623 + onReply: () => interactions.onReply?.(), 624 + onRepost: (event) => { 625 + if (event.shiftKey && interactions.onOpenEngagement) { 626 + interactions.onOpenEngagement("reposts"); 627 + return; 628 + } 629 + 630 + interactions.onRepost?.(); 631 + }, 561 632 }} 562 633 menu={{ 563 634 open: menuOpen(), ··· 576 647 likePending: !!actionFlags.likePending, 577 648 pulseLike: !!actionFlags.pulseLike, 578 649 pulseRepost: !!actionFlags.pulseRepost, 650 + quoteCount: quoteCount(), 579 651 replyCount: replyCount(), 580 652 repostCount: repostCount(), 581 653 repostPending: !!actionFlags.repostPending,
+1
src/components/feeds/tests/FeedContent.test.tsx
··· 33 33 onBookmark: vi.fn(async () => {}), 34 34 onFocusIndex: vi.fn(), 35 35 onLike: vi.fn(async () => {}), 36 + onOpenEngagement: vi.fn(async () => {}), 36 37 onOpenThread: vi.fn(async () => {}), 37 38 onQuote: vi.fn(), 38 39 onReply: vi.fn(),
+38 -4
src/components/feeds/tests/PostCard.test.tsx
··· 36 36 cid: "cid-post", 37 37 indexedAt: "2026-03-28T12:00:00.000Z", 38 38 likeCount: 4, 39 + quoteCount: 2, 39 40 record: { 40 41 createdAt: "2026-03-28T12:00:00.000Z", 41 42 facets: [{ ··· 93 94 expect(onOpenThread).toHaveBeenCalledTimes(2); 94 95 }); 95 96 96 - it("keeps profile navigation avatar-only and does not open thread on avatar/action clicks", () => { 97 + it("keeps profile navigation avatar/handle-only and does not open thread on profile/action clicks", () => { 97 98 const onOpenThread = vi.fn(); 98 99 const onLike = vi.fn(); 99 100 render(() => <PostCard post={createPost()} onLike={onLike} onOpenThread={onOpenThread} />); 100 101 101 102 expect(screen.getByRole("link", { name: "View @alice.test" })).toBeInTheDocument(); 102 103 expect(screen.queryByRole("link", { name: "Alice" })).not.toBeInTheDocument(); 103 - expect(screen.queryByRole("link", { name: "@alice.test" })).not.toBeInTheDocument(); 104 + expect(screen.getByRole("link", { name: "@alice.test" })).toBeInTheDocument(); 104 105 105 106 fireEvent.click(screen.getByRole("link", { name: "View @alice.test" })); 106 - fireEvent.click(screen.getByRole("button", { name: "4" })); 107 + fireEvent.click(screen.getByRole("link", { name: "@alice.test" })); 108 + fireEvent.click(screen.getByRole("button", { name: "Like" })); 107 109 108 110 expect(onOpenThread).not.toHaveBeenCalled(); 109 111 expect(onLike).toHaveBeenCalledTimes(1); ··· 120 122 121 123 it("opens the shared menu from the overflow trigger and from right click", async () => { 122 124 Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(void 0) } }); 125 + const onOpenEngagement = vi.fn(); 123 126 124 - render(() => <PostCard post={createPost()} onOpenThread={vi.fn()} />); 127 + render(() => <PostCard post={createPost()} onOpenEngagement={onOpenEngagement} onOpenThread={vi.fn()} />); 125 128 126 129 fireEvent.click(screen.getByRole("button", { name: "More actions" })); 127 130 expect(screen.getByRole("menu", { name: "Post actions" })).toBeInTheDocument(); 131 + expect(screen.getByRole("menuitem", { name: "4 likes" })).toBeInTheDocument(); 132 + expect(screen.getByRole("menuitem", { name: "1 repost" })).toBeInTheDocument(); 133 + expect(screen.getByRole("menuitem", { name: "2 quotes" })).toBeInTheDocument(); 134 + fireEvent.click(screen.getByRole("menuitem", { name: "4 likes" })); 135 + expect(onOpenEngagement).toHaveBeenCalledWith("likes"); 128 136 129 137 fireEvent.pointerDown(document.body); 130 138 await waitFor(() => expect(screen.queryByRole("menu", { name: "Post actions" })).not.toBeInTheDocument()); ··· 132 140 fireEvent.contextMenu(screen.getByRole("article")); 133 141 expect(screen.getByRole("menu", { name: "Post actions" })).toBeInTheDocument(); 134 142 expect(screen.getByRole("menuitem", { name: "Copy post link" })).toBeInTheDocument(); 143 + }); 144 + 145 + it("uses shift-click on like/repost/quote to open engagement lists without toggling actions", () => { 146 + const onLike = vi.fn(); 147 + const onQuote = vi.fn(); 148 + const onRepost = vi.fn(); 149 + const onOpenEngagement = vi.fn(); 150 + render(() => ( 151 + <PostCard 152 + post={createPost()} 153 + onLike={onLike} 154 + onOpenEngagement={onOpenEngagement} 155 + onQuote={onQuote} 156 + onRepost={onRepost} /> 157 + )); 158 + 159 + fireEvent.click(screen.getByRole("button", { name: "Like" }), { shiftKey: true }); 160 + fireEvent.click(screen.getByRole("button", { name: "Repost" }), { shiftKey: true }); 161 + fireEvent.click(screen.getByRole("button", { name: "Quote" }), { shiftKey: true }); 162 + 163 + expect(onOpenEngagement).toHaveBeenNthCalledWith(1, "likes"); 164 + expect(onOpenEngagement).toHaveBeenNthCalledWith(2, "reposts"); 165 + expect(onOpenEngagement).toHaveBeenNthCalledWith(3, "quotes"); 166 + expect(onLike).not.toHaveBeenCalled(); 167 + expect(onRepost).not.toHaveBeenCalled(); 168 + expect(onQuote).not.toHaveBeenCalled(); 135 169 }); 136 170 137 171 it("hides Thread action when no known thread context exists", () => {
+1
src/components/feeds/tests/useFeedWorkspaceController.test.tsx
··· 102 102 const controller = useFeedWorkspaceController({ 103 103 activeSession: ACTIVE_SESSION, 104 104 onError: onErrorMock, 105 + onOpenPostEngagement: () => {}, 105 106 onOpenThread: () => {}, 106 107 }); 107 108
+7
src/components/feeds/useFeedWorkspaceController.ts
··· 12 12 patchFeedItems, 13 13 toStrongRef, 14 14 } from "$/lib/feeds"; 15 + import type { PostEngagementTab } from "$/lib/post-engagement-routes"; 15 16 import type { 16 17 ActiveSession, 17 18 Draft, ··· 48 49 type FeedWorkspaceProps = { 49 50 activeSession: ActiveSession; 50 51 onError: (message: string) => void; 52 + onOpenPostEngagement: (uri: string, tab: PostEngagementTab) => void; 51 53 onOpenThread: (uri: string) => void; 52 54 }; 53 55 ··· 708 710 props.onOpenThread(uri); 709 711 } 710 712 713 + function openPostEngagement(uri: string, tab: PostEngagementTab) { 714 + props.onOpenPostEngagement(uri, tab); 715 + } 716 + 711 717 function openComposer() { 712 718 setWorkspace("composer", "open", true); 713 719 } ··· 1065 1071 loadDraft, 1066 1072 openComposer, 1067 1073 openDraftsList, 1074 + openPostEngagement, 1068 1075 openThread, 1069 1076 openQuoteComposer, 1070 1077 openReplyComposer,
+91
src/components/posts/PostEngagementPanel.test.tsx
··· 1 + import { HashRouter, Route } from "@solidjs/router"; 2 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { PostEngagementPanel } from "./PostEngagementPanel"; 5 + 6 + const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 7 + const postNavigationMock = vi.hoisted(() => ({ 8 + backFromPost: vi.fn(), 9 + buildPostHref: vi.fn(), 10 + openPost: vi.fn(), 11 + openPostEngagement: vi.fn(), 12 + openPostScreen: vi.fn(), 13 + })); 14 + 15 + vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 16 + vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 17 + 18 + const POST_URI = "at://did:plc:alice/app.bsky.feed.post/123"; 19 + 20 + function renderPanel(hash = `#/post/${encodeURIComponent(POST_URI)}/engagement`) { 21 + globalThis.location.hash = hash; 22 + return render(() => ( 23 + <HashRouter> 24 + <Route path="/post/:encodedUri/engagement" component={() => <PostEngagementPanel uri={POST_URI} />} /> 25 + </HashRouter> 26 + )); 27 + } 28 + 29 + describe("PostEngagementPanel", () => { 30 + beforeEach(() => { 31 + vi.resetAllMocks(); 32 + getRecordBacklinksMock.mockResolvedValue({ 33 + likes: { 34 + cursor: null, 35 + records: [{ 36 + did: "did:plc:bob", 37 + profile: { handle: "bob.test", displayName: "Bob" }, 38 + uri: "at://did:plc:bob/app.bsky.feed.like/1", 39 + }], 40 + total: 1, 41 + }, 42 + quotes: { 43 + cursor: null, 44 + records: [{ 45 + did: "did:plc:carol", 46 + profile: { handle: "carol.test", displayName: "Carol" }, 47 + uri: "at://did:plc:carol/app.bsky.feed.post/9", 48 + }], 49 + total: 1, 50 + }, 51 + replies: { cursor: null, records: [], total: 0 }, 52 + reposts: { 53 + cursor: null, 54 + records: [{ 55 + did: "did:plc:dana", 56 + profile: { handle: "dana.test", displayName: "Dana" }, 57 + uri: "at://did:plc:dana/app.bsky.feed.repost/3", 58 + }], 59 + total: 1, 60 + }, 61 + }); 62 + }); 63 + 64 + it("loads engagement and defaults to likes tab", async () => { 65 + renderPanel(); 66 + 67 + expect(await screen.findByText("Post Engagement")).toBeInTheDocument(); 68 + expect(await screen.findByText("Bob")).toBeInTheDocument(); 69 + expect(getRecordBacklinksMock).toHaveBeenCalledWith(POST_URI); 70 + }); 71 + 72 + it("opens quote posts from the quotes tab", async () => { 73 + renderPanel(`#/post/${encodeURIComponent(POST_URI)}/engagement?tab=quotes`); 74 + 75 + expect(await screen.findByText("Carol")).toBeInTheDocument(); 76 + 77 + fireEvent.click(screen.getByRole("button", { name: /carol/i })); 78 + 79 + expect(postNavigationMock.openPostScreen).toHaveBeenCalledWith("at://did:plc:carol/app.bsky.feed.post/9"); 80 + }); 81 + 82 + it("switches engagement tabs via query-state routing", async () => { 83 + renderPanel(); 84 + 85 + await screen.findByText("Bob"); 86 + fireEvent.click(screen.getByRole("button", { name: /Reposts/i })); 87 + 88 + await waitFor(() => expect(globalThis.location.hash).toContain("tab=reposts")); 89 + expect(await screen.findByText("Dana")).toBeInTheDocument(); 90 + }); 91 + });
+298
src/components/posts/PostEngagementPanel.tsx
··· 1 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 2 + import { Icon } from "$/components/shared/Icon"; 3 + import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; 4 + import { 5 + buildPostEngagementTabRoute, 6 + parsePostEngagementTab, 7 + type PostEngagementTab, 8 + } from "$/lib/post-engagement-routes"; 9 + import { buildProfileRoute } from "$/lib/profile"; 10 + import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; 11 + import { useLocation, useNavigate } from "@solidjs/router"; 12 + import * as logger from "@tauri-apps/plugin-log"; 13 + import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 14 + import { createStore } from "solid-js/store"; 15 + 16 + type EngagementState = { 17 + error: string | null; 18 + groups: Record<PostEngagementTab, DiagnosticBacklinkGroup>; 19 + loading: boolean; 20 + uri: string | null; 21 + }; 22 + 23 + const EMPTY_GROUP: DiagnosticBacklinkGroup = { cursor: null, records: [], total: 0 }; 24 + const TABS: Array<{ key: PostEngagementTab; label: string }> = [{ key: "likes", label: "Likes" }, { 25 + key: "reposts", 26 + label: "Reposts", 27 + }, { key: "quotes", label: "Quotes" }]; 28 + 29 + function createInitialState(): EngagementState { 30 + return { 31 + error: null, 32 + groups: { likes: EMPTY_GROUP, reposts: EMPTY_GROUP, quotes: EMPTY_GROUP }, 33 + loading: false, 34 + uri: null, 35 + }; 36 + } 37 + 38 + export function PostEngagementPanel(props: { uri: string | null }) { 39 + const location = useLocation(); 40 + const navigate = useNavigate(); 41 + const postNavigation = usePostNavigation(); 42 + const [state, setState] = createStore<EngagementState>(createInitialState()); 43 + let requestId = 0; 44 + 45 + const activeUri = createMemo(() => props.uri?.trim() || null); 46 + const activeTab = createMemo(() => parsePostEngagementTab(location.search)); 47 + const activeGroup = createMemo(() => state.groups[activeTab()]); 48 + const activeTabLabel = createMemo(() => TABS.find((tab) => tab.key === activeTab())?.label ?? "Likes"); 49 + 50 + createEffect(() => { 51 + const uri = activeUri(); 52 + if (!uri) { 53 + setState(createInitialState()); 54 + return; 55 + } 56 + 57 + const nextRequestId = ++requestId; 58 + setState({ 59 + error: null, 60 + groups: { likes: EMPTY_GROUP, reposts: EMPTY_GROUP, quotes: EMPTY_GROUP }, 61 + loading: true, 62 + uri, 63 + }); 64 + void loadEngagement(nextRequestId, uri); 65 + }); 66 + 67 + async function loadEngagement(nextRequestId: number, uri: string) { 68 + try { 69 + const response = await getRecordBacklinks(uri); 70 + if (nextRequestId !== requestId || uri !== activeUri()) { 71 + return; 72 + } 73 + 74 + setState({ 75 + error: null, 76 + groups: { 77 + likes: response.likes ?? EMPTY_GROUP, 78 + quotes: response.quotes ?? EMPTY_GROUP, 79 + reposts: response.reposts ?? EMPTY_GROUP, 80 + }, 81 + loading: false, 82 + uri, 83 + }); 84 + } catch (error) { 85 + const message = normalizeError(error); 86 + if (nextRequestId !== requestId || uri !== activeUri()) { 87 + return; 88 + } 89 + 90 + setState({ error: message, loading: false, uri }); 91 + logger.error("failed to load post engagement", { keyValues: { error: message, uri } }); 92 + } 93 + } 94 + 95 + function selectTab(tab: PostEngagementTab) { 96 + if (tab === activeTab()) { 97 + return; 98 + } 99 + 100 + void navigate(buildPostEngagementTabRoute(location.pathname, location.search, tab)); 101 + } 102 + 103 + function openProfile(item: DiagnosticBacklinkItem) { 104 + const actor = item.profile?.handle?.trim() || item.did?.trim(); 105 + if (!actor) { 106 + return; 107 + } 108 + 109 + void navigate(buildProfileRoute(actor)); 110 + } 111 + 112 + function openQuote(item: DiagnosticBacklinkItem) { 113 + if (!item.uri) { 114 + return; 115 + } 116 + 117 + void postNavigation.openPostScreen(item.uri); 118 + } 119 + 120 + return ( 121 + <section class="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 122 + <header class="sticky top-0 z-20 flex items-center justify-between gap-3 bg-[rgba(14,14,14,0.94)] px-6 pb-4 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)] max-[760px]:px-4 max-[520px]:px-3"> 123 + <div class="min-w-0"> 124 + <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post Engagement</p> 125 + <p class="m-0 mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{activeTabLabel()}</p> 126 + </div> 127 + <button 128 + type="button" 129 + class="inline-flex h-10 items-center gap-2 rounded-full border-0 bg-white/5 px-4 text-sm text-on-surface transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 130 + onClick={() => void postNavigation.backFromPost()}> 131 + <Icon aria-hidden="true" iconClass="i-ri-arrow-left-line" /> 132 + Back 133 + </button> 134 + </header> 135 + 136 + <nav class="flex flex-wrap gap-2 px-3 pb-3 pt-3 max-[520px]:px-2" aria-label="Engagement tabs"> 137 + <For each={TABS}> 138 + {(tab) => ( 139 + <button 140 + type="button" 141 + class="rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150 ease-out" 142 + classList={{ 143 + "bg-white/8 text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": activeTab() === tab.key, 144 + "text-on-surface-variant hover:bg-white/5 hover:text-on-surface": activeTab() !== tab.key, 145 + }} 146 + onClick={() => selectTab(tab.key)}> 147 + {tab.label} ({activeCount(state.groups[tab.key])}) 148 + </button> 149 + )} 150 + </For> 151 + </nav> 152 + 153 + <div class="min-h-0 overflow-y-auto px-3 pb-4 max-[520px]:px-2"> 154 + <Switch> 155 + <Match when={!activeUri()}> 156 + <PanelMessage title="Post unavailable" body="This engagement route is missing a valid post URI." /> 157 + </Match> 158 + <Match when={state.loading}> 159 + <EngagementSkeleton /> 160 + </Match> 161 + <Match when={state.error}> 162 + <PanelMessage title="Couldn’t load engagement" body={state.error ?? "Try refreshing this view."} /> 163 + </Match> 164 + <Match when={(activeGroup().records ?? []).length === 0}> 165 + <PanelMessage 166 + title={`No ${activeTabLabel().toLowerCase()} yet`} 167 + body={`This post does not have visible ${activeTabLabel().toLowerCase()} right now.`} /> 168 + </Match> 169 + <Match when={true}> 170 + <EngagementList 171 + items={activeGroup().records} 172 + kind={activeTab()} 173 + onOpenProfile={openProfile} 174 + onOpenQuote={openQuote} /> 175 + </Match> 176 + </Switch> 177 + </div> 178 + </section> 179 + ); 180 + } 181 + 182 + function activeCount(group: DiagnosticBacklinkGroup) { 183 + return group.total ?? group.records.length; 184 + } 185 + 186 + function EngagementList( 187 + props: { 188 + items: DiagnosticBacklinkItem[]; 189 + kind: PostEngagementTab; 190 + onOpenProfile: (item: DiagnosticBacklinkItem) => void; 191 + onOpenQuote: (item: DiagnosticBacklinkItem) => void; 192 + }, 193 + ) { 194 + return ( 195 + <div class="grid gap-3"> 196 + <For each={props.items}> 197 + {(item, index) => ( 198 + <EngagementRow 199 + index={index()} 200 + item={item} 201 + kind={props.kind} 202 + onOpenProfile={props.onOpenProfile} 203 + onOpenQuote={props.onOpenQuote} /> 204 + )} 205 + </For> 206 + </div> 207 + ); 208 + } 209 + 210 + function EngagementRow( 211 + props: { 212 + index: number; 213 + item: DiagnosticBacklinkItem; 214 + kind: PostEngagementTab; 215 + onOpenProfile: (item: DiagnosticBacklinkItem) => void; 216 + onOpenQuote: (item: DiagnosticBacklinkItem) => void; 217 + }, 218 + ) { 219 + const actorLabel = createMemo(() => 220 + props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown account" 221 + ); 222 + const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did)); 223 + const quoteInteractive = createMemo(() => props.kind === "quotes" && !!props.item.uri); 224 + const profileInteractive = createMemo(() => 225 + props.kind !== "quotes" && !!(props.item.profile?.handle || props.item.did) 226 + ); 227 + const interactive = createMemo(() => quoteInteractive() || profileInteractive()); 228 + 229 + return ( 230 + <button 231 + type="button" 232 + class="flex w-full items-start gap-3 rounded-3xl border-0 bg-white/4 p-4 text-left shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 hover:bg-white/6 disabled:cursor-default disabled:hover:bg-white/4" 233 + disabled={!interactive()} 234 + onClick={() => { 235 + if (quoteInteractive()) { 236 + props.onOpenQuote(props.item); 237 + return; 238 + } 239 + 240 + props.onOpenProfile(props.item); 241 + }}> 242 + <div class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/8 text-xs font-semibold text-on-surface-variant"> 243 + <Show when={props.item.profile?.avatar} fallback={<span>{initials(actorLabel())}</span>}> 244 + {(src) => <img alt={actorLabel()} class="h-full w-full object-cover" src={src()} />} 245 + </Show> 246 + </div> 247 + <div class="min-w-0 flex-1"> 248 + <div class="flex flex-wrap items-center gap-2"> 249 + <p class="m-0 text-sm font-medium text-on-surface">{actorLabel()}</p> 250 + <Show when={props.item.collection}> 251 + {(collection) => ( 252 + <span class="rounded-full bg-white/7 px-2.5 py-1 text-xs text-on-surface-variant">{collection()}</span> 253 + )} 254 + </Show> 255 + </div> 256 + <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p> 257 + <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p> 258 + </div> 259 + <Show when={interactive()}> 260 + <div class="pt-1 text-on-surface-variant"> 261 + <Icon iconClass="i-ri-arrow-right-up-line" /> 262 + </div> 263 + </Show> 264 + </button> 265 + ); 266 + } 267 + 268 + function PanelMessage(props: { body: string; title: string }) { 269 + return ( 270 + <div class="grid min-h-112 place-items-center px-6 py-10"> 271 + <div class="grid max-w-lg gap-3 text-center"> 272 + <p class="m-0 text-base font-medium text-on-surface">{props.title}</p> 273 + <p class="m-0 text-sm text-on-surface-variant">{props.body}</p> 274 + </div> 275 + </div> 276 + ); 277 + } 278 + 279 + function EngagementSkeleton() { 280 + return ( 281 + <div class="grid gap-3"> 282 + <For each={Array.from({ length: 4 })}> 283 + {() => ( 284 + <div class="rounded-3xl bg-white/4 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 285 + <div class="flex gap-3"> 286 + <div class="skeleton-block h-11 w-11 rounded-full" /> 287 + <div class="grid min-w-0 flex-1 gap-2"> 288 + <div class="skeleton-block h-4 w-32 rounded-full" /> 289 + <div class="skeleton-block h-3 w-24 rounded-full" /> 290 + <div class="skeleton-block h-3 w-full rounded-full" /> 291 + </div> 292 + </div> 293 + </div> 294 + )} 295 + </For> 296 + </div> 297 + ); 298 + }
+8
src/components/posts/PostPanel.tsx
··· 161 161 loading={state.loading} 162 162 onBookmark={(post) => void interactions.toggleBookmark(post)} 163 163 onLike={(post) => void interactions.toggleLike(post)} 164 + onOpenEngagement={(uri, tab) => void postNavigation.openPostEngagement(uri, tab)} 164 165 onOpenPost={(uri) => void postNavigation.openPost(uri)} 165 166 onRepost={(post) => void interactions.toggleRepost(post)} 166 167 parentChain={parentChain()} ··· 180 181 loading: boolean; 181 182 onBookmark: (post: PostView) => void; 182 183 onLike: (post: PostView) => void; 184 + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; 183 185 onOpenPost: (uri: string) => void; 184 186 onRepost: (post: PostView) => void; 185 187 parentChain: ThreadViewPost[]; ··· 210 212 likePending={!!props.likePendingByUri[parent.post.uri]} 211 213 onBookmark={() => props.onBookmark(parent.post)} 212 214 onLike={() => props.onLike(parent.post)} 215 + onOpenEngagement={(tab) => props.onOpenEngagement(parent.post.uri, tab)} 213 216 onOpenThread={() => props.onOpenPost(parent.post.uri)} 214 217 onRepost={() => props.onRepost(parent.post)} 215 218 post={parent.post} ··· 225 228 likePending={!!props.likePendingByUri[focused().post.uri]} 226 229 onBookmark={() => props.onBookmark(focused().post)} 227 230 onLike={() => props.onLike(focused().post)} 231 + onOpenEngagement={(tab) => props.onOpenEngagement(focused().post.uri, tab)} 228 232 onOpenThread={() => props.onOpenPost(focused().post.uri)} 229 233 onRepost={() => props.onRepost(focused().post)} 230 234 post={focused().post} ··· 240 244 node={reply} 241 245 onBookmark={props.onBookmark} 242 246 onLike={props.onLike} 247 + onOpenEngagement={props.onOpenEngagement} 243 248 onOpenPost={props.onOpenPost} 244 249 onRepost={props.onRepost} 245 250 repostPendingByUri={props.repostPendingByUri} /> ··· 261 266 node: ThreadNode; 262 267 onBookmark: (post: PostView) => void; 263 268 onLike: (post: PostView) => void; 269 + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; 264 270 onOpenPost: (uri: string) => void; 265 271 onRepost: (post: PostView) => void; 266 272 repostPendingByUri: Record<string, boolean>; ··· 284 290 likePending={!!props.likePendingByUri[current().post.uri]} 285 291 onBookmark={() => props.onBookmark(current().post)} 286 292 onLike={() => props.onLike(current().post)} 293 + onOpenEngagement={(tab) => props.onOpenEngagement(current().post.uri, tab)} 287 294 onOpenThread={() => props.onOpenPost(current().post.uri)} 288 295 onRepost={() => props.onRepost(current().post)} 289 296 post={current().post} ··· 299 306 node={reply} 300 307 onBookmark={props.onBookmark} 301 308 onLike={props.onLike} 309 + onOpenEngagement={props.onOpenEngagement} 302 310 onOpenPost={props.onOpenPost} 303 311 onRepost={props.onRepost} 304 312 repostPendingByUri={props.repostPendingByUri} />
+8 -1
src/components/posts/ThreadDrawer.tsx
··· 157 157 transition={{ duration: 0.22 }}> 158 158 <ThreadDrawerHeader 159 159 activeUri={activeUri()} 160 - onMaximize={(uri) => void postNavigation.openPost(uri)} 160 + onMaximize={(uri) => void postNavigation.openPostScreen(uri)} 161 161 parentThreadHref={parentThreadHref()} 162 162 onClose={() => void threadOverlay.closeThread()} /> 163 163 <ThreadDrawerBody ··· 168 168 loading={state.loading} 169 169 onBookmark={(post) => void interactions.toggleBookmark(post)} 170 170 onLike={(post) => void interactions.toggleLike(post)} 171 + onOpenEngagement={(uri, tab) => void postNavigation.openPostEngagement(uri, tab)} 171 172 onOpenThread={(uri) => void threadOverlay.openThread(uri)} 172 173 onRepost={(post) => void interactions.toggleRepost(post)} 173 174 repostPendingByUri={interactions.repostPendingByUri()} ··· 189 190 loading: boolean; 190 191 onBookmark: (post: PostView) => void; 191 192 onLike: (post: PostView) => void; 193 + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; 192 194 onOpenThread: (uri: string) => void; 193 195 onRepost: (post: PostView) => void; 194 196 repostPendingByUri: Record<string, boolean>; ··· 218 220 node={props.thread!} 219 221 onBookmark={props.onBookmark} 220 222 onLike={props.onLike} 223 + onOpenEngagement={props.onOpenEngagement} 221 224 onOpenThread={props.onOpenThread} 222 225 onRepost={props.onRepost} 223 226 repostPendingByUri={props.repostPendingByUri} ··· 293 296 node: ThreadNode; 294 297 onBookmark: (post: PostView) => void; 295 298 onLike: (post: PostView) => void; 299 + onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; 296 300 onOpenThread: (uri: string) => void; 297 301 onRepost: (post: PostView) => void; 298 302 repostPendingByUri: Record<string, boolean>; ··· 322 326 node={parent()} 323 327 onBookmark={props.onBookmark} 324 328 onLike={props.onLike} 329 + onOpenEngagement={props.onOpenEngagement} 325 330 onOpenThread={props.onOpenThread} 326 331 onRepost={props.onRepost} 327 332 repostPendingByUri={props.repostPendingByUri} ··· 336 341 likePending={!!props.likePendingByUri[threadNode().post.uri]} 337 342 onBookmark={() => props.onBookmark(threadNode().post)} 338 343 onLike={() => props.onLike(threadNode().post)} 344 + onOpenEngagement={(tab) => props.onOpenEngagement(threadNode().post.uri, tab)} 339 345 onOpenThread={() => props.onOpenThread(threadNode().post.uri)} 340 346 onRepost={() => props.onRepost(threadNode().post)} 341 347 post={threadNode().post} ··· 352 358 node={reply} 353 359 onBookmark={props.onBookmark} 354 360 onLike={props.onLike} 361 + onOpenEngagement={props.onOpenEngagement} 355 362 onOpenThread={props.onOpenThread} 356 363 onRepost={props.onRepost} 357 364 repostPendingByUri={props.repostPendingByUri}
+66
src/components/posts/usePostNavigation.test.ts
··· 1 + import { createRoot } from "solid-js"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { usePostNavigation } from "./usePostNavigation"; 4 + 5 + const navigateMock = vi.hoisted(() => vi.fn()); 6 + const threadOverlayMock = vi.hoisted(() => ({ 7 + buildThreadHref: vi.fn((uri: string | null) => (uri ? `/timeline?thread=${encodeURIComponent(uri)}` : "/timeline")), 8 + closeThread: vi.fn(), 9 + drawerEnabled: vi.fn(() => true), 10 + openThread: vi.fn(), 11 + threadUri: vi.fn(() => null), 12 + })); 13 + 14 + vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 15 + vi.mock("./useThreadOverlayNavigation", () => ({ useThreadOverlayNavigation: () => threadOverlayMock })); 16 + 17 + describe("usePostNavigation", () => { 18 + beforeEach(() => { 19 + navigateMock.mockReset(); 20 + threadOverlayMock.buildThreadHref.mockClear(); 21 + threadOverlayMock.openThread.mockClear(); 22 + }); 23 + 24 + it("opens posts with thread overlay context", () => { 25 + createRoot((dispose) => { 26 + const navigation = usePostNavigation(); 27 + void navigation.openPost("at://did:plc:alice/app.bsky.feed.post/1"); 28 + dispose(); 29 + }); 30 + 31 + expect(threadOverlayMock.openThread).toHaveBeenCalledWith("at://did:plc:alice/app.bsky.feed.post/1"); 32 + expect(navigateMock).not.toHaveBeenCalled(); 33 + }); 34 + 35 + it("opens full-screen post routes explicitly", () => { 36 + createRoot((dispose) => { 37 + const navigation = usePostNavigation(); 38 + void navigation.openPostScreen("at://did:plc:alice/app.bsky.feed.post/2"); 39 + dispose(); 40 + }); 41 + 42 + expect(navigateMock).toHaveBeenCalledWith("/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F2"); 43 + }); 44 + 45 + it("builds and opens post engagement routes", () => { 46 + createRoot((dispose) => { 47 + const navigation = usePostNavigation(); 48 + void navigation.openPostEngagement("at://did:plc:alice/app.bsky.feed.post/3", "quotes"); 49 + dispose(); 50 + }); 51 + 52 + expect(navigateMock).toHaveBeenCalledWith( 53 + "/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F3/engagement?tab=quotes", 54 + ); 55 + }); 56 + 57 + it("delegates href building to thread overlay routing", () => { 58 + createRoot((dispose) => { 59 + const navigation = usePostNavigation(); 60 + navigation.buildPostHref("at://did:plc:alice/app.bsky.feed.post/4"); 61 + dispose(); 62 + }); 63 + 64 + expect(threadOverlayMock.buildThreadHref).toHaveBeenCalledWith("at://did:plc:alice/app.bsky.feed.post/4"); 65 + }); 66 + });
+12 -1
src/components/posts/usePostNavigation.ts
··· 1 1 import { TIMELINE_ROUTE } from "$/lib/feeds"; 2 + import { buildPostEngagementRoute, type PostEngagementTab } from "$/lib/post-engagement-routes"; 2 3 import { buildPostRoute } from "$/lib/post-routes"; 3 4 import { useNavigate } from "@solidjs/router"; 5 + import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation"; 4 6 5 7 export function usePostNavigation() { 6 8 const navigate = useNavigate(); 9 + const threadOverlay = useThreadOverlayNavigation(); 7 10 8 11 function openPost(uri: string) { 12 + return threadOverlay.openThread(uri); 13 + } 14 + 15 + function openPostScreen(uri: string) { 9 16 return navigate(buildPostRoute(uri)); 10 17 } 11 18 19 + function openPostEngagement(uri: string, tab: PostEngagementTab) { 20 + return navigate(buildPostEngagementRoute(uri, tab)); 21 + } 22 + 12 23 function backFromPost() { 13 24 if (globalThis.history.length > 1) { 14 25 return navigate(-1); ··· 17 28 return navigate(TIMELINE_ROUTE); 18 29 } 19 30 20 - return { backFromPost, buildPostHref: buildPostRoute, openPost }; 31 + return { backFromPost, buildPostHref: threadOverlay.buildThreadHref, openPost, openPostEngagement, openPostScreen }; 21 32 }
+5
src/components/profile/ProfileFeed.tsx
··· 1 + import type { PostEngagementTab } from "$/lib/post-engagement-routes"; 1 2 import type { ProfileTab } from "$/lib/profile"; 2 3 import type { FeedViewPost, PostView } from "$/lib/types"; 3 4 import { For, Match, Show, Switch } from "solid-js"; ··· 11 12 items: FeedViewPost[]; 12 13 likePendingByUri: Record<string, boolean>; 13 14 onBookmark: (post: PostView) => void; 15 + onOpenEngagement: (uri: string, tab: PostEngagementTab) => void; 14 16 onLike: (post: PostView) => void; 15 17 onOpenThread: (uri: string) => void; 16 18 onRepost: (post: PostView) => void; ··· 26 28 likePending={!!props.likePendingByUri[item.post.uri]} 27 29 onBookmark={() => props.onBookmark(item.post)} 28 30 onLike={() => props.onLike(item.post)} 31 + onOpenEngagement={(tab) => props.onOpenEngagement(item.post.uri, tab)} 29 32 post={item.post} 30 33 item={item} 31 34 onOpenThread={() => props.onOpenThread(item.post.uri)} ··· 97 100 loading: boolean; 98 101 loadingMore: boolean; 99 102 onBookmark: (post: PostView) => void; 103 + onOpenEngagement: (uri: string, tab: PostEngagementTab) => void; 100 104 onLike: (post: PostView) => void; 101 105 onLoadMore: () => void; 102 106 onOpenThread: (uri: string) => void; ··· 120 124 items={props.items} 121 125 likePendingByUri={props.likePendingByUri} 122 126 onBookmark={props.onBookmark} 127 + onOpenEngagement={props.onOpenEngagement} 123 128 onLike={props.onLike} 124 129 onOpenThread={props.onOpenThread} 125 130 onRepost={props.onRepost}
+5
src/components/profile/ProfilePanel.tsx
··· 274 274 void postNavigation.openPost(uri); 275 275 } 276 276 277 + function openEngagement(uri: string, tab: "likes" | "reposts" | "quotes") { 278 + void postNavigation.openPostEngagement(uri, tab); 279 + } 280 + 277 281 function openExplorerTarget(target: string) { 278 282 queueExplorerTarget(target); 279 283 void navigate("/explorer"); ··· 496 500 onBookmark={(post) => void interactions.toggleBookmark(post)} 497 501 onLike={(post) => void interactions.toggleLike(post)} 498 502 onLoadMore={handleLoadMore} 503 + onOpenEngagement={openEngagement} 499 504 onOpenThread={openThread} 500 505 onRepost={(post) => void interactions.toggleRepost(post)} 501 506 repostPendingByUri={interactions.repostPendingByUri()} />
+19
src/components/rail/AppRail.test.tsx
··· 38 38 expect(link).toHaveAttribute("href", "#/saved"); 39 39 }); 40 40 41 + it("tracks in-app history and enables back/forward controls", async () => { 42 + renderRail(); 43 + 44 + const backButton = await screen.findByRole("button", { name: "Back" }); 45 + const forwardButton = await screen.findByRole("button", { name: "Forward" }); 46 + expect(backButton).toBeDisabled(); 47 + expect(forwardButton).toBeDisabled(); 48 + 49 + fireEvent.click(screen.getByRole("link", { name: "Profile" })); 50 + await waitFor(() => expect(globalThis.location.hash).toBe("#/profile")); 51 + expect(backButton).toBeEnabled(); 52 + expect(forwardButton).toBeDisabled(); 53 + 54 + fireEvent.click(backButton); 55 + await waitFor(() => expect(globalThis.location.hash).toBe("#/timeline")); 56 + expect(backButton).toBeDisabled(); 57 + expect(forwardButton).toBeEnabled(); 58 + }); 59 + 41 60 it("opens the support URL with the opener plugin", async () => { 42 61 renderRail(); 43 62
+117 -19
src/components/rail/AppRail.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 + import { useLocation, useNavigate } from "@solidjs/router"; 3 4 import { openUrl } from "@tauri-apps/plugin-opener"; 4 - import { Show } from "solid-js"; 5 + import { createEffect, createMemo, createSignal, Show } from "solid-js"; 5 6 import { AccountSwitcher } from "../account/AccountSwitcher"; 6 - import { RailFoldIcon } from "../shared/Icon"; 7 + import { ArrowIcon, RailFoldIcon } from "../shared/Icon"; 7 8 import { Wordmark } from "../Wordmark"; 8 9 import { RailActionButton, RailButton } from "./AppRailButton"; 9 10 10 - function RailHeader(props: { collapsed: boolean; onToggleCollapse: () => void }) { 11 + function RailHeader( 12 + props: { 13 + canGoBack: boolean; 14 + canGoForward: boolean; 15 + collapsed: boolean; 16 + onGoBack: () => void; 17 + onGoForward: () => void; 18 + onToggleCollapse: () => void; 19 + }, 20 + ) { 11 21 return ( 12 - <div 13 - class="flex shrink-0 items-center justify-between gap-3 max-[1180px]:min-w-0 max-[1180px]:justify-self-start" 14 - classList={{ "w-full flex-col gap-3": props.collapsed }}> 15 - <Wordmark compact={props.collapsed} iconClass="text-primary" /> 16 - <button 17 - class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface max-[1180px]:hidden" 18 - type="button" 19 - aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 20 - aria-pressed={props.collapsed} 21 - onClick={() => props.onToggleCollapse()}> 22 - <Show when={props.collapsed} fallback={<RailFoldIcon kind="close" />}> 23 - <RailFoldIcon kind="open" /> 24 - </Show> 25 - </button> 26 - </div> 22 + <> 23 + <div 24 + class="flex shrink-0 items-center justify-between gap-3 max-[1180px]:min-w-0 max-[1180px]:justify-self-start" 25 + classList={{ "w-full flex-col gap-3": props.collapsed }}> 26 + <Wordmark compact={props.collapsed} iconClass="text-primary" /> 27 + 28 + <div> 29 + <button 30 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface" 31 + type="button" 32 + aria-label={props.collapsed ? "Expand app rail" : "Collapse app rail"} 33 + aria-pressed={props.collapsed} 34 + onClick={() => props.onToggleCollapse()}> 35 + <RailFoldIcon kind={props.collapsed ? "open" : "close"} /> 36 + </button> 37 + </div> 38 + </div> 39 + <div class="flex items-center gap-2 max-[1180px]:hidden"> 40 + <button 41 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 42 + type="button" 43 + aria-label="Back" 44 + disabled={!props.canGoBack} 45 + onClick={() => props.onGoBack()}> 46 + <ArrowIcon direction="left" /> 47 + </button> 48 + 49 + <button 50 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/4 text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8 hover:text-on-surface disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-white/4" 51 + type="button" 52 + aria-label="Forward" 53 + disabled={!props.canGoForward} 54 + onClick={() => props.onGoForward()}> 55 + <ArrowIcon direction="right" /> 56 + </button> 57 + </div> 58 + </> 27 59 ); 28 60 } 29 61 62 + function useShellHistoryTracker() { 63 + const location = useLocation(); 64 + const navigate = useNavigate(); 65 + const [entries, setEntries] = createSignal<string[]>([]); 66 + const [index, setIndex] = createSignal(-1); 67 + const routeKey = createMemo(() => `${location.pathname}${location.search}`); 68 + 69 + createEffect(() => { 70 + const key = routeKey(); 71 + const stack = entries(); 72 + const currentIndex = index(); 73 + 74 + if (stack.length === 0) { 75 + setEntries([key]); 76 + setIndex(0); 77 + return; 78 + } 79 + 80 + if (stack[currentIndex] === key) { 81 + return; 82 + } 83 + 84 + if (currentIndex > 0 && stack[currentIndex - 1] === key) { 85 + setIndex(currentIndex - 1); 86 + return; 87 + } 88 + 89 + if (currentIndex < stack.length - 1 && stack[currentIndex + 1] === key) { 90 + setIndex(currentIndex + 1); 91 + return; 92 + } 93 + 94 + const nextStack = [...stack.slice(0, currentIndex + 1), key]; 95 + setEntries(nextStack); 96 + setIndex(nextStack.length - 1); 97 + }); 98 + 99 + const canGoBack = createMemo(() => index() > 0); 100 + const canGoForward = createMemo(() => index() >= 0 && index() < entries().length - 1); 101 + 102 + function goBack() { 103 + if (!canGoBack()) { 104 + return; 105 + } 106 + 107 + void navigate(-1); 108 + } 109 + 110 + function goForward() { 111 + if (!canGoForward()) { 112 + return; 113 + } 114 + 115 + void navigate(1); 116 + } 117 + 118 + return { canGoBack, canGoForward, goBack, goForward }; 119 + } 120 + 30 121 function RailNavigation(props: { collapsed: boolean; hasSession: boolean; unreadNotifications: number }) { 31 122 return ( 32 123 <div class="grid gap-1 max-[1180px]:col-start-2 max-[1180px]:row-start-1 max-[1180px]:flex max-[1180px]:min-w-0 max-[1180px]:items-center max-[1180px]:gap-2 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> ··· 68 159 export function AppRail() { 69 160 const session = useAppSession(); 70 161 const shell = useAppShellUi(); 162 + const history = useShellHistoryTracker(); 71 163 72 164 return ( 73 165 <aside ··· 77 169 "gap-5": shell.railCondensed && !shell.narrowViewport, 78 170 }} 79 171 aria-label="Primary navigation"> 80 - <RailHeader collapsed={shell.railCondensed} onToggleCollapse={shell.toggleRailCollapsed} /> 172 + <RailHeader 173 + canGoBack={history.canGoBack()} 174 + canGoForward={history.canGoForward()} 175 + collapsed={shell.railCondensed} 176 + onGoBack={history.goBack} 177 + onGoForward={history.goForward} 178 + onToggleCollapse={shell.toggleRailCollapsed} /> 81 179 <RailNavigation 82 180 collapsed={shell.railCondensed} 83 181 hasSession={session.hasSession}
+33
src/lib/post-engagement-routes.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + buildPostEngagementRoute, 4 + buildPostEngagementTabRoute, 5 + parsePostEngagementTab, 6 + } from "./post-engagement-routes"; 7 + 8 + describe("post-engagement-routes", () => { 9 + it("builds a canonical engagement route with tab query", () => { 10 + const uri = "at://did:plc:alice/app.bsky.feed.post/abc123"; 11 + expect(buildPostEngagementRoute(uri, "quotes")).toBe( 12 + "/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123/engagement?tab=quotes", 13 + ); 14 + }); 15 + 16 + it("defaults to likes tab for missing or invalid tab values", () => { 17 + expect(parsePostEngagementTab("")).toBe("likes"); 18 + expect(parsePostEngagementTab("?foo=bar")).toBe("likes"); 19 + expect(parsePostEngagementTab("?tab=invalid")).toBe("likes"); 20 + }); 21 + 22 + it("parses valid tabs", () => { 23 + expect(parsePostEngagementTab("?tab=likes")).toBe("likes"); 24 + expect(parsePostEngagementTab("?tab=reposts")).toBe("reposts"); 25 + expect(parsePostEngagementTab("?tab=quotes")).toBe("quotes"); 26 + }); 27 + 28 + it("updates the tab while preserving other query params", () => { 29 + expect(buildPostEngagementTabRoute("/post/foo/engagement", "?foo=bar&tab=likes", "reposts")).toBe( 30 + "/post/foo/engagement?foo=bar&tab=reposts", 31 + ); 32 + }); 33 + });
+34
src/lib/post-engagement-routes.ts
··· 1 + import { buildPostRoute } from "$/lib/post-routes"; 2 + 3 + export type PostEngagementTab = "likes" | "reposts" | "quotes"; 4 + 5 + const POST_ENGAGEMENT_SEGMENT = "engagement"; 6 + const POST_ENGAGEMENT_TABS = new Set<PostEngagementTab>(["likes", "reposts", "quotes"]); 7 + const POST_ENGAGEMENT_TAB_QUERY_PARAM = "tab"; 8 + 9 + export function buildPostEngagementRoute(uri: string, tab: PostEngagementTab = "likes") { 10 + const base = `${buildPostRoute(uri)}/${POST_ENGAGEMENT_SEGMENT}`; 11 + const params = new URLSearchParams(); 12 + params.set(POST_ENGAGEMENT_TAB_QUERY_PARAM, tab); 13 + return `${base}?${params.toString()}`; 14 + } 15 + 16 + export function parsePostEngagementTab(search: string | null | undefined): PostEngagementTab { 17 + if (!search) { 18 + return "likes"; 19 + } 20 + 21 + const raw = new URLSearchParams(search).get(POST_ENGAGEMENT_TAB_QUERY_PARAM); 22 + return isPostEngagementTab(raw) ? raw : "likes"; 23 + } 24 + 25 + export function buildPostEngagementTabRoute(pathname: string, search: string, tab: PostEngagementTab) { 26 + const params = new URLSearchParams(search); 27 + params.set(POST_ENGAGEMENT_TAB_QUERY_PARAM, tab); 28 + const query = params.toString(); 29 + return query ? `${pathname}?${query}` : pathname; 30 + } 31 + 32 + function isPostEngagementTab(value: string | null): value is PostEngagementTab { 33 + return !!value && POST_ENGAGEMENT_TABS.has(value as PostEngagementTab); 34 + }
+23 -1
src/router.test.tsx
··· 31 31 globalThis.location.hash = hash; 32 32 const renderComposer = vi.fn(() => <div data-testid="composer-view">composer</div>); 33 33 const renderNotifications = vi.fn(() => <div data-testid="notifications-view">notifications</div>); 34 + const renderPostEngagement = vi.fn((props: { uri: string | null }) => ( 35 + <div data-testid="post-engagement-view">{props.uri ?? "none"}</div> 36 + )); 34 37 const renderPost = vi.fn((props: { uri: string | null }) => <div data-testid="post-view">{props.uri ?? "none"}</div>); 35 38 const renderProfile = vi.fn((props: { actor: string | null }) => ( 36 39 <div data-testid="profile-view"> ··· 56 59 renderComposer={renderComposer} 57 60 renderMessages={renderMessages} 58 61 renderNotifications={renderNotifications} 62 + renderPostEngagement={renderPostEngagement} 59 63 renderPost={renderPost} 60 64 renderProfile={renderProfile} 61 65 renderShell={Shell} ··· 63 67 </AppTestProviders> 64 68 )); 65 69 66 - return { renderComposer, renderMessages, renderNotifications, renderPost, renderProfile, renderTimeline }; 70 + return { 71 + renderComposer, 72 + renderMessages, 73 + renderNotifications, 74 + renderPost, 75 + renderPostEngagement, 76 + renderProfile, 77 + renderTimeline, 78 + }; 67 79 } 68 80 69 81 describe("AppRouter", () => { ··· 117 129 await screen.findByTestId("post-view"); 118 130 119 131 expect(renderPost.mock.lastCall?.[0].uri).toBe(uri); 132 + expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 133 + }); 134 + 135 + it("renders post engagement routes with the decoded post uri", async () => { 136 + const uri = "at://did:plc:alice/app.bsky.feed.post/123"; 137 + const { renderPostEngagement } = renderRouter(`#/post/${encodeURIComponent(uri)}/engagement?tab=quotes`); 138 + 139 + await screen.findByTestId("post-engagement-view"); 140 + 141 + expect(renderPostEngagement.mock.lastCall?.[0].uri).toBe(uri); 120 142 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 121 143 }); 122 144
+12
src/router.tsx
··· 29 29 renderComposer: () => JSX.Element; 30 30 renderMessages: Component<TMessagesRouteProps>; 31 31 renderNotifications: () => JSX.Element; 32 + renderPostEngagement: Component<TPostRouteProps>; 32 33 renderPost: Component<TPostRouteProps>; 33 34 renderProfile: Component<TProfileRouteProps>; 34 35 renderShell: Component<AppShellProps>; ··· 113 114 ); 114 115 }; 115 116 117 + const PostEngagementRoute = () => { 118 + const params = useParams<{ encodedUri: string }>(); 119 + 120 + return ( 121 + <ProtectedRouteView> 122 + <Dynamic component={props.renderPostEngagement} uri={decodePostRouteUri(params.encodedUri)} /> 123 + </ProtectedRouteView> 124 + ); 125 + }; 126 + 116 127 const HashtagRoute = () => { 117 128 const params = useParams<{ hashtag: string }>(); 118 129 const tag = decodeHashtagRouteTag(params.hashtag); ··· 187 198 <Route path="/hashtag/:hashtag" component={HashtagRoute} /> 188 199 <Route path="/saved" component={SavedPostsRoute} /> 189 200 <Route path="/notifications" component={NotificationsRoute} /> 201 + <Route path="/post/:encodedUri/engagement" component={PostEngagementRoute} /> 190 202 <Route path="/post/:encodedUri" component={PostRoute} /> 191 203 <Route path="/messages" component={MessagesRoute} /> 192 204 <Route path="/messages/:memberDid" component={MemberMessagesRoute} />