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.

at main 416 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, 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}