BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
at main 415 lines 15 kB view raw
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}