BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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}