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: dedicated post navigation and routing

+1423 -267
+4 -3
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 { ThreadModal } from "./components/posts/ThreadModal"; 14 + import { PostPanel } from "./components/posts/PostPanel"; 15 + import { ThreadDrawer } from "./components/posts/ThreadDrawer"; 15 16 import { ProfilePanel } from "./components/profile/ProfilePanel"; 16 17 import { AppRail } from "./components/rail/AppRail"; 17 18 import { SessionSpotlight } from "./components/Session"; ··· 66 67 {props.children} 67 68 </section> 68 69 </main> 69 - 70 - <ThreadModal /> 70 + <ThreadDrawer /> 71 71 <ErrorToast message={session.errorMessage} onDismiss={session.clearError} /> 72 72 </> 73 73 ); ··· 86 86 renderComposer={() => <ComposerWindow />} 87 87 renderMessages={(props) => <MessagesPanel memberDid={props.memberDid} />} 88 88 renderNotifications={() => <NotificationsPanel />} 89 + renderPost={(props) => <PostPanel uri={props.uri} />} 89 90 renderProfile={(props) => <ProfilePanel actor={props.actor} />} 90 91 renderShell={AppShell} 91 92 renderTimeline={() => <FeedWorkspace />} />
+1
src/components/feeds/FeedComposer.tsx
··· 361 361 class="mt-4 rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 362 362 href={buildPublicPostUrl(post())} 363 363 text={getPostText(post()) || "Quoted post"} 364 + truncate 364 365 title="Quote preview" /> 365 366 )} 366 367 </Show>
+112 -33
src/components/feeds/PostCard.tsx
··· 15 15 getPostCreatedAt, 16 16 getPostFacets, 17 17 getPostText, 18 + hasKnownThreadContext, 18 19 isReplyItem, 19 20 } from "$/lib/feeds"; 20 21 import { collectModerationLabels } from "$/lib/moderation"; 21 22 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 22 23 import type { 24 + EmbedView, 23 25 FeedViewPost, 24 26 ModerationLabel, 25 27 ModerationReasonType, ··· 38 40 return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']"); 39 41 } 40 42 41 - function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string; profileHref: string }) { 43 + function isDecisionHidden(decision: ModerationUiDecision) { 44 + return decision.filter || decision.blur !== "none"; 45 + } 46 + 47 + function mergeModerationDecisions( 48 + contentDecision: ModerationUiDecision, 49 + mediaDecision: ModerationUiDecision, 50 + ): ModerationUiDecision { 51 + return { 52 + alert: contentDecision.alert || mediaDecision.alert, 53 + blur: contentDecision.blur !== "none" ? contentDecision.blur : mediaDecision.blur, 54 + filter: contentDecision.filter || mediaDecision.filter, 55 + inform: contentDecision.inform || mediaDecision.inform, 56 + noOverride: contentDecision.noOverride || mediaDecision.noOverride, 57 + }; 58 + } 59 + 60 + function PostHeader(props: { authorHandle: string; authorName: string; createdAt: string }) { 42 61 return ( 43 62 <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> 63 + <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> 56 65 <span class="text-xs text-on-surface-variant">{props.createdAt}</span> 57 66 </header> 58 67 ); ··· 147 156 onOpen: (element: HTMLButtonElement) => void; 148 157 triggerRef: (element: HTMLButtonElement) => void; 149 158 }; 159 + showThreadAction: boolean; 150 160 state: PostActionStatus; 151 161 }; 152 162 153 163 function PostActions(props: PostActionsProps) { 154 - const [status, menu, actions] = splitProps(props, ["state"], ["menu"], ["handlers"]); 164 + const [status, menu, actions, visibility] = splitProps(props, ["state"], ["menu"], ["handlers"], [ 165 + "showThreadAction", 166 + ]); 155 167 156 168 return ( 157 169 <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> ··· 180 192 label={status.state.isBookmarked ? "Saved" : "Save"} 181 193 onClick={actions.handlers.onBookmark} /> 182 194 <PostActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={actions.handlers.onQuote} /> 183 - <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={actions.handlers.onOpenThread} /> 195 + <Show when={visibility.showThreadAction}> 196 + <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={actions.handlers.onOpenThread} /> 197 + </Show> 184 198 <button 185 199 aria-label="More actions" 186 200 ref={(element) => menu.menu.triggerRef(element)} ··· 218 232 ); 219 233 } 220 234 235 + function PostEmbedContent( 236 + props: { embed: EmbedView; onOpenPost?: (uri: string) => void; post: PostView; withTopMargin?: boolean }, 237 + ) { 238 + return ( 239 + <div classList={{ "mt-4": !!props.withTopMargin }}> 240 + <EmbedContent embed={props.embed} onOpenPost={props.onOpenPost} post={props.post} /> 241 + </div> 242 + ); 243 + } 244 + 245 + function PostModeratedContent( 246 + props: { 247 + contentDecision: ModerationUiDecision; 248 + contentLabels: ModerationLabel[]; 249 + hasPostText: boolean; 250 + mediaDecision: ModerationUiDecision; 251 + mediaLabels: ModerationLabel[]; 252 + mergeBodyAndEmbedModeration: boolean; 253 + mergedPostDecision: ModerationUiDecision; 254 + onOpenPost?: (uri: string) => void; 255 + post: PostView; 256 + text: string; 257 + }, 258 + ) { 259 + return ( 260 + <Show 261 + when={props.mergeBodyAndEmbedModeration} 262 + fallback={ 263 + <> 264 + <ModeratedPostBody 265 + decision={props.contentDecision} 266 + labels={props.contentLabels} 267 + post={props.post} 268 + text={props.text} /> 269 + 270 + <Show when={props.post.embed}> 271 + {(current) => ( 272 + <ModeratedBlurOverlay decision={props.mediaDecision} labels={props.mediaLabels} class="mt-4"> 273 + <PostEmbedContent embed={current()} onOpenPost={props.onOpenPost} post={props.post} /> 274 + </ModeratedBlurOverlay> 275 + )} 276 + </Show> 277 + </> 278 + }> 279 + <ModeratedBlurOverlay decision={props.mergedPostDecision} labels={props.mediaLabels} class="mt-3"> 280 + <PostBodyText facets={getPostFacets(props.post)} text={props.text} /> 281 + <Show when={props.post.embed}> 282 + {(current) => ( 283 + <PostEmbedContent 284 + embed={current()} 285 + onOpenPost={props.onOpenPost} 286 + post={props.post} 287 + withTopMargin={props.hasPostText} /> 288 + )} 289 + </Show> 290 + </ModeratedBlurOverlay> 291 + </Show> 292 + ); 293 + } 294 + 221 295 type PostCardProps = { 222 296 bookmarkPending?: boolean; 223 297 focused?: boolean; ··· 263 337 const contentDecision = useModerationDecision(contentLabels, "contentList"); 264 338 const mediaDecision = useModerationDecision(mediaLabels, "contentMedia"); 265 339 const avatarDecision = useModerationDecision(avatarLabels, "avatar"); 340 + const contentHidden = createMemo(() => isDecisionHidden(contentDecision())); 341 + const mediaHidden = createMemo(() => isDecisionHidden(mediaDecision())); 342 + const mergeBodyAndEmbedModeration = createMemo(() => contentHidden() && mediaHidden()); 343 + const mergedPostDecision = createMemo(() => mergeModerationDecisions(contentDecision(), mediaDecision())); 344 + const hasPostText = createMemo(() => postText().trim().length > 0); 345 + const showThreadAction = createMemo(() => hasKnownThreadContext(view.post, view.item)); 266 346 const reasonLabel = createMemo(() => { 267 347 const reason = view.item?.reason; 268 348 if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { ··· 333 413 onSelect: () => void navigator.clipboard?.writeText(buildPublicPostUrl(view.post)), 334 414 }); 335 415 336 - if (interactions.onOpenThread) { 416 + if (interactions.onOpenThread && showThreadAction()) { 337 417 items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: interactions.onOpenThread }); 338 418 } 339 419 ··· 437 517 </Show> 438 518 439 519 <div class="flex min-w-0 gap-3"> 440 - <a class="shrink-0 no-underline" href={`#${profileHref()}`} onClick={(event) => event.stopPropagation()}> 520 + <a 521 + aria-label={`View @${view.post.author.handle}`} 522 + class="shrink-0 no-underline" 523 + href={`#${profileHref()}`} 524 + onClick={(event) => event.stopPropagation()}> 441 525 <ModeratedAvatar 442 526 avatar={view.post.author.avatar} 443 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)]" ··· 448 532 449 533 <div class="min-w-0 flex-1"> 450 534 <PostPrimaryRegion onFocus={interactions.onFocus} onOpenThread={interactions.onOpenThread}> 451 - <PostHeader 452 - authorName={authorName()} 453 - authorHandle={authorHandle()} 454 - createdAt={createdAt()} 455 - profileHref={profileHref()} /> 535 + <PostHeader authorName={authorName()} authorHandle={authorHandle()} createdAt={createdAt()} /> 456 536 457 537 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} /> 458 538 459 - <ModeratedPostBody 460 - decision={contentDecision()} 461 - labels={contentLabels()} 539 + <PostModeratedContent 540 + contentDecision={contentDecision()} 541 + contentLabels={contentLabels()} 542 + hasPostText={hasPostText()} 543 + mediaDecision={mediaDecision()} 544 + mediaLabels={mediaLabels()} 545 + mergeBodyAndEmbedModeration={mergeBodyAndEmbedModeration()} 546 + mergedPostDecision={mergedPostDecision()} 547 + onOpenPost={interactions.onOpenThread} 462 548 post={view.post} 463 549 text={postText()} /> 464 - 465 - <Show when={view.post.embed}> 466 - {(current) => ( 467 - <ModeratedBlurOverlay decision={mediaDecision()} labels={mediaLabels()} class="mt-4"> 468 - <EmbedContent embed={current()} post={view.post} /> 469 - </ModeratedBlurOverlay> 470 - )} 471 - </Show> 472 550 </PostPrimaryRegion> 473 551 474 552 <Show when={view.showActions !== false}> ··· 488 566 menuTriggerRef = element; 489 567 }, 490 568 }} 569 + showThreadAction={showThreadAction()} 491 570 state={{ 492 571 bookmarkPending: !!actionFlags.bookmarkPending, 493 572 isBookmarked: isBookmarked(),
+26 -5
src/components/feeds/embeds/ContentEmbed.tsx
··· 1 1 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 2 - import { getQuotedAuthor, getQuotedHref, getQuotedText, postRkeyFromUri } from "$/lib/feeds"; 2 + import { getQuotedAuthor, getQuotedHref, getQuotedText, getQuotedUri, postRkeyFromUri } from "$/lib/feeds"; 3 + import { buildPostRoute } from "$/lib/post-routes"; 3 4 import type { EmbedView, ImagesEmbedView, PostView } from "$/lib/types"; 4 5 import { createMemo, Match, Show, Switch } from "solid-js"; 5 6 import { ExternalEmbed } from "./ExternalEmbed"; 6 7 import { ImageEmbed } from "./ImageEmbed"; 7 8 import { VideoEmbed } from "./VideoEmbed"; 8 9 9 - export function EmbedContent(props: { embed: EmbedView; post: PostView }) { 10 + export function EmbedContent(props: { embed: EmbedView; onOpenPost?: (uri: string) => void; post: PostView }) { 10 11 const postRkey = createMemo(() => postRkeyFromUri(props.post.uri)); 11 12 const media = () => ("media" in props.embed ? props.embed.media : null); 13 + const quotedUri = createMemo(() => getQuotedUri(props.embed)); 14 + const quotedInternalHref = createMemo(() => { 15 + const uri = quotedUri(); 16 + return uri ? `#${buildPostRoute(uri)}` : null; 17 + }); 18 + const quotedExternalHref = createMemo(() => getQuotedHref(props.embed)); 19 + const openQuotedPost = () => { 20 + const uri = quotedUri(); 21 + if (!uri || !props.onOpenPost) { 22 + return; 23 + } 24 + 25 + props.onOpenPost(uri); 26 + }; 12 27 13 28 return ( 14 29 <Switch> ··· 33 48 <Match when={props.embed.$type === "app.bsky.embed.record#view"}> 34 49 <QuotedPostPreview 35 50 author={getQuotedAuthor(props.embed)} 36 - href={getQuotedHref(props.embed)} 51 + href={quotedUri() && props.onOpenPost ? null : quotedInternalHref() ?? quotedExternalHref()} 52 + onOpenPost={quotedUri() && props.onOpenPost ? openQuotedPost : undefined} 37 53 text={getQuotedText(props.embed)} 38 54 title="Quoted post" /> 39 55 </Match> 40 56 <Match when={props.embed.$type === "app.bsky.embed.recordWithMedia#view"}> 41 57 <div class="grid gap-3"> 42 - <Show when={media()}>{(current) => <EmbedContent embed={current() as EmbedView} post={props.post} />}</Show> 58 + <Show when={media()}> 59 + {(current) => ( 60 + <EmbedContent embed={current() as EmbedView} onOpenPost={props.onOpenPost} post={props.post} /> 61 + )} 62 + </Show> 43 63 <QuotedPostPreview 44 64 author={getQuotedAuthor(props.embed)} 45 - href={getQuotedHref(props.embed)} 65 + href={quotedUri() && props.onOpenPost ? null : quotedInternalHref() ?? quotedExternalHref()} 66 + onOpenPost={quotedUri() && props.onOpenPost ? openQuotedPost : undefined} 46 67 text={getQuotedText(props.embed)} 47 68 title="Quoted post" /> 48 69 </div>
+73 -8
src/components/feeds/tests/PostCard.test.tsx
··· 93 93 expect(onOpenThread).toHaveBeenCalledTimes(2); 94 94 }); 95 95 96 - it("does not open the thread when clicking the author link or an action button", () => { 96 + it("keeps profile navigation avatar-only and does not open thread on avatar/action clicks", () => { 97 97 const onOpenThread = vi.fn(); 98 98 const onLike = vi.fn(); 99 99 render(() => <PostCard post={createPost()} onLike={onLike} onOpenThread={onOpenThread} />); 100 100 101 - fireEvent.click(screen.getByRole("link", { name: "Alice" })); 101 + expect(screen.getByRole("link", { name: "View @alice.test" })).toBeInTheDocument(); 102 + expect(screen.queryByRole("link", { name: "Alice" })).not.toBeInTheDocument(); 103 + expect(screen.queryByRole("link", { name: "@alice.test" })).not.toBeInTheDocument(); 104 + 105 + fireEvent.click(screen.getByRole("link", { name: "View @alice.test" })); 102 106 fireEvent.click(screen.getByRole("button", { name: "4" })); 103 107 104 108 expect(onOpenThread).not.toHaveBeenCalled(); 105 109 expect(onLike).toHaveBeenCalledTimes(1); 106 110 }); 107 111 112 + it("opens the thread when clicking the author text region", () => { 113 + const onOpenThread = vi.fn(); 114 + render(() => <PostCard post={createPost()} onOpenThread={onOpenThread} />); 115 + 116 + fireEvent.click(screen.getByText("Alice")); 117 + 118 + expect(onOpenThread).toHaveBeenCalledOnce(); 119 + }); 120 + 108 121 it("opens the shared menu from the overflow trigger and from right click", async () => { 109 122 Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(void 0) } }); 110 123 ··· 121 134 expect(screen.getByRole("menuitem", { name: "Copy post link" })).toBeInTheDocument(); 122 135 }); 123 136 137 + it("hides Thread action when no known thread context exists", () => { 138 + render(() => ( 139 + <PostCard 140 + post={{ ...createPost(), record: { ...createPost().record, reply: undefined }, replyCount: undefined }} 141 + onOpenThread={vi.fn()} /> 142 + )); 143 + 144 + expect(screen.queryByRole("button", { name: "Thread" })).not.toBeInTheDocument(); 145 + fireEvent.click(screen.getByRole("button", { name: "More actions" })); 146 + expect(screen.queryByRole("menuitem", { name: "Open thread" })).not.toBeInTheDocument(); 147 + }); 148 + 149 + it("shows Thread action when reply count indicates known thread context", () => { 150 + render(() => <PostCard post={{ ...createPost(), replyCount: 1 }} onOpenThread={vi.fn()} />); 151 + 152 + expect(screen.getByRole("button", { name: "Thread" })).toBeInTheDocument(); 153 + fireEvent.click(screen.getByRole("button", { name: "More actions" })); 154 + expect(screen.getByRole("menuitem", { name: "Open thread" })).toBeInTheDocument(); 155 + }); 156 + 124 157 it("shows reply context when the feed item is a reply", () => { 125 158 render(() => ( 126 159 <PostCard ··· 161 194 expect(screen.getByText("Replying to did:plc:bob")).toBeInTheDocument(); 162 195 }); 163 196 164 - it("renders recordWithMedia embeds as media plus quoted record", () => { 197 + it("renders recordWithMedia embeds and opens quoted posts internally without bubbling", () => { 198 + const onOpenThread = vi.fn(); 165 199 render(() => ( 166 200 <PostCard 167 201 post={{ ··· 181 215 }, 182 216 }, 183 217 }, 184 - }} /> 218 + }} 219 + onOpenThread={onOpenThread} /> 185 220 )); 186 221 187 222 expect(screen.getByAltText("Preview image")).toHaveAttribute("src", "https://cdn.example.com/image.png"); 188 223 expect(screen.getByText("Quoted post")).toBeInTheDocument(); 189 224 expect(screen.getByText("Quoted body")).toBeInTheDocument(); 190 - expect(screen.getByRole("link", { name: /quoted body/i })).toHaveAttribute( 191 - "href", 192 - "https://bsky.app/profile/bob.test/post/quoted", 193 - ); 225 + 226 + fireEvent.click(screen.getByRole("button", { name: /quoted body/i })); 227 + 228 + expect(onOpenThread).toHaveBeenCalledTimes(1); 229 + expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/quoted"); 194 230 }); 195 231 196 232 it("renders inline video embed player for video attachments", () => { ··· 209 245 210 246 expect(screen.getByRole("button", { name: "Play video" })).toBeInTheDocument(); 211 247 expect(screen.getByText("Attached clip")).toBeInTheDocument(); 248 + }); 249 + 250 + it("shows one moderation overlay when post and embed are both hidden", async () => { 251 + moderateContentMock.mockImplementation(async (_labels, context) => { 252 + if (context === "contentList") { 253 + return { filter: false, blur: "content", alert: false, inform: false, noOverride: false }; 254 + } 255 + 256 + if (context === "contentMedia") { 257 + return { filter: false, blur: "media", alert: false, inform: false, noOverride: false }; 258 + } 259 + 260 + return { filter: false, blur: "none", alert: false, inform: false, noOverride: false }; 261 + }); 262 + 263 + render(() => ( 264 + <PostCard 265 + post={{ 266 + ...createPost(), 267 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 268 + embed: { 269 + $type: "app.bsky.embed.images#view", 270 + images: [{ alt: "Inline image", fullsize: "https://cdn.example.com/post-image.jpg" }], 271 + }, 272 + }} /> 273 + )); 274 + 275 + await waitFor(() => expect(screen.getAllByText("Content blurred")).toHaveLength(1)); 276 + expect(screen.getAllByRole("button", { name: "Show content" })).toHaveLength(1); 212 277 }); 213 278 214 279 it("opens gallery on image click and supports right-click save", async () => {
+62 -12
src/components/notifications/NotificationsPanel.test.tsx
··· 1 1 import { AppTestProviders } from "$/test/providers"; 2 + import { HashRouter, Route } from "@solidjs/router"; 2 3 import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 3 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 5 import { NotificationsPanel } from "./NotificationsPanel"; ··· 23 24 uri: `at://did:plc:${reason}/app.bsky.notification/${reason}`, 24 25 ...overrides, 25 26 }; 27 + } 28 + 29 + function renderNotificationsPanelWithRouter() { 30 + render(() => ( 31 + <AppTestProviders> 32 + <HashRouter> 33 + <Route path="/notifications" component={() => <NotificationsPanel />} /> 34 + </HashRouter> 35 + </AppTestProviders> 36 + )); 37 + } 38 + 39 + async function flushRouterNavigation() { 40 + await vi.runAllTimersAsync(); 41 + await Promise.resolve(); 42 + await Promise.resolve(); 26 43 } 27 44 28 45 describe("NotificationsPanel", () => { ··· 174 191 seenAt: null, 175 192 }); 176 193 177 - render(() => ( 178 - <AppTestProviders> 179 - <NotificationsPanel /> 180 - </AppTestProviders> 181 - )); 194 + renderNotificationsPanelWithRouter(); 182 195 183 196 const body = await screen.findByRole("button", { name: /like author liked your post/i }); 184 197 fireEvent.click(body); 185 198 186 - expect(globalThis.location.hash).toContain("thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F1"); 199 + await flushRouterNavigation(); 200 + expect(globalThis.location.hash).toBe( 201 + "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F1", 202 + ); 187 203 expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 188 204 }); 189 205 206 + it("opens the selected thread when clicking different notification rows", async () => { 207 + listNotificationsMock.mockResolvedValue({ 208 + cursor: null, 209 + notifications: [ 210 + createNotification("like", { 211 + author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 212 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 213 + uri: "at://did:plc:like/app.bsky.notification/1", 214 + }), 215 + createNotification("like", { 216 + author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 217 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/2", 218 + uri: "at://did:plc:like/app.bsky.notification/2", 219 + }), 220 + ], 221 + seenAt: null, 222 + }); 223 + 224 + renderNotificationsPanelWithRouter(); 225 + 226 + const firstBody = await screen.findByRole("button", { name: /alice liked your post/i }); 227 + fireEvent.click(firstBody); 228 + await flushRouterNavigation(); 229 + expect(globalThis.location.hash).toBe( 230 + "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F1", 231 + ); 232 + 233 + const secondBody = screen.getByRole("button", { name: /bob liked your post/i }); 234 + fireEvent.click(secondBody); 235 + await flushRouterNavigation(); 236 + expect(globalThis.location.hash).toBe( 237 + "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F2", 238 + ); 239 + }); 240 + 190 241 it("opens reply/quote target on body click and links original as 'your post'", async () => { 191 242 listNotificationsMock.mockResolvedValue({ 192 243 cursor: null, ··· 200 251 seenAt: null, 201 252 }); 202 253 203 - render(() => ( 204 - <AppTestProviders> 205 - <NotificationsPanel /> 206 - </AppTestProviders> 207 - )); 254 + renderNotificationsPanelWithRouter(); 208 255 209 256 const yourPost = await screen.findByRole("link", { name: "your post" }); 210 257 expect(yourPost).toHaveAttribute( ··· 214 261 215 262 const body = screen.getByRole("button", { name: /alice replied to.*your post/i }); 216 263 fireEvent.click(body); 217 - expect(globalThis.location.hash).toContain("thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Freply"); 264 + await flushRouterNavigation(); 265 + expect(globalThis.location.hash).toBe( 266 + "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Freply", 267 + ); 218 268 expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 219 269 }); 220 270
+31 -21
src/components/notifications/NotificationsPanel.tsx
··· 1 1 import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 2 2 import { useModerationDecision } from "$/components/moderation/useModerationDecision"; 3 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 3 4 import { useAppSession } from "$/contexts/app-session"; 4 5 import { listNotifications, updateSeen } from "$/lib/api/notifications"; 5 6 import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 6 - import { buildThreadOverlayRoute, formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 + import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 8 import { collectModerationLabels } from "$/lib/moderation"; 9 + import { buildPostRoute } from "$/lib/post-routes"; 8 10 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 9 11 import type { ListNotificationsResponse, NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types"; 10 12 import { normalizeError } from "$/lib/utils/text"; ··· 28 30 29 31 type Tab = "all" | "mentions" | "activity"; 30 32 31 - function getCurrentRouteFromHash() { 32 - const rawHash = globalThis.location.hash.replace(/^#/, ""); 33 - const hashRoute = rawHash.length > 0 ? rawHash : "/notifications"; 34 - const [pathname, ...searchTokens] = hashRoute.split("?"); 35 - const search = searchTokens.length > 0 ? `?${searchTokens.join("?")}` : ""; 36 - 37 - return { pathname: pathname || "/notifications", search }; 38 - } 39 - 40 - function buildThreadHrefFromHash(uri: string | null) { 41 - const { pathname, search } = getCurrentRouteFromHash(); 42 - return buildThreadOverlayRoute(pathname, search, uri); 43 - } 44 - 45 33 function hasUnreadNotifications(items: NotificationView[]) { 46 34 return items.some((notification) => !notification.isRead); 47 35 } ··· 71 59 72 60 export function NotificationsPanel() { 73 61 const session = useAppSession(); 62 + let threadOverlay: ReturnType<typeof useThreadOverlayNavigation> | null = null; 63 + try { 64 + threadOverlay = useThreadOverlayNavigation(); 65 + } catch { 66 + threadOverlay = null; 67 + } 68 + 69 + const buildPostHref = (uri: string | null) => { 70 + if (!uri) { 71 + return "/notifications"; 72 + } 73 + 74 + if (threadOverlay) { 75 + return threadOverlay.buildThreadHref(uri); 76 + } 77 + 78 + return buildPostRoute(uri); 79 + }; 80 + const openPost = (uri: string) => { 81 + if (threadOverlay) { 82 + void threadOverlay.openThread(uri); 83 + return; 84 + } 85 + 86 + globalThis.location.hash = `#${buildPostRoute(uri)}`; 87 + }; 74 88 const [tab, setTab] = createSignal<Tab>("all"); 75 89 const [notifications, setNotifications] = createSignal<NotificationView[]>([]); 76 90 const [loading, setLoading] = createSignal(true); ··· 160 174 } 161 175 } 162 176 163 - function openThread(uri: string) { 164 - globalThis.location.hash = buildThreadHrefFromHash(uri); 165 - } 166 - 167 177 onMount(() => { 168 178 reloadNotifications(); 169 179 ··· 187 197 <NotificationsViewport 188 198 activity={activityGrouped()} 189 199 all={allMixed()} 190 - buildThreadHref={buildThreadHrefFromHash} 200 + buildThreadHref={buildPostHref} 191 201 error={error()} 192 202 loading={loading()} 193 203 mentions={mentionsFeed()} 194 204 onMarkRead={markReadByUris} 195 - onOpenThread={openThread} 205 + onOpenThread={openPost} 196 206 tab={tab()} /> 197 207 </article> 198 208 );
+140
src/components/posts/PostPanel.test.tsx
··· 1 + import { decodePostRouteUri } from "$/lib/post-routes"; 2 + import { AppTestProviders } from "$/test/providers"; 3 + import { HashRouter, Route, useParams } from "@solidjs/router"; 4 + import { render, screen, waitFor } from "@solidjs/testing-library"; 5 + import { beforeEach, describe, expect, it, vi } from "vitest"; 6 + import { PostPanel } from "./PostPanel"; 7 + 8 + const invokeMock = vi.hoisted(() => vi.fn()); 9 + 10 + vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 11 + 12 + function createThreadPayload(uri: string, text: string) { 13 + return { 14 + thread: { 15 + $type: "app.bsky.feed.defs#threadViewPost", 16 + post: { 17 + author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 18 + cid: `cid-${text}`, 19 + indexedAt: "2026-03-28T12:00:00.000Z", 20 + likeCount: 0, 21 + record: { createdAt: "2026-03-28T12:00:00.000Z", text }, 22 + replyCount: 0, 23 + repostCount: 0, 24 + uri, 25 + viewer: {}, 26 + }, 27 + replies: [], 28 + }, 29 + }; 30 + } 31 + 32 + function deferred<T>() { 33 + let resolve!: (value: T) => void; 34 + let reject!: (error?: unknown) => void; 35 + const promise = new Promise<T>((innerResolve, innerReject) => { 36 + resolve = innerResolve; 37 + reject = innerReject; 38 + }); 39 + 40 + return { promise, reject, resolve }; 41 + } 42 + 43 + describe("PostPanel", () => { 44 + beforeEach(() => { 45 + invokeMock.mockReset(); 46 + }); 47 + 48 + it("updates visible post content when navigating between post routes", async () => { 49 + const uriA = "at://did:plc:alice/app.bsky.feed.post/a"; 50 + const uriB = "at://did:plc:alice/app.bsky.feed.post/b"; 51 + 52 + invokeMock.mockImplementation((command: string, args?: { uri?: string }) => { 53 + if (command !== "get_post_thread") { 54 + throw new Error(`unexpected invoke: ${command}`); 55 + } 56 + 57 + if (args?.uri === uriA) { 58 + return Promise.resolve(createThreadPayload(uriA, "Post A")); 59 + } 60 + 61 + if (args?.uri === uriB) { 62 + return Promise.resolve(createThreadPayload(uriB, "Post B")); 63 + } 64 + 65 + throw new Error(`unexpected uri: ${args?.uri}`); 66 + }); 67 + 68 + globalThis.location.hash = `#/post/${encodeURIComponent(uriA)}`; 69 + 70 + render(() => ( 71 + <AppTestProviders 72 + session={{ 73 + activeDid: "did:plc:alice", 74 + activeHandle: "alice.test", 75 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 76 + }}> 77 + <HashRouter> 78 + <Route path="/post/:encodedUri" component={TestPostRoute} /> 79 + </HashRouter> 80 + </AppTestProviders> 81 + )); 82 + 83 + expect(await screen.findByText("Post A")).toBeInTheDocument(); 84 + 85 + globalThis.location.hash = `#/post/${encodeURIComponent(uriB)}`; 86 + 87 + await waitFor(() => expect(screen.getByText("Post B")).toBeInTheDocument()); 88 + expect(screen.queryByText("Post A")).not.toBeInTheDocument(); 89 + }); 90 + 91 + it("ignores stale thread responses when switching to a newer route", async () => { 92 + const uriA = "at://did:plc:alice/app.bsky.feed.post/a"; 93 + const uriB = "at://did:plc:alice/app.bsky.feed.post/b"; 94 + const first = deferred<ReturnType<typeof createThreadPayload>>(); 95 + 96 + invokeMock.mockImplementation((command: string, args?: { uri?: string }) => { 97 + if (command !== "get_post_thread") { 98 + throw new Error(`unexpected invoke: ${command}`); 99 + } 100 + 101 + if (args?.uri === uriA) { 102 + return first.promise; 103 + } 104 + 105 + if (args?.uri === uriB) { 106 + return Promise.resolve(createThreadPayload(uriB, "Post B")); 107 + } 108 + 109 + throw new Error(`unexpected uri: ${args?.uri}`); 110 + }); 111 + 112 + globalThis.location.hash = `#/post/${encodeURIComponent(uriA)}`; 113 + 114 + render(() => ( 115 + <AppTestProviders 116 + session={{ 117 + activeDid: "did:plc:alice", 118 + activeHandle: "alice.test", 119 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 120 + }}> 121 + <HashRouter> 122 + <Route path="/post/:encodedUri" component={TestPostRoute} /> 123 + </HashRouter> 124 + </AppTestProviders> 125 + )); 126 + 127 + globalThis.location.hash = `#/post/${encodeURIComponent(uriB)}`; 128 + expect(await screen.findByText("Post B")).toBeInTheDocument(); 129 + 130 + first.resolve(createThreadPayload(uriA, "Post A")); 131 + 132 + await waitFor(() => expect(screen.queryByText("Post A")).not.toBeInTheDocument()); 133 + expect(screen.getByText("Post B")).toBeInTheDocument(); 134 + }); 135 + }); 136 + 137 + function TestPostRoute() { 138 + const params = useParams<{ encodedUri: string }>(); 139 + return <PostPanel uri={decodePostRouteUri(params.encodedUri)} />; 140 + }
+352
src/components/posts/PostPanel.tsx
··· 1 + import { PostCard } from "$/components/feeds/PostCard"; 2 + import { Icon } from "$/components/shared/Icon"; 3 + import { useAppSession } from "$/contexts/app-session"; 4 + import { FeedController } from "$/lib/api/feeds"; 5 + import { isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds"; 6 + import type { PostView, ThreadNode, ThreadViewPost } from "$/lib/types"; 7 + import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 8 + import { createStore } from "solid-js/store"; 9 + import { usePostInteractions } from "./usePostInteractions"; 10 + import { usePostNavigation } from "./usePostNavigation"; 11 + 12 + type PostPanelState = { error: string | null; loading: boolean; thread: ThreadNode | null; uri: string | null }; 13 + 14 + function createPostPanelState(): PostPanelState { 15 + return { error: null, loading: false, thread: null, uri: null }; 16 + } 17 + 18 + function findThreadPost(node: ThreadNode | null | undefined, uri: string): ThreadViewPost | null { 19 + if (!node || !isThreadViewPost(node)) { 20 + return null; 21 + } 22 + 23 + if (node.post.uri === uri) { 24 + return node; 25 + } 26 + 27 + const parentMatch = findThreadPost(node.parent, uri); 28 + if (parentMatch) { 29 + return parentMatch; 30 + } 31 + 32 + for (const reply of node.replies ?? []) { 33 + const replyMatch = findThreadPost(reply, uri); 34 + if (replyMatch) { 35 + return replyMatch; 36 + } 37 + } 38 + 39 + return null; 40 + } 41 + 42 + function collectParentChain(node: ThreadViewPost | null): ThreadViewPost[] { 43 + if (!node) { 44 + return []; 45 + } 46 + 47 + const chain: ThreadViewPost[] = []; 48 + let current: ThreadNode | null | undefined = node.parent; 49 + while (current && isThreadViewPost(current)) { 50 + chain.unshift(current); 51 + current = current.parent; 52 + } 53 + 54 + return chain; 55 + } 56 + 57 + export function PostPanel(props: { uri: string | null }) { 58 + const session = useAppSession(); 59 + const postNavigation = usePostNavigation(); 60 + const [state, setState] = createStore<PostPanelState>(createPostPanelState()); 61 + let requestId = 0; 62 + const interactions = usePostInteractions({ 63 + onError: session.reportError, 64 + patchPost(uri, updater) { 65 + const current = state.thread; 66 + if (!current) { 67 + return; 68 + } 69 + 70 + setState("thread", patchThreadNode(current, uri, updater)); 71 + }, 72 + }); 73 + 74 + const focusedNode = createMemo(() => { 75 + const uri = props.uri; 76 + const thread = state.thread; 77 + if (!uri || !thread) { 78 + return null; 79 + } 80 + 81 + return findThreadPost(thread, uri); 82 + }); 83 + const parentChain = createMemo(() => collectParentChain(focusedNode())); 84 + const parentPostUri = createMemo(() => { 85 + const focused = focusedNode(); 86 + if (!focused || !focused.parent || !isThreadViewPost(focused.parent)) { 87 + return null; 88 + } 89 + 90 + return focused.parent.post.uri; 91 + }); 92 + 93 + createEffect(() => { 94 + const uri = props.uri; 95 + if (!uri) { 96 + setState(createPostPanelState()); 97 + return; 98 + } 99 + 100 + if (state.uri === uri && (state.loading || state.thread || state.error)) { 101 + return; 102 + } 103 + 104 + const nextRequestId = ++requestId; 105 + void loadThread(uri, nextRequestId); 106 + }); 107 + 108 + async function loadThread(uri: string, nextRequestId: number) { 109 + setState({ error: null, loading: true, thread: null, uri }); 110 + 111 + try { 112 + const payload = await FeedController.getPostThread(uri); 113 + if (nextRequestId !== requestId || props.uri !== uri) { 114 + return; 115 + } 116 + 117 + setState({ error: null, loading: false, thread: payload.thread, uri }); 118 + } catch (error) { 119 + if (nextRequestId !== requestId || props.uri !== uri) { 120 + return; 121 + } 122 + 123 + setState({ error: String(error), loading: false, thread: null, uri }); 124 + session.reportError(`Failed to open post: ${String(error)}`); 125 + } 126 + } 127 + 128 + return ( 129 + <section class="grid min-h-0 grid-rows-[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)]"> 130 + <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"> 131 + <div class="min-w-0"> 132 + <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post</p> 133 + <Show when={parentPostUri()}> 134 + {(parentUri) => ( 135 + <a 136 + class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant no-underline transition hover:text-primary hover:underline" 137 + href={`#${postNavigation.buildPostHref(parentUri())}`}> 138 + Parent post 139 + </a> 140 + )} 141 + </Show> 142 + </div> 143 + <button 144 + type="button" 145 + 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" 146 + onClick={() => void postNavigation.backFromPost()}> 147 + <Icon aria-hidden="true" iconClass="i-ri-arrow-left-line" /> 148 + Back 149 + </button> 150 + </header> 151 + 152 + <div class="min-h-0 overflow-y-auto overscroll-contain px-3 pb-4 pt-3"> 153 + <Show 154 + when={props.uri} 155 + fallback={<PostPanelMessage body="This post link is invalid." title="Post unavailable" />}> 156 + <ThreadState 157 + bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 158 + error={state.error} 159 + focusedNode={focusedNode()} 160 + likePendingByUri={interactions.likePendingByUri()} 161 + loading={state.loading} 162 + onBookmark={(post) => void interactions.toggleBookmark(post)} 163 + onLike={(post) => void interactions.toggleLike(post)} 164 + onOpenPost={(uri) => void postNavigation.openPost(uri)} 165 + onRepost={(post) => void interactions.toggleRepost(post)} 166 + parentChain={parentChain()} 167 + repostPendingByUri={interactions.repostPendingByUri()} /> 168 + </Show> 169 + </div> 170 + </section> 171 + ); 172 + } 173 + 174 + function ThreadState( 175 + props: { 176 + bookmarkPendingByUri: Record<string, boolean>; 177 + error: string | null; 178 + focusedNode: ThreadViewPost | null; 179 + likePendingByUri: Record<string, boolean>; 180 + loading: boolean; 181 + onBookmark: (post: PostView) => void; 182 + onLike: (post: PostView) => void; 183 + onOpenPost: (uri: string) => void; 184 + onRepost: (post: PostView) => void; 185 + parentChain: ThreadViewPost[]; 186 + repostPendingByUri: Record<string, boolean>; 187 + }, 188 + ) { 189 + return ( 190 + <> 191 + <Show when={props.loading}> 192 + <div class="grid gap-3"> 193 + <SkeletonPostCard /> 194 + <SkeletonPostCard /> 195 + </div> 196 + </Show> 197 + 198 + <Show when={!props.loading && props.error}> 199 + {(message) => <PostPanelMessage body={message()} title="Couldn't load this post" />} 200 + </Show> 201 + 202 + <Show when={!props.loading && !props.error && props.focusedNode}> 203 + {(focused) => ( 204 + <div class="grid gap-3"> 205 + <For each={props.parentChain}> 206 + {(parent) => ( 207 + <div class="rounded-3xl bg-white/3 p-3"> 208 + <PostCard 209 + bookmarkPending={!!props.bookmarkPendingByUri[parent.post.uri]} 210 + likePending={!!props.likePendingByUri[parent.post.uri]} 211 + onBookmark={() => props.onBookmark(parent.post)} 212 + onLike={() => props.onLike(parent.post)} 213 + onOpenThread={() => props.onOpenPost(parent.post.uri)} 214 + onRepost={() => props.onRepost(parent.post)} 215 + post={parent.post} 216 + repostPending={!!props.repostPendingByUri[parent.post.uri]} 217 + showActions={false} /> 218 + </div> 219 + )} 220 + </For> 221 + 222 + <PostCard 223 + bookmarkPending={!!props.bookmarkPendingByUri[focused().post.uri]} 224 + focused 225 + likePending={!!props.likePendingByUri[focused().post.uri]} 226 + onBookmark={() => props.onBookmark(focused().post)} 227 + onLike={() => props.onLike(focused().post)} 228 + onOpenThread={() => props.onOpenPost(focused().post.uri)} 229 + onRepost={() => props.onRepost(focused().post)} 230 + post={focused().post} 231 + repostPending={!!props.repostPendingByUri[focused().post.uri]} /> 232 + 233 + <Show when={focused().replies?.length}> 234 + <div class="grid gap-3 rounded-3xl bg-white/3 p-3"> 235 + <For each={focused().replies}> 236 + {(reply) => ( 237 + <ThreadReplies 238 + bookmarkPendingByUri={props.bookmarkPendingByUri} 239 + likePendingByUri={props.likePendingByUri} 240 + node={reply} 241 + onBookmark={props.onBookmark} 242 + onLike={props.onLike} 243 + onOpenPost={props.onOpenPost} 244 + onRepost={props.onRepost} 245 + repostPendingByUri={props.repostPendingByUri} /> 246 + )} 247 + </For> 248 + </div> 249 + </Show> 250 + </div> 251 + )} 252 + </Show> 253 + </> 254 + ); 255 + } 256 + 257 + function ThreadReplies( 258 + props: { 259 + bookmarkPendingByUri: Record<string, boolean>; 260 + likePendingByUri: Record<string, boolean>; 261 + node: ThreadNode; 262 + onBookmark: (post: PostView) => void; 263 + onLike: (post: PostView) => void; 264 + onOpenPost: (uri: string) => void; 265 + onRepost: (post: PostView) => void; 266 + repostPendingByUri: Record<string, boolean>; 267 + }, 268 + ) { 269 + const threadNode = createMemo(() => (isThreadViewPost(props.node) ? props.node : null)); 270 + 271 + return ( 272 + <Switch> 273 + <Match when={isBlockedNode(props.node)}> 274 + <StateCard label="Blocked post" meta={isBlockedNode(props.node) ? props.node.uri : ""} /> 275 + </Match> 276 + <Match when={isNotFoundNode(props.node)}> 277 + <StateCard label="Post not found" meta={isNotFoundNode(props.node) ? props.node.uri : ""} /> 278 + </Match> 279 + <Match when={threadNode()}> 280 + {(current) => ( 281 + <div class="grid gap-3"> 282 + <PostCard 283 + bookmarkPending={!!props.bookmarkPendingByUri[current().post.uri]} 284 + likePending={!!props.likePendingByUri[current().post.uri]} 285 + onBookmark={() => props.onBookmark(current().post)} 286 + onLike={() => props.onLike(current().post)} 287 + onOpenThread={() => props.onOpenPost(current().post.uri)} 288 + onRepost={() => props.onRepost(current().post)} 289 + post={current().post} 290 + repostPending={!!props.repostPendingByUri[current().post.uri]} /> 291 + 292 + <Show when={current().replies?.length}> 293 + <div class="ml-3 grid gap-3 border-l border-white/8 pl-3"> 294 + <For each={current().replies}> 295 + {(reply) => ( 296 + <ThreadReplies 297 + bookmarkPendingByUri={props.bookmarkPendingByUri} 298 + likePendingByUri={props.likePendingByUri} 299 + node={reply} 300 + onBookmark={props.onBookmark} 301 + onLike={props.onLike} 302 + onOpenPost={props.onOpenPost} 303 + onRepost={props.onRepost} 304 + repostPendingByUri={props.repostPendingByUri} /> 305 + )} 306 + </For> 307 + </div> 308 + </Show> 309 + </div> 310 + )} 311 + </Match> 312 + </Switch> 313 + ); 314 + } 315 + 316 + function PostPanelMessage(props: { body: string; title: string }) { 317 + return ( 318 + <div class="grid min-h-112 place-items-center px-6 py-10"> 319 + <div class="grid max-w-lg gap-3 text-center"> 320 + <p class="m-0 text-base font-medium text-on-surface">{props.title}</p> 321 + <p class="m-0 text-sm text-on-surface-variant">{props.body}</p> 322 + </div> 323 + </div> 324 + ); 325 + } 326 + 327 + function SkeletonPostCard() { 328 + return ( 329 + <div class="rounded-3xl bg-white/3 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 330 + <div class="flex gap-3"> 331 + <div class="skeleton-block h-11 w-11 rounded-full" /> 332 + <div class="min-w-0 flex-1"> 333 + <div class="skeleton-block h-4 w-40 rounded-full" /> 334 + <div class="mt-3 grid gap-2"> 335 + <div class="skeleton-block h-3.5 w-full rounded-full" /> 336 + <div class="skeleton-block h-3.5 w-[82%] rounded-full" /> 337 + <div class="skeleton-block h-3.5 w-[68%] rounded-full" /> 338 + </div> 339 + </div> 340 + </div> 341 + </div> 342 + ); 343 + } 344 + 345 + function StateCard(props: { label: string; meta: string }) { 346 + return ( 347 + <div class="rounded-3xl bg-white/3 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 348 + <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 349 + <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 350 + </div> 351 + ); 352 + }
+170
src/components/posts/ThreadDrawer.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { HashRouter, Route } from "@solidjs/router"; 3 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { describe, expect, it, vi } from "vitest"; 5 + import { ThreadDrawer } from "./ThreadDrawer"; 6 + 7 + const invokeMock = vi.hoisted(() => vi.fn()); 8 + 9 + vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 10 + 11 + function createThreadPayload() { 12 + return { 13 + thread: { 14 + $type: "app.bsky.feed.defs#threadViewPost", 15 + post: { 16 + author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 17 + cid: "cid-post", 18 + indexedAt: "2026-03-28T12:00:00.000Z", 19 + likeCount: 4, 20 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Thread root" }, 21 + replyCount: 2, 22 + repostCount: 1, 23 + uri: "at://did:plc:alice/app.bsky.feed.post/123", 24 + viewer: {}, 25 + }, 26 + replies: [], 27 + }, 28 + }; 29 + } 30 + 31 + function createThreadPayloadWithParent() { 32 + return { 33 + thread: { 34 + $type: "app.bsky.feed.defs#threadViewPost", 35 + post: { 36 + author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 37 + cid: "cid-child", 38 + indexedAt: "2026-03-28T12:05:00.000Z", 39 + likeCount: 1, 40 + record: { createdAt: "2026-03-28T12:05:00.000Z", text: "Child post" }, 41 + replyCount: 0, 42 + repostCount: 0, 43 + uri: "at://did:plc:alice/app.bsky.feed.post/child", 44 + viewer: {}, 45 + }, 46 + parent: { 47 + $type: "app.bsky.feed.defs#threadViewPost", 48 + post: { 49 + author: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 50 + cid: "cid-parent", 51 + indexedAt: "2026-03-28T12:00:00.000Z", 52 + likeCount: 2, 53 + record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Parent root" }, 54 + replyCount: 1, 55 + repostCount: 0, 56 + uri: "at://did:plc:bob/app.bsky.feed.post/parent", 57 + viewer: {}, 58 + }, 59 + replies: [], 60 + }, 61 + replies: [], 62 + }, 63 + }; 64 + } 65 + 66 + describe("ThreadDrawer", () => { 67 + it("opens from the thread query param on eligible routes and closes to the base route", async () => { 68 + invokeMock.mockImplementation((command: string) => { 69 + if (command === "get_post_thread") { 70 + return Promise.resolve(createThreadPayload()); 71 + } 72 + 73 + throw new Error(`unexpected invoke: ${command}`); 74 + }); 75 + 76 + globalThis.location.hash = "#/timeline?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F123"; 77 + 78 + render(() => ( 79 + <AppTestProviders 80 + session={{ 81 + activeDid: "did:plc:alice", 82 + activeHandle: "alice.test", 83 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 84 + }}> 85 + <HashRouter> 86 + <Route path="/timeline" component={() => <ThreadDrawer />} /> 87 + </HashRouter> 88 + </AppTestProviders> 89 + )); 90 + 91 + expect(await screen.findByText("Thread root")).toBeInTheDocument(); 92 + 93 + fireEvent.click(screen.getByRole("button", { name: "Close thread" })); 94 + 95 + await waitFor(() => expect(globalThis.location.hash).toBe("#/timeline")); 96 + expect(screen.queryByText("Thread root")).not.toBeInTheDocument(); 97 + }); 98 + 99 + it("keeps parent links in drawer mode and supports maximize to the full post screen", async () => { 100 + const childUri = "at://did:plc:alice/app.bsky.feed.post/child"; 101 + const parentUri = "at://did:plc:bob/app.bsky.feed.post/parent"; 102 + 103 + invokeMock.mockImplementation((command: string, args?: { uri?: string }) => { 104 + if (command !== "get_post_thread") { 105 + throw new Error(`unexpected invoke: ${command}`); 106 + } 107 + 108 + if (args?.uri === parentUri) { 109 + return Promise.resolve({ 110 + thread: { 111 + $type: "app.bsky.feed.defs#threadViewPost", 112 + post: createThreadPayloadWithParent().thread.parent.post, 113 + replies: [], 114 + }, 115 + }); 116 + } 117 + 118 + return Promise.resolve(createThreadPayloadWithParent()); 119 + }); 120 + 121 + globalThis.location.hash = `#/timeline?foo=bar&thread=${encodeURIComponent(childUri)}`; 122 + 123 + render(() => ( 124 + <AppTestProviders 125 + session={{ 126 + activeDid: "did:plc:alice", 127 + activeHandle: "alice.test", 128 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 129 + }}> 130 + <HashRouter> 131 + <Route path="/timeline" component={() => <ThreadDrawer />} /> 132 + </HashRouter> 133 + </AppTestProviders> 134 + )); 135 + 136 + expect(await screen.findByText("Child post")).toBeInTheDocument(); 137 + expect(screen.getByRole("link", { name: "Parent post" })).toBeInTheDocument(); 138 + 139 + fireEvent.click(screen.getByRole("link", { name: "Parent post" })); 140 + 141 + await waitFor(() => 142 + expect(globalThis.location.hash).toBe(`#/timeline?foo=bar&thread=${encodeURIComponent(parentUri)}`) 143 + ); 144 + expect(screen.queryByRole("link", { name: "Parent post" })).not.toBeInTheDocument(); 145 + 146 + fireEvent.click(screen.getByRole("button", { name: "Open full post" })); 147 + 148 + await waitFor(() => expect(globalThis.location.hash).toBe(`#/post/${encodeURIComponent(parentUri)}`)); 149 + }); 150 + 151 + it("does not render on ineligible routes even when a thread query param exists", async () => { 152 + globalThis.location.hash = "#/profile/alice?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F123"; 153 + 154 + render(() => ( 155 + <AppTestProviders 156 + session={{ 157 + activeDid: "did:plc:alice", 158 + activeHandle: "alice.test", 159 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 160 + }}> 161 + <HashRouter> 162 + <Route path="/profile/:actor" component={() => <ThreadDrawer />} /> 163 + </HashRouter> 164 + </AppTestProviders> 165 + )); 166 + 167 + expect(screen.queryByText("Thread root")).not.toBeInTheDocument(); 168 + expect(screen.queryByRole("button", { name: "Close thread" })).not.toBeInTheDocument(); 169 + }); 170 + });
-71
src/components/posts/ThreadModal.test.tsx
··· 1 - import { AppTestProviders } from "$/test/providers"; 2 - import { HashRouter, Route } from "@solidjs/router"; 3 - import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 4 - import { describe, expect, it, vi } from "vitest"; 5 - import { ThreadModal } from "./ThreadModal"; 6 - 7 - const invokeMock = vi.hoisted(() => vi.fn()); 8 - 9 - vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 10 - 11 - function createThreadPayload() { 12 - return { 13 - thread: { 14 - $type: "app.bsky.feed.defs#threadViewPost", 15 - post: { 16 - author: { did: "did:plc:alice", handle: "alice.test", displayName: "Alice" }, 17 - cid: "cid-post", 18 - indexedAt: "2026-03-28T12:00:00.000Z", 19 - likeCount: 4, 20 - record: { createdAt: "2026-03-28T12:00:00.000Z", text: "Thread root" }, 21 - replyCount: 2, 22 - repostCount: 1, 23 - uri: "at://did:plc:alice/app.bsky.feed.post/123", 24 - viewer: {}, 25 - }, 26 - replies: [], 27 - }, 28 - }; 29 - } 30 - 31 - describe("ThreadModal", () => { 32 - it("opens from the thread query param on top of the current route and closes without changing the base path", async () => { 33 - invokeMock.mockImplementation((command: string) => { 34 - if (command === "get_post_thread") { 35 - return Promise.resolve(createThreadPayload()); 36 - } 37 - 38 - throw new Error(`unexpected invoke: ${command}`); 39 - }); 40 - 41 - globalThis.location.hash = "#/profile/alice?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F123"; 42 - 43 - render(() => ( 44 - <AppTestProviders 45 - session={{ 46 - activeDid: "did:plc:alice", 47 - activeHandle: "alice.test", 48 - activeSession: { did: "did:plc:alice", handle: "alice.test" }, 49 - }}> 50 - <HashRouter> 51 - <Route 52 - path="/profile/:actor" 53 - component={() => ( 54 - <> 55 - <div data-testid="profile-screen">profile underneath</div> 56 - <ThreadModal /> 57 - </> 58 - )} /> 59 - </HashRouter> 60 - </AppTestProviders> 61 - )); 62 - 63 - expect(await screen.findByText("Thread root")).toBeInTheDocument(); 64 - expect(screen.getByTestId("profile-screen")).toBeInTheDocument(); 65 - 66 - fireEvent.click(screen.getByRole("button", { name: "Close thread" })); 67 - 68 - await waitFor(() => expect(globalThis.location.hash).toBe("#/profile/alice")); 69 - expect(screen.queryByText("Thread root")).not.toBeInTheDocument(); 70 - }); 71 - });
+117 -30
src/components/posts/ThreadModal.tsx src/components/posts/ThreadDrawer.tsx
··· 8 8 import { Motion, Presence } from "solid-motionone"; 9 9 import { PostCard } from "../feeds/PostCard"; 10 10 import { usePostInteractions } from "./usePostInteractions"; 11 + import { usePostNavigation } from "./usePostNavigation"; 11 12 import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation"; 12 13 13 - type ThreadModalState = { error: string | null; loading: boolean; thread: ThreadNode | null; uri: string | null }; 14 + type ThreadDrawerState = { error: string | null; loading: boolean; thread: ThreadNode | null; uri: string | null }; 14 15 15 - function createThreadModalState(): ThreadModalState { 16 + function createThreadDrawerState(): ThreadDrawerState { 16 17 return { error: null, loading: false, thread: null, uri: null }; 17 18 } 18 19 19 - export function ThreadModal() { 20 + function findParentUri(node: ThreadNode | null, targetUri: string | null): string | null { 21 + if (!node || !targetUri) { 22 + return null; 23 + } 24 + 25 + const visited = new Set<ThreadNode>(); 26 + 27 + function walk(current: ThreadNode): string | null { 28 + if (visited.has(current)) { 29 + return null; 30 + } 31 + 32 + visited.add(current); 33 + 34 + if (isThreadViewPost(current)) { 35 + if (current.post.uri === targetUri && current.parent && isThreadViewPost(current.parent)) { 36 + return current.parent.post.uri; 37 + } 38 + 39 + if (current.parent) { 40 + const parentMatch = walk(current.parent); 41 + if (parentMatch) { 42 + return parentMatch; 43 + } 44 + } 45 + 46 + for (const reply of current.replies ?? []) { 47 + const replyMatch = walk(reply); 48 + if (replyMatch) { 49 + return replyMatch; 50 + } 51 + } 52 + } 53 + 54 + return null; 55 + } 56 + 57 + return walk(node); 58 + } 59 + 60 + function createEscapeKeyHandler(onClose: () => void) { 61 + return (event: KeyboardEvent) => { 62 + if (event.key !== "Escape") { 63 + return; 64 + } 65 + 66 + event.preventDefault(); 67 + onClose(); 68 + }; 69 + } 70 + 71 + export function ThreadDrawer() { 20 72 const session = useAppSession(); 73 + const postNavigation = usePostNavigation(); 21 74 const threadOverlay = useThreadOverlayNavigation(); 22 - const [state, setState] = createStore<ThreadModalState>(createThreadModalState()); 75 + const [state, setState] = createStore<ThreadDrawerState>(createThreadDrawerState()); 76 + const activeUri = createMemo(() => (threadOverlay.drawerEnabled() ? threadOverlay.threadUri() : null)); 23 77 const rootPost = createMemo(() => findRootPost(state.thread)); 78 + const parentThreadUri = createMemo(() => findParentUri(state.thread, activeUri())); 79 + const parentThreadHref = createMemo(() => 80 + parentThreadUri() ? threadOverlay.buildThreadHref(parentThreadUri()) : null 81 + ); 24 82 const interactions = usePostInteractions({ 25 83 onError: session.reportError, 26 84 patchPost(uri, updater) { ··· 34 92 }); 35 93 36 94 createEffect(() => { 37 - const uri = threadOverlay.threadUri(); 95 + const uri = activeUri(); 38 96 if (!uri) { 39 97 if (state.uri || state.thread || state.error || state.loading) { 40 - setState(createThreadModalState()); 98 + setState(createThreadDrawerState()); 41 99 } 42 100 return; 43 101 } ··· 50 108 }); 51 109 52 110 createEffect(() => { 53 - if (!threadOverlay.threadUri()) { 111 + if (!activeUri()) { 54 112 return; 55 113 } 56 114 57 - const handleKeyDown = (event: KeyboardEvent) => { 58 - if (event.key === "Escape") { 59 - event.preventDefault(); 60 - void threadOverlay.closeThread(); 61 - } 62 - }; 115 + const handleKeyDown = createEscapeKeyHandler(() => { 116 + void threadOverlay.closeThread(); 117 + }); 63 118 64 119 globalThis.addEventListener("keydown", handleKeyDown); 65 120 onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown)); ··· 70 125 71 126 try { 72 127 const payload = await FeedController.getPostThread(uri); 73 - if (threadOverlay.threadUri() === uri) { 128 + if (activeUri() === uri) { 74 129 setState({ error: null, loading: false, thread: payload.thread, uri }); 75 130 } 76 131 } catch (error) { 77 - if (threadOverlay.threadUri() === uri) { 132 + if (activeUri() === uri) { 78 133 setState({ error: String(error), loading: false, thread: null, uri }); 79 134 } 80 135 session.reportError(`Failed to open thread: ${String(error)}`); ··· 83 138 84 139 return ( 85 140 <Presence> 86 - <Show when={threadOverlay.threadUri()}> 141 + <Show when={activeUri()}> 87 142 <div class="fixed inset-0 z-50"> 88 143 <Motion.button 89 144 class="absolute inset-0 border-0 bg-surface-container-highest/70 backdrop-blur-xl" ··· 100 155 animate={{ opacity: 1, x: 0 }} 101 156 exit={{ opacity: 0, x: 36 }} 102 157 transition={{ duration: 0.22 }}> 103 - <ThreadModalHeader onClose={() => void threadOverlay.closeThread()} /> 104 - <ThreadModalBody 105 - activeUri={threadOverlay.threadUri()} 158 + <ThreadDrawerHeader 159 + activeUri={activeUri()} 160 + onMaximize={(uri) => void postNavigation.openPost(uri)} 161 + parentThreadHref={parentThreadHref()} 162 + onClose={() => void threadOverlay.closeThread()} /> 163 + <ThreadDrawerBody 164 + activeUri={activeUri()} 106 165 bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 107 166 error={state.error} 108 167 likePendingByUri={interactions.likePendingByUri()} ··· 121 180 ); 122 181 } 123 182 124 - function ThreadModalBody( 183 + function ThreadDrawerBody( 125 184 props: { 126 185 activeUri: string | null; 127 186 bookmarkPendingByUri: Record<string, boolean>; ··· 139 198 ) { 140 199 return ( 141 200 <div class="min-h-0 overflow-y-auto overscroll-contain pb-1"> 142 - <ThreadModalLoading loading={props.loading} /> 201 + <ThreadDrawerLoading loading={props.loading} /> 143 202 144 203 <Show when={!props.loading && props.error}> 145 204 {(message) => ( ··· 170 229 ); 171 230 } 172 231 173 - function ThreadModalHeader(props: { onClose: () => void }) { 232 + function ThreadDrawerHeader( 233 + props: { 234 + activeUri: string | null; 235 + onClose: () => void; 236 + onMaximize: (uri: string) => void; 237 + parentThreadHref: string | null; 238 + }, 239 + ) { 174 240 return ( 175 241 <header class="sticky top-0 z-10 mb-4 flex items-center justify-between rounded-3xl bg-[rgba(14,14,14,0.9)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 176 242 <div> 177 243 <p class="m-0 text-base font-semibold text-on-surface">Thread</p> 178 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Nested replies</p> 244 + <Show when={props.parentThreadHref}> 245 + {(href) => ( 246 + <a 247 + class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant no-underline transition hover:text-primary hover:underline" 248 + href={`#${href()}`}> 249 + Parent post 250 + </a> 251 + )} 252 + </Show> 179 253 </div> 180 - <button 181 - class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 182 - type="button" 183 - onClick={() => props.onClose()}> 184 - <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 185 - </button> 254 + <div class="flex items-center gap-2"> 255 + <Show when={props.activeUri}> 256 + {(uri) => ( 257 + <button 258 + aria-label="Open full post" 259 + class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 260 + type="button" 261 + onClick={() => props.onMaximize(uri())}> 262 + <Icon aria-hidden="true" iconClass="i-ri-external-link-line" /> 263 + </button> 264 + )} 265 + </Show> 266 + <button 267 + class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 268 + type="button" 269 + onClick={() => props.onClose()}> 270 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 271 + </button> 272 + </div> 186 273 </header> 187 274 ); 188 275 } 189 276 190 - function ThreadModalLoading(props: { loading: boolean }) { 277 + function ThreadDrawerLoading(props: { loading: boolean }) { 191 278 return ( 192 279 <Show when={props.loading}> 193 280 <div class="grid gap-3">
+21
src/components/posts/usePostNavigation.ts
··· 1 + import { TIMELINE_ROUTE } from "$/lib/feeds"; 2 + import { buildPostRoute } from "$/lib/post-routes"; 3 + import { useNavigate } from "@solidjs/router"; 4 + 5 + export function usePostNavigation() { 6 + const navigate = useNavigate(); 7 + 8 + function openPost(uri: string) { 9 + return navigate(buildPostRoute(uri)); 10 + } 11 + 12 + function backFromPost() { 13 + if (globalThis.history.length > 1) { 14 + return navigate(-1); 15 + } 16 + 17 + return navigate(TIMELINE_ROUTE); 18 + } 19 + 20 + return { backFromPost, buildPostHref: buildPostRoute, openPost }; 21 + }
+52
src/components/posts/useThreadOverlayNavigation.test.ts
··· 1 + import { createRoot } from "solid-js"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { useThreadOverlayNavigation } from "./useThreadOverlayNavigation"; 4 + 5 + const navigateMock = vi.hoisted(() => vi.fn()); 6 + const locationState = vi.hoisted(() => ({ pathname: "/timeline", search: "" })); 7 + 8 + vi.mock("@solidjs/router", () => ({ useLocation: () => locationState, useNavigate: () => navigateMock })); 9 + 10 + describe("useThreadOverlayNavigation", () => { 11 + beforeEach(() => { 12 + navigateMock.mockReset(); 13 + locationState.pathname = "/timeline"; 14 + locationState.search = ""; 15 + }); 16 + 17 + it("opens threads in drawer mode on eligible routes", () => { 18 + createRoot((dispose) => { 19 + const overlay = useThreadOverlayNavigation(); 20 + void overlay.openThread("at://did:plc:alice/app.bsky.feed.post/1"); 21 + dispose(); 22 + }); 23 + 24 + expect(navigateMock).toHaveBeenCalledWith("/timeline?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F1"); 25 + }); 26 + 27 + it("opens threads on the dedicated post route for ineligible paths", () => { 28 + locationState.pathname = "/search"; 29 + locationState.search = "?q=test"; 30 + 31 + createRoot((dispose) => { 32 + const overlay = useThreadOverlayNavigation(); 33 + void overlay.openThread("at://did:plc:alice/app.bsky.feed.post/2"); 34 + dispose(); 35 + }); 36 + 37 + expect(navigateMock).toHaveBeenCalledWith("/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F2"); 38 + }); 39 + 40 + it("closes drawer mode by removing only the thread query param", () => { 41 + locationState.pathname = "/notifications"; 42 + locationState.search = "?foo=bar&thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2F3"; 43 + 44 + createRoot((dispose) => { 45 + const overlay = useThreadOverlayNavigation(); 46 + void overlay.closeThread(); 47 + dispose(); 48 + }); 49 + 50 + expect(navigateMock).toHaveBeenCalledWith("/notifications?foo=bar"); 51 + }); 52 + });
+44 -8
src/components/posts/useThreadOverlayNavigation.ts
··· 1 - import { buildThreadOverlayRoute, getThreadOverlayUri } from "$/lib/feeds"; 1 + import { buildThreadOverlayRoute, getThreadOverlayUri, TIMELINE_ROUTE } from "$/lib/feeds"; 2 + import { buildPostRoute, decodePostRouteUri, isThreadDrawerPath, POST_ROUTE } from "$/lib/post-routes"; 2 3 import { useLocation, useNavigate } from "@solidjs/router"; 3 4 import { createMemo } from "solid-js"; 4 5 5 6 export function useThreadOverlayNavigation() { 6 7 const location = useLocation(); 7 8 const navigate = useNavigate(); 9 + const drawerEnabled = createMemo(() => isThreadDrawerPath(location.pathname)); 10 + const postRouteThreadUri = createMemo(() => { 11 + const prefix = `${POST_ROUTE}/`; 12 + if (!location.pathname.startsWith(prefix)) { 13 + return null; 14 + } 8 15 9 - const threadUri = createMemo(() => getThreadOverlayUri(location.search)); 16 + return decodePostRouteUri(location.pathname.slice(prefix.length)); 17 + }); 18 + 19 + const threadUri = createMemo(() => { 20 + if (drawerEnabled()) { 21 + return getThreadOverlayUri(location.search); 22 + } 23 + 24 + return postRouteThreadUri(); 25 + }); 10 26 11 27 function openThread(uri: string) { 12 - return navigate(buildThreadOverlayRoute(location.pathname, location.search, uri)); 13 - } 28 + if (drawerEnabled()) { 29 + return navigate(buildThreadOverlayRoute(location.pathname, location.search, uri)); 30 + } 14 31 15 - function closeThread() { 16 - return navigate(buildThreadOverlayRoute(location.pathname, location.search, null)); 32 + return navigate(buildPostRoute(uri)); 17 33 } 18 34 19 35 function buildThreadHref(uri: string | null) { 20 - return buildThreadOverlayRoute(location.pathname, location.search, uri); 36 + if (!uri) { 37 + return TIMELINE_ROUTE; 38 + } 39 + 40 + if (drawerEnabled()) { 41 + return buildThreadOverlayRoute(location.pathname, location.search, uri); 42 + } 43 + 44 + return buildPostRoute(uri); 21 45 } 22 46 23 - return { buildThreadHref, closeThread, openThread, threadUri }; 47 + function closeThread() { 48 + if (drawerEnabled() || getThreadOverlayUri(location.search)) { 49 + return navigate(buildThreadOverlayRoute(location.pathname, location.search, null)); 50 + } 51 + 52 + if (globalThis.history.length > 1) { 53 + return navigate(-1); 54 + } 55 + 56 + return navigate(TIMELINE_ROUTE); 57 + } 58 + 59 + return { buildThreadHref, closeThread, drawerEnabled, openThread, threadUri }; 24 60 }
+8 -13
src/components/profile/ProfilePanel.test.tsx
··· 17 17 const getProfileMock = vi.hoisted(() => vi.fn()); 18 18 const navigateMock = vi.hoisted(() => vi.fn()); 19 19 const unfollowActorMock = vi.hoisted(() => vi.fn()); 20 - const threadOverlayMock = vi.hoisted(() => ({ 21 - buildThreadHref: vi.fn(( 22 - uri: string | null, 23 - ) => (uri ? `/profile/bob.test?thread=${encodeURIComponent(uri)}` : "/profile/bob.test")), 24 - closeThread: vi.fn(), 25 - openThread: vi.fn(), 26 - threadUri: vi.fn(() => null), 20 + const postNavigationMock = vi.hoisted(() => ({ 21 + backFromPost: vi.fn(), 22 + buildPostHref: vi.fn((uri: string | null) => (uri ? `/post/${encodeURIComponent(uri)}` : "/timeline")), 23 + openPost: vi.fn(), 27 24 })); 28 25 29 26 vi.mock( ··· 52 49 ); 53 50 54 51 vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 55 - vi.mock( 56 - "$/components/posts/useThreadOverlayNavigation", 57 - () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 58 - ); 52 + vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 59 53 60 54 function deferred<T>() { 61 55 let resolve!: (value: T) => void; ··· 148 142 149 143 await waitFor(() => { 150 144 expect(followActorMock).toHaveBeenCalledWith("did:plc:bob"); 151 - expect(getHeroFollowingButton()).toBeDefined(); 152 - expect(screen.getAllByText("Following").length).toBeGreaterThanOrEqual(2); 153 145 expect(followersStat).toHaveTextContent("9"); 154 146 }); 147 + const heroFollowingButton = getHeroFollowingButton(); 148 + expect(heroFollowingButton).toBeDefined(); 149 + expect(heroFollowingButton?.textContent).toContain("Following"); 155 150 156 151 followRequest.resolve({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); 157 152
+4 -4
src/components/profile/ProfilePanel.tsx
··· 1 1 import { DiagnosticsPanel } from "$/components/deck/DiagnosticsPanel"; 2 2 import { usePostInteractions } from "$/components/posts/usePostInteractions"; 3 - import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 3 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 4 4 import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 5 5 import { useAppSession } from "$/contexts/app-session"; 6 6 import { ··· 43 43 export function ProfilePanel(props: { actor: string | null; embedded?: boolean }) { 44 44 const navigate = useNavigate(); 45 45 const session = useAppSession(); 46 - const threadOverlay = useThreadOverlayNavigation(); 46 + const postNavigation = usePostNavigation(); 47 47 const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); 48 48 const [heroHeight, setHeroHeight] = createSignal<number | null>(null); 49 49 let requestSequence = 0; ··· 72 72 const showCompactHeader = createMemo(() => state.scrollTop >= compactHeaderThreshold()); 73 73 const pinnedPostHref = createMemo(() => { 74 74 const uri = activeProfile()?.pinnedPost?.uri; 75 - return uri ? threadOverlay.buildThreadHref(uri) : null; 75 + return uri ? postNavigation.buildPostHref(uri) : null; 76 76 }); 77 77 const profileBadges = createMemo(() => { 78 78 const profile = activeProfile(); ··· 271 271 } 272 272 273 273 function openThread(uri: string) { 274 - void threadOverlay.openThread(uri); 274 + void postNavigation.openPost(uri); 275 275 } 276 276 277 277 function openExplorerTarget(target: string) {
+2 -5
src/components/saved/SavedPostsPanel.test.tsx
··· 8 8 const listSavedPostsMock = vi.hoisted(() => vi.fn()); 9 9 const syncPostsMock = vi.hoisted(() => vi.fn()); 10 10 const loggerErrorMock = vi.hoisted(() => vi.fn()); 11 - const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() })); 11 + const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() })); 12 12 13 13 vi.mock( 14 14 "$/lib/api/search", ··· 20 20 }, 21 21 }), 22 22 ); 23 - vi.mock( 24 - "$/components/posts/useThreadOverlayNavigation", 25 - () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 26 - ); 23 + vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 27 24 vi.mock("@tauri-apps/plugin-log", () => ({ error: loggerErrorMock })); 28 25 29 26 function createStatus(source: "bookmark" | "like", count: number) {
+5 -3
src/components/saved/SavedPostsPanel.tsx
··· 1 - import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 1 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 2 2 import { LocalPostResultsList, LocalPostResultsSkeletons } from "$/components/search/LocalPostResultsList"; 3 3 import { SearchEmptyState } from "$/components/search/SearchEmptyState"; 4 4 import { SearchQueryInput } from "$/components/search/SearchQueryInput"; ··· 16 16 import { Motion, Presence } from "solid-motionone"; 17 17 18 18 const PAGE_SIZE = 50; 19 + 19 20 const SEARCH_DEBOUNCE_MS = 300; 20 21 21 22 type TabKey = SavedPostSource; 23 + 22 24 type TabState = { 23 25 error: string | null; 24 26 items: LocalPostResult[]; ··· 116 118 117 119 export function SavedPostsPanel() { 118 120 const session = useAppSession(); 119 - const threadOverlay = useThreadOverlayNavigation(); 121 + const postNavigation = usePostNavigation(); 120 122 const [activeTab, setActiveTab] = createSignal<TabKey>("bookmark"); 121 123 const [state, setState] = createStore<SavedPanelState>(createPanelState()); 122 124 const browseRequestIds: Record<TabKey, number> = { bookmark: 0, like: 0 }; ··· 403 405 <SavedPostsViewport 404 406 activeTab={activeTab()} 405 407 browsingState={activeTabState()} 406 - onOpenThread={(uri) => void threadOverlay.openThread(uri)} 408 + onOpenThread={(uri) => void postNavigation.openPost(uri)} 407 409 query={trimmedQuery()} 408 410 searching={isSearching()} 409 411 searchingState={activeSearchState()}
+3 -6
src/components/search/HashtagPanel.test.tsx
··· 6 6 import { HashtagPanel } from "./HashtagPanel"; 7 7 8 8 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 9 - const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() })); 9 + const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() })); 10 10 11 11 vi.mock("$/lib/api/search", () => ({ SearchController: { searchPostsNetwork: searchPostsNetworkMock } })); 12 - vi.mock( 13 - "$/components/posts/useThreadOverlayNavigation", 14 - () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 15 - ); 12 + vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 16 13 vi.mock("@tauri-apps/plugin-log", () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() })); 17 14 18 15 async function flushRouter() { ··· 36 33 beforeEach(() => { 37 34 vi.useFakeTimers(); 38 35 searchPostsNetworkMock.mockReset(); 39 - threadOverlayMock.openThread.mockReset(); 36 + postNavigationMock.openPost.mockReset(); 40 37 searchPostsNetworkMock.mockResolvedValue({ posts: [] }); 41 38 }); 42 39
+3 -3
src/components/search/HashtagPanel.tsx
··· 1 1 import { PostCard } from "$/components/feeds/PostCard"; 2 - import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 3 3 import { Icon } from "$/components/shared/Icon"; 4 4 import { SearchController } from "$/lib/api/search"; 5 5 import type { NetworkSearchResult } from "$/lib/api/types/search"; ··· 34 34 const location = useLocation(); 35 35 const navigate = useNavigate(); 36 36 const params = useParams<{ hashtag: string }>(); 37 - const threadOverlay = useThreadOverlayNavigation(); 37 + const postNavigation = usePostNavigation(); 38 38 const [state, setState] = createStore<HashtagPanelState>({ 39 39 error: null, 40 40 hasSearched: false, ··· 105 105 </header> 106 106 107 107 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 108 - <Show when={state.loading} fallback={<HashtagState {...state} onOpenThread={threadOverlay.openThread} />}> 108 + <Show when={state.loading} fallback={<HashtagState {...state} onOpenThread={postNavigation.openPost} />}> 109 109 <div class="grid gap-2 py-1"> 110 110 <For each={Array.from({ length: 5 })}> 111 111 {() => <div class="h-40 animate-pulse rounded-3xl bg-white/4" aria-hidden="true" />}
+3 -6
src/components/search/SearchPanel.test.tsx
··· 11 11 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 12 12 const getSyncStatusMock = vi.hoisted(() => vi.fn()); 13 13 const syncPostsMock = vi.hoisted(() => vi.fn()); 14 - const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() })); 14 + const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() })); 15 15 16 16 vi.mock( 17 17 "$/lib/api/search", ··· 26 26 }), 27 27 ); 28 28 vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock })); 29 - vi.mock( 30 - "$/components/posts/useThreadOverlayNavigation", 31 - () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 32 - ); 29 + vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 33 30 34 31 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 35 32 ··· 60 57 searchPostsNetworkMock.mockReset(); 61 58 getSyncStatusMock.mockReset(); 62 59 syncPostsMock.mockReset(); 63 - threadOverlayMock.openThread.mockReset(); 60 + postNavigationMock.openPost.mockReset(); 64 61 65 62 getSyncStatusMock.mockResolvedValue([]); 66 63 searchActorSuggestionsMock.mockResolvedValue([]);
+37 -19
src/components/search/SearchPanel.tsx
··· 1 1 import { ActorSuggestionList, getActorSuggestionHeadline, useActorSuggestions } from "$/components/actors/actor-search"; 2 2 import { AvatarBadge } from "$/components/AvatarBadge"; 3 3 import { PostCard } from "$/components/feeds/PostCard"; 4 - import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 4 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 5 5 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 6 6 import { useAppPreferences } from "$/contexts/app-preferences"; 7 7 import { useAppSession } from "$/contexts/app-session"; ··· 36 36 import type { EmptyStateReason } from "./types"; 37 37 38 38 const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 39 + 39 40 const SEARCH_TABS: SearchTab[] = ["posts", "profiles"]; 40 41 41 42 type SearchPanelState = { ··· 52 53 type SearchPanelProps = { embedded?: boolean; initialMode?: SearchMode; initialQuery?: string }; 53 54 54 55 function ModeLabel(props: { mode: SearchMode }) { 56 + const text = createMemo(() => { 57 + switch (props.mode) { 58 + case "network": { 59 + return "Network"; 60 + } 61 + case "keyword": { 62 + return "Keyword"; 63 + } 64 + case "semantic": { 65 + return "Semantic"; 66 + } 67 + case "hybrid": { 68 + return "Hybrid"; 69 + } 70 + } 71 + }); 72 + 55 73 return ( 56 74 <span class="flex items-center gap-1.5"> 57 75 <SearchModeIcon mode={props.mode} class="text-base" /> 58 - <Switch> 59 - <Match when={props.mode === "network"}>Network</Match> 60 - <Match when={props.mode === "keyword"}>Keyword</Match> 61 - <Match when={props.mode === "semantic"}>Semantic</Match> 62 - <Match when={props.mode === "hybrid"}>Hybrid</Match> 63 - </Switch> 76 + {text()} 64 77 </span> 65 78 ); 66 79 } ··· 70 83 const navigate = useNavigate(); 71 84 const preferences = useAppPreferences(); 72 85 const session = useAppSession(); 73 - const threadOverlay = useThreadOverlayNavigation(); 86 + const postNavigation = usePostNavigation(); 74 87 const [search, setSearch] = createStore<SearchPanelState>({ 75 88 actorResults: null, 76 89 error: null, ··· 99 112 100 113 return parsed; 101 114 }); 115 + 102 116 const actorSuggestions = useActorSuggestions({ 103 117 container: () => actorSearchContainerRef, 104 118 disabled: () => routeState().tab !== "profiles", ··· 107 121 logger.warn("failed to load actor search suggestions", { keyValues: { error: normalizeError(error) } }), 108 122 value: () => routeState().q, 109 123 }); 124 + 110 125 const isActorTab = createMemo(() => routeState().tab === "profiles"); 111 126 const isLocalMode = createMemo(() => routeState().tab === "posts" && routeState().mode !== "network"); 112 127 const networkFiltersEnabled = createMemo(() => routeState().tab === "posts" && routeState().mode === "network"); 113 128 const semanticEnabled = createMemo(() => 114 129 !!preferences.embeddingsConfig?.enabled && !!preferences.embeddingsConfig?.downloaded 115 130 ); 131 + 116 132 const totalIndexedPosts = createMemo(() => 117 133 search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 118 134 ); 135 + 119 136 const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); 120 137 const lastSync = createMemo(() => { 121 138 const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; ··· 125 142 126 143 return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 127 144 }); 145 + 128 146 const cycleModes = createMemo(() => 129 147 MODES.filter((candidate) => semanticEnabled() || (candidate !== "semantic" && candidate !== "hybrid")) 130 148 ); ··· 416 434 localResults={search.results} 417 435 networkResults={search.networkResults} 418 436 onOpenActor={openActor} 419 - onOpenThread={(uri) => void threadOverlay.openThread(uri)} 437 + onOpenThread={(uri) => void postNavigation.openPost(uri)} 420 438 query={routeState().q} /> 421 439 </section> 422 440 ··· 599 617 ); 600 618 } 601 619 602 - function ResultMeta( 603 - props: { 604 - hasSearched: boolean; 605 - isActorTab: boolean; 606 - lastSync: string | null; 607 - mode: SearchMode; 608 - resultCount: number; 609 - totalIndexedPosts: number; 610 - }, 611 - ) { 620 + type ResultMetaProps = { 621 + hasSearched: boolean; 622 + isActorTab: boolean; 623 + lastSync: string | null; 624 + mode: SearchMode; 625 + resultCount: number; 626 + totalIndexedPosts: number; 627 + }; 628 + 629 + function ResultMeta(props: ResultMetaProps) { 612 630 return ( 613 631 <div class="flex items-center justify-between gap-4 border-t border-white/5 pt-3"> 614 632 <span class="text-sm text-on-surface-variant">
+56 -16
src/components/shared/QuotedPostPreview.tsx
··· 3 3 import { formatHandle } from "$/lib/utils/text"; 4 4 import { createMemo, Show } from "solid-js"; 5 5 6 - export function QuotedPostPreview( 7 - props: { author: ProfileViewBasic | null; class?: string; href?: string | null; text?: unknown; title: string }, 8 - ) { 6 + function QuotedText(props: { text: string; truncated: boolean }) { 7 + return ( 8 + <Show 9 + when={props.truncated} 10 + fallback={ 11 + <p class="mt-2 whitespace-pre-wrap wrap-break-word text-sm leading-[1.55] text-on-secondary-container"> 12 + {props.text} 13 + </p> 14 + }> 15 + <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{props.text}</p> 16 + </Show> 17 + ); 18 + } 19 + 20 + type QuotedPostPreviewProps = { 21 + author: ProfileViewBasic | null; 22 + class?: string; 23 + href?: string | null; 24 + onOpenPost?: () => void; 25 + text?: unknown; 26 + title: string; 27 + truncate?: boolean; 28 + }; 29 + 30 + export function QuotedPostPreview(props: QuotedPostPreviewProps) { 9 31 const preview = createMemo(() => (typeof props.text === "string" ? props.text : "")); 32 + const openInNewTab = createMemo(() => !!props.href && /^https?:\/\//i.test(props.href)); 33 + const truncated = createMemo(() => props.truncate ?? false); 10 34 11 35 return ( 12 36 <div class={props.class ?? "rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"}> 13 37 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 14 - <Show when={props.href} fallback={<QuotedPreviewContent author={props.author} preview={preview()} />}> 15 - {(href) => ( 16 - <a 17 - class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-white/4" 18 - href={href()} 19 - rel="noreferrer" 20 - target="_blank" 21 - onClick={(event) => event.stopPropagation()}> 22 - <QuotedPreviewContent author={props.author} preview={preview()} /> 23 - </a> 24 - )} 38 + <Show 39 + when={props.onOpenPost} 40 + fallback={ 41 + <Show 42 + when={props.href} 43 + fallback={<QuotedPreviewContent author={props.author} preview={preview()} truncated={truncated()} />}> 44 + {(href) => ( 45 + <a 46 + class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-white/4" 47 + href={href()} 48 + rel={openInNewTab() ? "noreferrer" : undefined} 49 + target={openInNewTab() ? "_blank" : undefined} 50 + onClick={(event) => event.stopPropagation()}> 51 + <QuotedPreviewContent author={props.author} preview={preview()} truncated={truncated()} /> 52 + </a> 53 + )} 54 + </Show> 55 + }> 56 + <button 57 + class="mt-2 block w-full rounded-xl border-0 bg-transparent px-1 py-1 text-left text-inherit transition duration-150 ease-out hover:bg-white/4" 58 + type="button" 59 + onClick={(event) => { 60 + event.stopPropagation(); 61 + props.onOpenPost?.(); 62 + }}> 63 + <QuotedPreviewContent author={props.author} preview={preview()} truncated={truncated()} /> 64 + </button> 25 65 </Show> 26 66 </div> 27 67 ); 28 68 } 29 69 30 - function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview: string }) { 70 + function QuotedPreviewContent(props: { author: ProfileViewBasic | null; preview: string; truncated: boolean }) { 31 71 return ( 32 72 <> 33 73 <Show when={props.author}> ··· 43 83 <Show 44 84 when={props.preview} 45 85 fallback={<p class="mt-2 text-sm leading-[1.55] text-on-surface-variant">Quoted post</p>}> 46 - {(text) => <p class="mt-2 line-clamp-4 text-sm leading-[1.55] text-on-secondary-container">{text()}</p>} 86 + {(text) => <QuotedText text={text()} truncated={props.truncated} />} 47 87 </Show> 48 88 </> 49 89 );
+17
src/lib/feeds.ts
··· 198 198 return !!asRecord(record?.reply); 199 199 } 200 200 201 + export function hasKnownThreadContext(post: PostView, item?: FeedViewPost) { 202 + if (item && isReplyItem(item)) { 203 + return true; 204 + } 205 + 206 + if (asRecord(asRecord(post.record)?.reply)) { 207 + return true; 208 + } 209 + 210 + return typeof post.replyCount === "number" && post.replyCount > 0; 211 + } 212 + 201 213 function isReplyByUnfollowed(item: FeedViewPost) { 202 214 return isReplyItem(item) && !item.post.author.viewer?.following; 203 215 } ··· 307 319 308 320 export function getQuotedAuthor(embed: Maybe<EmbedView>) { 309 321 return getQuotedRecord(embed)?.author ?? null; 322 + } 323 + 324 + export function getQuotedUri(embed: Maybe<EmbedView>) { 325 + const uri = getQuotedRecord(embed)?.uri; 326 + return typeof uri === "string" && uri.trim() ? uri : null; 310 327 } 311 328 312 329 export function getQuotedHref(embed: Maybe<EmbedView>) {
+30
src/lib/post-routes.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { buildPostRoute, decodePostRouteUri, isThreadDrawerPath } from "./post-routes"; 3 + 4 + describe("post-routes", () => { 5 + it("builds a canonical encoded post route", () => { 6 + const uri = "at://did:plc:alice/app.bsky.feed.post/abc123"; 7 + expect(buildPostRoute(uri)).toBe("/post/at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123"); 8 + }); 9 + 10 + it("decodes valid encoded at:// URIs", () => { 11 + expect(decodePostRouteUri("at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Fabc123")).toBe( 12 + "at://did:plc:alice/app.bsky.feed.post/abc123", 13 + ); 14 + }); 15 + 16 + it("rejects malformed or non-at URI values", () => { 17 + expect(decodePostRouteUri("not-a-uri")).toBeNull(); 18 + expect(decodePostRouteUri("%E0%A4%A")).toBeNull(); 19 + expect(decodePostRouteUri(null)).toBeNull(); 20 + expect(decodePostRouteUri()).toBeNull(); 21 + }); 22 + 23 + it("marks only feed, notifications, and deck as drawer routes", () => { 24 + expect(isThreadDrawerPath("/timeline")).toBe(true); 25 + expect(isThreadDrawerPath("/notifications")).toBe(true); 26 + expect(isThreadDrawerPath("/deck")).toBe(true); 27 + expect(isThreadDrawerPath("/profile/alice")).toBe(false); 28 + expect(isThreadDrawerPath("/search")).toBe(false); 29 + }); 30 + });
+23
src/lib/post-routes.ts
··· 1 + export const POST_ROUTE = "/post"; 2 + export const THREAD_DRAWER_PATHS = ["/timeline", "/notifications", "/deck"] as const; 3 + 4 + export function buildPostRoute(uri: string) { 5 + return `${POST_ROUTE}/${encodeURIComponent(uri)}`; 6 + } 7 + 8 + export function isThreadDrawerPath(pathname: string): pathname is (typeof THREAD_DRAWER_PATHS)[number] { 9 + return (THREAD_DRAWER_PATHS as readonly string[]).includes(pathname); 10 + } 11 + 12 + export function decodePostRouteUri(value?: string | null) { 13 + if (!value) { 14 + return null; 15 + } 16 + 17 + try { 18 + const decoded = decodeURIComponent(value); 19 + return decoded.startsWith("at://") ? decoded : null; 20 + } catch { 21 + return null; 22 + } 23 + }
+13 -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 renderPost = vi.fn((props: { uri: string | null }) => <div data-testid="post-view">{props.uri ?? "none"}</div>); 34 35 const renderProfile = vi.fn((props: { actor: string | null }) => ( 35 36 <div data-testid="profile-view"> 36 37 <span>{props.actor ?? "self-profile"}</span> ··· 55 56 renderComposer={renderComposer} 56 57 renderMessages={renderMessages} 57 58 renderNotifications={renderNotifications} 59 + renderPost={renderPost} 58 60 renderProfile={renderProfile} 59 61 renderShell={Shell} 60 62 renderTimeline={renderTimeline} /> 61 63 </AppTestProviders> 62 64 )); 63 65 64 - return { renderComposer, renderMessages, renderNotifications, renderProfile, renderTimeline }; 66 + return { renderComposer, renderMessages, renderNotifications, renderPost, renderProfile, renderTimeline }; 65 67 } 66 68 67 69 describe("AppRouter", () => { ··· 105 107 106 108 expect(renderNotifications).toHaveBeenCalledOnce(); 107 109 expect(screen.getByText("notifications")).toBeInTheDocument(); 110 + expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 111 + }); 112 + 113 + it("renders decoded post routes inside the protected shell", async () => { 114 + const uri = "at://did:plc:alice/app.bsky.feed.post/123"; 115 + const { renderPost } = renderRouter(`#/post/${encodeURIComponent(uri)}`); 116 + 117 + await screen.findByTestId("post-view"); 118 + 119 + expect(renderPost.mock.lastCall?.[0].uri).toBe(uri); 108 120 expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 109 121 }); 110 122
+14
src/router.tsx
··· 14 14 import { SettingsPanel } from "./components/settings/SettingsPanel"; 15 15 import { decodeMessagesRouteMemberDid } from "./lib/conversations"; 16 16 import { TIMELINE_ROUTE } from "./lib/feeds"; 17 + import { decodePostRouteUri } from "./lib/post-routes"; 17 18 import { decodeProfileRouteActor } from "./lib/profile"; 18 19 import { buildSearchPreflightRoute, decodeHashtagRouteTag, parseSearchRouteState } from "./lib/search-routes"; 19 20 20 21 type TMessagesRouteProps = { memberDid: string | null }; 22 + type TPostRouteProps = { uri: string | null }; 21 23 type TProfileRouteProps = { actor: string | null }; 22 24 23 25 type AppShellProps = ParentProps<{ fullWidth?: boolean }>; ··· 27 29 renderComposer: () => JSX.Element; 28 30 renderMessages: Component<TMessagesRouteProps>; 29 31 renderNotifications: () => JSX.Element; 32 + renderPost: Component<TPostRouteProps>; 30 33 renderProfile: Component<TProfileRouteProps>; 31 34 renderShell: Component<AppShellProps>; 32 35 renderTimeline: () => JSX.Element; ··· 100 103 101 104 const NotificationsRoute = () => <ProtectedRouteView>{props.renderNotifications()}</ProtectedRouteView>; 102 105 106 + const PostRoute = () => { 107 + const params = useParams<{ encodedUri: string }>(); 108 + 109 + return ( 110 + <ProtectedRouteView> 111 + <Dynamic component={props.renderPost} uri={decodePostRouteUri(params.encodedUri)} /> 112 + </ProtectedRouteView> 113 + ); 114 + }; 115 + 103 116 const HashtagRoute = () => { 104 117 const params = useParams<{ hashtag: string }>(); 105 118 const tag = decodeHashtagRouteTag(params.hashtag); ··· 174 187 <Route path="/hashtag/:hashtag" component={HashtagRoute} /> 175 188 <Route path="/saved" component={SavedPostsRoute} /> 176 189 <Route path="/notifications" component={NotificationsRoute} /> 190 + <Route path="/post/:encodedUri" component={PostRoute} /> 177 191 <Route path="/messages" component={MessagesRoute} /> 178 192 <Route path="/messages/:memberDid" component={MemberMessagesRoute} /> 179 193 <Route path="/deck" component={DeckRoute} />