BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
at main 361 lines 13 kB view raw
1import { PostCard } from "$/components/feeds/PostCard"; 2import { Icon } from "$/components/shared/Icon"; 3import { useAppSession } from "$/contexts/app-session"; 4import { FeedController } from "$/lib/api/feeds"; 5import { patchThreadNode } from "$/lib/feeds"; 6import { isBlockedNode, isNotFoundNode, isThreadViewPost } from "$/lib/feeds/type-guards"; 7import type { PostView, ThreadNode, ThreadViewPost } from "$/lib/types"; 8import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 9import { createStore } from "solid-js/store"; 10import { usePostInteractions } from "./hooks/usePostInteractions"; 11import { usePostNavigation } from "./hooks/usePostNavigation"; 12 13type PostPanelState = { error: string | null; loading: boolean; thread: ThreadNode | null; uri: string | null }; 14 15function createPostPanelState(): PostPanelState { 16 return { error: null, loading: false, thread: null, uri: null }; 17} 18 19function findThreadPost(node: ThreadNode | null | undefined, uri: string): ThreadViewPost | null { 20 if (!node || !isThreadViewPost(node)) { 21 return null; 22 } 23 24 if (node.post.uri === uri) { 25 return node; 26 } 27 28 const parentMatch = findThreadPost(node.parent, uri); 29 if (parentMatch) { 30 return parentMatch; 31 } 32 33 for (const reply of node.replies ?? []) { 34 const replyMatch = findThreadPost(reply, uri); 35 if (replyMatch) { 36 return replyMatch; 37 } 38 } 39 40 return null; 41} 42 43function collectParentChain(node: ThreadViewPost | null): ThreadViewPost[] { 44 if (!node) { 45 return []; 46 } 47 48 const chain: ThreadViewPost[] = []; 49 let current: ThreadNode | null | undefined = node.parent; 50 while (current && isThreadViewPost(current)) { 51 chain.unshift(current); 52 current = current.parent; 53 } 54 55 return chain; 56} 57 58export function PostPanel(props: { uri: string | null }) { 59 const session = useAppSession(); 60 const postNavigation = usePostNavigation(); 61 const [state, setState] = createStore<PostPanelState>(createPostPanelState()); 62 let requestId = 0; 63 const interactions = usePostInteractions({ 64 onError: session.reportError, 65 patchPost(uri, updater) { 66 const current = state.thread; 67 if (!current) { 68 return; 69 } 70 71 setState("thread", patchThreadNode(current, uri, updater)); 72 }, 73 }); 74 75 const focusedNode = createMemo(() => { 76 const uri = props.uri; 77 const thread = state.thread; 78 if (!uri || !thread) { 79 return null; 80 } 81 82 return findThreadPost(thread, uri); 83 }); 84 const parentChain = createMemo(() => collectParentChain(focusedNode())); 85 const parentPostUri = createMemo(() => { 86 const focused = focusedNode(); 87 if (!focused || !focused.parent || !isThreadViewPost(focused.parent)) { 88 return null; 89 } 90 91 return focused.parent.post.uri; 92 }); 93 94 createEffect(() => { 95 const uri = props.uri; 96 if (!uri) { 97 setState(createPostPanelState()); 98 return; 99 } 100 101 if (state.uri === uri && (state.loading || state.thread || state.error)) { 102 return; 103 } 104 105 const nextRequestId = ++requestId; 106 void loadThread(uri, nextRequestId); 107 }); 108 109 async function loadThread(uri: string, nextRequestId: number) { 110 setState({ error: null, loading: true, thread: null, uri }); 111 112 try { 113 const payload = await FeedController.getPostThread(uri); 114 if (nextRequestId !== requestId || props.uri !== uri) { 115 return; 116 } 117 118 setState({ error: null, loading: false, thread: payload.thread, uri }); 119 } catch (error) { 120 if (nextRequestId !== requestId || props.uri !== uri) { 121 return; 122 } 123 124 setState({ error: String(error), loading: false, thread: null, uri }); 125 session.reportError(`Failed to open post: ${String(error)}`); 126 } 127 } 128 129 return ( 130 <section class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)"> 131 <header class="sticky top-0 z-20 flex items-center justify-between gap-3 bg-surface-container-high px-6 pb-4 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_var(--outline-subtle)] max-[760px]:px-4 max-[520px]:px-3"> 132 <div class="min-w-0"> 133 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post</p> 134 <Show when={parentPostUri()}> 135 {(parentUri) => ( 136 <a 137 class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant no-underline transition hover:text-primary hover:underline" 138 href={`#${postNavigation.buildPostHref(parentUri())}`}> 139 Parent post 140 </a> 141 )} 142 </Show> 143 </div> 144 <button 145 type="button" 146 class="ui-control ui-control-hoverable inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm text-on-surface" 147 onClick={() => void postNavigation.backFromPost()}> 148 <Icon aria-hidden="true" iconClass="i-ri-arrow-left-line" /> 149 Back 150 </button> 151 </header> 152 153 <div class="min-h-0 overflow-y-auto overscroll-contain px-3 pb-4 pt-3"> 154 <Show 155 when={props.uri} 156 fallback={<PostPanelMessage body="This post link is invalid." title="Post unavailable" />}> 157 <ThreadState 158 bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 159 error={state.error} 160 focusedNode={focusedNode()} 161 likePendingByUri={interactions.likePendingByUri()} 162 loading={state.loading} 163 onBookmark={(post) => void interactions.toggleBookmark(post)} 164 onLike={(post) => void interactions.toggleLike(post)} 165 onOpenEngagement={(uri, tab) => void postNavigation.openPostEngagement(uri, tab)} 166 onOpenPost={(uri) => void postNavigation.openPost(uri)} 167 onRepost={(post) => void interactions.toggleRepost(post)} 168 parentChain={parentChain()} 169 repostPendingByUri={interactions.repostPendingByUri()} /> 170 </Show> 171 </div> 172 </section> 173 ); 174} 175 176function ThreadState( 177 props: { 178 bookmarkPendingByUri: Record<string, boolean>; 179 error: string | null; 180 focusedNode: ThreadViewPost | null; 181 likePendingByUri: Record<string, boolean>; 182 loading: boolean; 183 onBookmark: (post: PostView) => void; 184 onLike: (post: PostView) => void; 185 onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; 186 onOpenPost: (uri: string) => void; 187 onRepost: (post: PostView) => void; 188 parentChain: ThreadViewPost[]; 189 repostPendingByUri: Record<string, boolean>; 190 }, 191) { 192 return ( 193 <> 194 <Show when={props.loading}> 195 <div class="grid gap-3"> 196 <SkeletonPostCard /> 197 <SkeletonPostCard /> 198 </div> 199 </Show> 200 201 <Show when={!props.loading && props.error}> 202 {(message) => <PostPanelMessage body={message()} title="Couldn't load this post" />} 203 </Show> 204 205 <Show when={!props.loading && !props.error && props.focusedNode}> 206 {(focused) => ( 207 <div class="grid gap-3"> 208 <For each={props.parentChain}> 209 {(parent) => ( 210 <div class="tone-muted rounded-3xl p-3 shadow-(--inset-shadow)"> 211 <PostCard 212 bookmarkPending={!!props.bookmarkPendingByUri[parent.post.uri]} 213 likePending={!!props.likePendingByUri[parent.post.uri]} 214 onBookmark={() => props.onBookmark(parent.post)} 215 onLike={() => props.onLike(parent.post)} 216 onOpenEngagement={(tab) => props.onOpenEngagement(parent.post.uri, tab)} 217 onOpenThread={() => props.onOpenPost(parent.post.uri)} 218 onRepost={() => props.onRepost(parent.post)} 219 post={parent.post} 220 repostPending={!!props.repostPendingByUri[parent.post.uri]} 221 showActions={false} /> 222 </div> 223 )} 224 </For> 225 226 <PostCard 227 bookmarkPending={!!props.bookmarkPendingByUri[focused().post.uri]} 228 focused 229 likePending={!!props.likePendingByUri[focused().post.uri]} 230 onBookmark={() => props.onBookmark(focused().post)} 231 onLike={() => props.onLike(focused().post)} 232 onOpenEngagement={(tab) => props.onOpenEngagement(focused().post.uri, tab)} 233 onOpenThread={() => props.onOpenPost(focused().post.uri)} 234 onRepost={() => props.onRepost(focused().post)} 235 post={focused().post} 236 repostPending={!!props.repostPendingByUri[focused().post.uri]} /> 237 238 <Show when={focused().replies?.length}> 239 <div class="tone-muted grid gap-3 rounded-3xl p-3 shadow-(--inset-shadow)"> 240 <For each={focused().replies}> 241 {(reply) => ( 242 <ThreadReplies 243 bookmarkPendingByUri={props.bookmarkPendingByUri} 244 likePendingByUri={props.likePendingByUri} 245 node={reply} 246 onBookmark={props.onBookmark} 247 onLike={props.onLike} 248 onOpenEngagement={props.onOpenEngagement} 249 onOpenPost={props.onOpenPost} 250 onRepost={props.onRepost} 251 repostPendingByUri={props.repostPendingByUri} /> 252 )} 253 </For> 254 </div> 255 </Show> 256 </div> 257 )} 258 </Show> 259 </> 260 ); 261} 262 263function ThreadReplies( 264 props: { 265 bookmarkPendingByUri: Record<string, boolean>; 266 likePendingByUri: Record<string, boolean>; 267 node: ThreadNode; 268 onBookmark: (post: PostView) => void; 269 onLike: (post: PostView) => void; 270 onOpenEngagement: (uri: string, tab: "likes" | "reposts" | "quotes") => void; 271 onOpenPost: (uri: string) => void; 272 onRepost: (post: PostView) => void; 273 repostPendingByUri: Record<string, boolean>; 274 }, 275) { 276 const threadNode = createMemo(() => (isThreadViewPost(props.node) ? props.node : null)); 277 278 return ( 279 <Switch> 280 <Match when={isBlockedNode(props.node)}> 281 <StateCard label="Blocked post" meta={isBlockedNode(props.node) ? props.node.uri : ""} /> 282 </Match> 283 <Match when={isNotFoundNode(props.node)}> 284 <StateCard label="Post not found" meta={isNotFoundNode(props.node) ? props.node.uri : ""} /> 285 </Match> 286 <Match when={threadNode()}> 287 {(current) => ( 288 <div class="grid gap-3"> 289 <PostCard 290 bookmarkPending={!!props.bookmarkPendingByUri[current().post.uri]} 291 likePending={!!props.likePendingByUri[current().post.uri]} 292 onBookmark={() => props.onBookmark(current().post)} 293 onLike={() => props.onLike(current().post)} 294 onOpenEngagement={(tab) => props.onOpenEngagement(current().post.uri, tab)} 295 onOpenThread={() => props.onOpenPost(current().post.uri)} 296 onRepost={() => props.onRepost(current().post)} 297 post={current().post} 298 repostPending={!!props.repostPendingByUri[current().post.uri]} /> 299 300 <Show when={current().replies?.length}> 301 <div class="ml-3 grid gap-3 border-l pl-3 ui-outline-subtle"> 302 <For each={current().replies}> 303 {(reply) => ( 304 <ThreadReplies 305 bookmarkPendingByUri={props.bookmarkPendingByUri} 306 likePendingByUri={props.likePendingByUri} 307 node={reply} 308 onBookmark={props.onBookmark} 309 onLike={props.onLike} 310 onOpenEngagement={props.onOpenEngagement} 311 onOpenPost={props.onOpenPost} 312 onRepost={props.onRepost} 313 repostPendingByUri={props.repostPendingByUri} /> 314 )} 315 </For> 316 </div> 317 </Show> 318 </div> 319 )} 320 </Match> 321 </Switch> 322 ); 323} 324 325function PostPanelMessage(props: { body: string; title: string }) { 326 return ( 327 <div class="grid min-h-112 place-items-center px-6 py-10"> 328 <div class="grid max-w-lg gap-3 text-center"> 329 <p class="m-0 text-base font-medium text-on-surface">{props.title}</p> 330 <p class="m-0 text-sm text-on-surface-variant">{props.body}</p> 331 </div> 332 </div> 333 ); 334} 335 336function SkeletonPostCard() { 337 return ( 338 <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)"> 339 <div class="flex gap-3"> 340 <div class="skeleton-block h-11 w-11 rounded-full" /> 341 <div class="min-w-0 flex-1"> 342 <div class="skeleton-block h-4 w-40 rounded-full" /> 343 <div class="mt-3 grid gap-2"> 344 <div class="skeleton-block h-3.5 w-full rounded-full" /> 345 <div class="skeleton-block h-3.5 w-[82%] rounded-full" /> 346 <div class="skeleton-block h-3.5 w-[68%] rounded-full" /> 347 </div> 348 </div> 349 </div> 350 </div> 351 ); 352} 353 354function StateCard(props: { label: string; meta: string }) { 355 return ( 356 <div class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> 357 <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 358 <p class="mt-1 text-xs text-on-surface-variant">{props.meta}</p> 359 </div> 360 ); 361}