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