BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { Icon } from "$/components/shared/Icon";
2import { useAppSession } from "$/contexts/app-session";
3import { FeedController } from "$/lib/api/feeds";
4import { findRootPost, isBlockedNode, isNotFoundNode, isThreadViewPost, patchThreadNode } from "$/lib/feeds";
5import { useNavigationHistory } from "$/lib/navigation-history";
6import type { PostView, ThreadNode } from "$/lib/types";
7import { createEffect, createMemo, For, Match, onCleanup, Show, splitProps, Switch } from "solid-js";
8import { createStore } from "solid-js/store";
9import { Motion, Presence } from "solid-motionone";
10import { PostCard } from "../feeds/PostCard";
11import { HistoryControls } from "../shared/HistoryControls";
12import { usePostInteractions } from "./hooks/usePostInteractions";
13import { usePostNavigation } from "./hooks/usePostNavigation";
14import { useThreadOverlayNavigation } from "./hooks/useThreadOverlayNavigation";
15
16type ThreadDrawerState = { error: string | null; loading: boolean; thread: ThreadNode | null; uri: string | null };
17
18function createThreadDrawerState(): ThreadDrawerState {
19 return { error: null, loading: false, thread: null, uri: null };
20}
21
22function findParentUri(node: ThreadNode | null, targetUri: string | null): string | null {
23 if (!node || !targetUri) {
24 return null;
25 }
26
27 const visited = new Set<ThreadNode>();
28
29 function walk(current: ThreadNode): string | null {
30 if (visited.has(current)) {
31 return null;
32 }
33
34 visited.add(current);
35
36 if (isThreadViewPost(current)) {
37 if (current.post.uri === targetUri && current.parent && isThreadViewPost(current.parent)) {
38 return current.parent.post.uri;
39 }
40
41 if (current.parent) {
42 const parentMatch = walk(current.parent);
43 if (parentMatch) {
44 return parentMatch;
45 }
46 }
47
48 for (const reply of current.replies ?? []) {
49 const replyMatch = walk(reply);
50 if (replyMatch) {
51 return replyMatch;
52 }
53 }
54 }
55
56 return null;
57 }
58
59 return walk(node);
60}
61
62function createEscapeKeyHandler(onClose: () => void) {
63 return (event: KeyboardEvent) => {
64 if (event.key !== "Escape") {
65 return;
66 }
67
68 event.preventDefault();
69 onClose();
70 };
71}
72
73type ThreadDrawerBodyProps = {
74 activeUri: string | null;
75 bookmarkPendingByUri: Record<string, boolean>;
76 error: string | null;
77 likePendingByUri: Record<string, boolean>;
78 loading: boolean;
79 onBookmark: (post: PostView) => void;
80 onLike: (post: PostView) => void;
81 onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void;
82 onOpenThread: (uri: string) => void;
83 onRepost: (post: PostView) => void;
84 repostPendingByUri: Record<string, boolean>;
85 rootPost: PostView | null;
86 thread: ThreadNode | null;
87};
88
89function ThreadDrawerBody(props: ThreadDrawerBodyProps) {
90 return (
91 <div class="min-h-0 overflow-y-auto overscroll-contain pb-1">
92 <ThreadDrawerLoading loading={props.loading} />
93
94 <Show when={!props.loading && props.error}>
95 {(message) => (
96 <div class="rounded-3xl bg-error-surface p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(180,35,24,0.2)]">
97 {message()}
98 </div>
99 )}
100 </Show>
101
102 <Show when={!props.loading && props.thread && !props.error && props.rootPost}>
103 {(root) => (
104 <div class="grid gap-4">
105 <ThreadNodeView
106 activeUri={props.activeUri}
107 bookmarkPendingByUri={props.bookmarkPendingByUri}
108 likePendingByUri={props.likePendingByUri}
109 node={props.thread!}
110 onBookmark={props.onBookmark}
111 onLike={props.onLike}
112 onOpenEngagement={props.onOpenEngagement}
113 onOpenThread={props.onOpenThread}
114 onRepost={props.onRepost}
115 repostPendingByUri={props.repostPendingByUri}
116 rootPost={root()} />
117 </div>
118 )}
119 </Show>
120 </div>
121 );
122}
123
124type ThreadDrawerHeaderProps = {
125 activeUri: string | null;
126 canGoBack: boolean;
127 canGoForward: boolean;
128 onClose: () => void;
129 onGoBack: () => void;
130 onGoForward: () => void;
131 onMaximize: (uri: string) => void;
132 parentThreadHref: string | null;
133};
134
135function ThreadDrawerHeader(props: ThreadDrawerHeaderProps) {
136 const [local, historyControls] = splitProps(props, ["parentThreadHref", "activeUri", "onClose", "onMaximize"]);
137 return (
138 <header class="sticky top-0 z-10 mb-4 flex items-center gap-3 rounded-3xl bg-surface-container-high px-4 py-3 shadow-(--inset-shadow)">
139 <div class="min-w-0 flex-1">
140 <p class="m-0 text-base font-semibold text-on-surface">Thread!</p>
141 <Show when={local.parentThreadHref}>
142 {(href) => (
143 <a
144 class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant no-underline transition hover:text-primary hover:underline"
145 href={`#${href()}`}>
146 Parent post
147 </a>
148 )}
149 </Show>
150 </div>
151 <div class="flex items-center gap-2 flex-1 justify-end">
152 <HistoryControls {...historyControls} />
153 <Show when={local.activeUri}>
154 {(uri) => (
155 <button
156 aria-label="Open full post"
157 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-surface-bright hover:text-on-surface"
158 type="button"
159 onClick={() => local.onMaximize(uri())}>
160 <Icon aria-hidden="true" iconClass="i-ri-external-link-line" />
161 </button>
162 )}
163 </Show>
164 <button
165 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-surface-bright hover:text-on-surface"
166 type="button"
167 onClick={() => local.onClose()}>
168 <Icon aria-hidden="true" iconClass="i-ri-close-line" />
169 </button>
170 </div>
171 </header>
172 );
173}
174
175function ThreadDrawerLoading(props: { loading: boolean }) {
176 return (
177 <Show when={props.loading}>
178 <div class="grid gap-3">
179 <ThreadSkeletonCard />
180 <ThreadSkeletonCard />
181 </div>
182 </Show>
183 );
184}
185
186function ThreadNodeView(
187 props: {
188 activeUri: string | null;
189 bookmarkPendingByUri: Record<string, boolean>;
190 likePendingByUri: Record<string, boolean>;
191 node: ThreadNode;
192 onBookmark: (post: PostView) => void;
193 onLike: (post: PostView) => void;
194 onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void;
195 onOpenThread: (uri: string) => void;
196 onRepost: (post: PostView) => void;
197 repostPendingByUri: Record<string, boolean>;
198 rootPost: PostView;
199 },
200) {
201 const node = createMemo(() => (isThreadViewPost(props.node) ? props.node : null));
202
203 return (
204 <Switch>
205 <Match when={isBlockedNode(props.node)}>
206 <ThreadStateCard label="Blocked post" meta={isBlockedNode(props.node) ? props.node.uri : ""} />
207 </Match>
208 <Match when={isNotFoundNode(props.node)}>
209 <ThreadStateCard label="Post not found" meta={isNotFoundNode(props.node) ? props.node.uri : ""} />
210 </Match>
211 <Match when={node()}>
212 {(threadNode) => (
213 <div class="grid gap-4">
214 <Show when={threadNode().parent}>
215 {(parent) => (
216 <div class="tone-muted rounded-3xl p-3 shadow-(--inset-shadow)">
217 <ThreadNodeView
218 activeUri={props.activeUri}
219 bookmarkPendingByUri={props.bookmarkPendingByUri}
220 likePendingByUri={props.likePendingByUri}
221 node={parent()}
222 onBookmark={props.onBookmark}
223 onLike={props.onLike}
224 onOpenEngagement={props.onOpenEngagement}
225 onOpenThread={props.onOpenThread}
226 onRepost={props.onRepost}
227 repostPendingByUri={props.repostPendingByUri}
228 rootPost={props.rootPost} />
229 </div>
230 )}
231 </Show>
232
233 <PostCard
234 bookmarkPending={!!props.bookmarkPendingByUri[threadNode().post.uri]}
235 focused={threadNode().post.uri === props.activeUri}
236 likePending={!!props.likePendingByUri[threadNode().post.uri]}
237 onBookmark={() => props.onBookmark(threadNode().post)}
238 onLike={() => props.onLike(threadNode().post)}
239 onOpenEngagement={(tab) => props.onOpenEngagement(threadNode().post.uri, tab)}
240 onOpenThread={() => props.onOpenThread(threadNode().post.uri)}
241 onRepost={() => props.onRepost(threadNode().post)}
242 post={threadNode().post}
243 repostPending={!!props.repostPendingByUri[threadNode().post.uri]} />
244
245 <Show when={threadNode().replies?.length}>
246 <div class="tone-muted grid gap-4 rounded-3xl p-3 shadow-(--inset-shadow)">
247 <For each={threadNode().replies}>
248 {(reply) => (
249 <ThreadNodeView
250 activeUri={props.activeUri}
251 bookmarkPendingByUri={props.bookmarkPendingByUri}
252 likePendingByUri={props.likePendingByUri}
253 node={reply}
254 onBookmark={props.onBookmark}
255 onLike={props.onLike}
256 onOpenEngagement={props.onOpenEngagement}
257 onOpenThread={props.onOpenThread}
258 onRepost={props.onRepost}
259 repostPendingByUri={props.repostPendingByUri}
260 rootPost={props.rootPost} />
261 )}
262 </For>
263 </div>
264 </Show>
265 </div>
266 )}
267 </Match>
268 </Switch>
269 );
270}
271
272function ThreadStateCard(props: { label: string; meta: string }) {
273 return (
274 <div class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)">
275 <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p>
276 <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p>
277 </div>
278 );
279}
280
281function ThreadSkeletonCard() {
282 return (
283 <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)">
284 <div class="flex gap-3">
285 <div class="skeleton-block h-11 w-11 rounded-full" />
286 <div class="min-w-0 flex-1">
287 <div class="skeleton-block h-4 w-40 rounded-full" />
288 <div class="mt-3 grid gap-2">
289 <div class="skeleton-block h-3.5 w-full rounded-full" />
290 <div class="skeleton-block h-3.5 w-[82%] rounded-full" />
291 <div class="skeleton-block h-3.5 w-[68%] rounded-full" />
292 </div>
293 </div>
294 </div>
295 </div>
296 );
297}
298
299export function ThreadDrawer() {
300 const session = useAppSession();
301 const postNavigation = usePostNavigation();
302 const threadOverlay = useThreadOverlayNavigation();
303 const history = useNavigationHistory();
304 const [state, setState] = createStore<ThreadDrawerState>(createThreadDrawerState());
305 const activeUri = createMemo(() => (threadOverlay.drawerEnabled() ? threadOverlay.threadUri() : null));
306 const rootPost = createMemo(() => findRootPost(state.thread));
307 const parentThreadUri = createMemo(() => findParentUri(state.thread, activeUri()));
308 const parentThreadHref = createMemo(() =>
309 parentThreadUri() ? threadOverlay.buildThreadHref(parentThreadUri()) : null
310 );
311 const interactions = usePostInteractions({
312 onError: session.reportError,
313 patchPost(uri, updater) {
314 const current = state.thread;
315 if (!current) {
316 return;
317 }
318
319 setState("thread", patchThreadNode(current, uri, updater));
320 },
321 });
322
323 createEffect(() => {
324 const uri = activeUri();
325 if (!uri) {
326 if (state.uri || state.thread || state.error || state.loading) {
327 setState(createThreadDrawerState());
328 }
329 return;
330 }
331
332 if (state.uri === uri && (state.loading || state.thread || state.error)) {
333 return;
334 }
335
336 void loadThread(uri);
337 });
338
339 createEffect(() => {
340 if (!activeUri()) {
341 return;
342 }
343
344 const handleKeyDown = createEscapeKeyHandler(() => {
345 void threadOverlay.closeThread();
346 });
347
348 globalThis.addEventListener("keydown", handleKeyDown);
349 onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown));
350 });
351
352 async function loadThread(uri: string) {
353 setState({ error: null, loading: true, thread: null, uri });
354
355 try {
356 const payload = await FeedController.getPostThread(uri);
357 if (activeUri() === uri) {
358 setState({ error: null, loading: false, thread: payload.thread, uri });
359 }
360 } catch (error) {
361 if (activeUri() === uri) {
362 setState({ error: String(error), loading: false, thread: null, uri });
363 }
364 session.reportError(`Failed to open thread: ${String(error)}`);
365 }
366 }
367
368 return (
369 <Presence>
370 <Show when={activeUri()}>
371 <div class="fixed inset-0 z-50">
372 <Motion.button
373 class="ui-scrim absolute inset-0 border-0 backdrop-blur-xl"
374 type="button"
375 aria-label="Close thread"
376 initial={{ opacity: 0 }}
377 animate={{ opacity: 1 }}
378 exit={{ opacity: 0 }}
379 transition={{ duration: 0.2 }}
380 onClick={() => void threadOverlay.closeThread()} />
381 <Motion.aside
382 class="absolute inset-y-0 right-0 grid w-full max-w-136 grid-rows-[auto_minmax(0,1fr)] overflow-hidden bg-surface-container-highest px-5 pb-6 pt-5 shadow-[-28px_0_50px_rgba(0,0,0,0.24)] backdrop-blur-[22px]"
383 initial={{ opacity: 0, x: 30 }}
384 animate={{ opacity: 1, x: 0 }}
385 exit={{ opacity: 0, x: 36 }}
386 transition={{ duration: 0.22 }}>
387 <ThreadDrawerHeader
388 activeUri={activeUri()}
389 canGoBack={history.canGoBack()}
390 canGoForward={history.canGoForward()}
391 onGoBack={history.goBack}
392 onGoForward={history.goForward}
393 onMaximize={(uri) => void postNavigation.openPostScreen(uri)}
394 parentThreadHref={parentThreadHref()}
395 onClose={() => void threadOverlay.closeThread()} />
396 <ThreadDrawerBody
397 activeUri={activeUri()}
398 bookmarkPendingByUri={interactions.bookmarkPendingByUri()}
399 error={state.error}
400 likePendingByUri={interactions.likePendingByUri()}
401 loading={state.loading}
402 onBookmark={(post) => void interactions.toggleBookmark(post)}
403 onLike={(post) => void interactions.toggleLike(post)}
404 onOpenEngagement={(uri, tab) => void postNavigation.openPostEngagement(uri, tab)}
405 onOpenThread={(uri) => void threadOverlay.openThread(uri)}
406 onRepost={(post) => void interactions.toggleRepost(post)}
407 repostPendingByUri={interactions.repostPendingByUri()}
408 rootPost={rootPost()}
409 thread={state.thread} />
410 </Motion.aside>
411 </div>
412 </Show>
413 </Presence>
414 );
415}