BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision";
2import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar";
3import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow";
4import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation";
5import { Icon } from "$/components/shared/Icon";
6import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview";
7import type { DiagnosticBacklinkGroup, DiagnosticBacklinkItem } from "$/lib/api/diagnostics";
8import { DiagnosticsController } from "$/lib/api/diagnostics";
9import { collectModerationLabels } from "$/lib/moderation";
10import {
11 buildPostEngagementTabRoute,
12 parsePostEngagementTab,
13 type PostEngagementTab,
14} from "$/lib/post-engagement-routes";
15import { buildProfileRoute } from "$/lib/profile";
16import { asRecord } from "$/lib/type-guards";
17import type { ProfileViewBasic } from "$/lib/types";
18import { formatHandle, initials, normalizeError } from "$/lib/utils/text";
19import { useLocation, useNavigate } from "@solidjs/router";
20import * as logger from "@tauri-apps/plugin-log";
21import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js";
22import { createStore } from "solid-js/store";
23
24type EngagementState = {
25 error: string | null;
26 groups: Record<PostEngagementTab, DiagnosticBacklinkGroup>;
27 loading: boolean;
28 uri: string | null;
29};
30
31const EMPTY_GROUP: DiagnosticBacklinkGroup = { cursor: null, records: [], total: 0 };
32const TABS: Array<{ key: PostEngagementTab; label: string }> = [{ key: "likes", label: "Likes" }, {
33 key: "reposts",
34 label: "Reposts",
35}, { key: "quotes", label: "Quotes" }];
36
37function createInitialState(): EngagementState {
38 return {
39 error: null,
40 groups: { likes: EMPTY_GROUP, reposts: EMPTY_GROUP, quotes: EMPTY_GROUP },
41 loading: false,
42 uri: null,
43 };
44}
45
46export function PostEngagementPanel(props: { uri: string | null }) {
47 const location = useLocation();
48 const navigate = useNavigate();
49 const postNavigation = usePostNavigation();
50 const [state, setState] = createStore<EngagementState>(createInitialState());
51 let requestId = 0;
52
53 const activeUri = createMemo(() => props.uri?.trim() || null);
54 const activeTab = createMemo(() => parsePostEngagementTab(location.search));
55 const activeGroup = createMemo(() => state.groups[activeTab()]);
56 const activeTabLabel = createMemo(() => TABS.find((tab) => tab.key === activeTab())?.label ?? "Likes");
57
58 createEffect(() => {
59 const uri = activeUri();
60 if (!uri) {
61 setState(createInitialState());
62 return;
63 }
64
65 const nextRequestId = ++requestId;
66 setState({
67 error: null,
68 groups: { likes: EMPTY_GROUP, reposts: EMPTY_GROUP, quotes: EMPTY_GROUP },
69 loading: true,
70 uri,
71 });
72 void loadEngagement(nextRequestId, uri);
73 });
74
75 async function loadEngagement(nextRequestId: number, uri: string) {
76 try {
77 const response = await DiagnosticsController.getRecordBacklinks(uri);
78 if (nextRequestId !== requestId || uri !== activeUri()) {
79 return;
80 }
81
82 setState({
83 error: null,
84 groups: {
85 likes: response.likes ?? EMPTY_GROUP,
86 quotes: response.quotes ?? EMPTY_GROUP,
87 reposts: response.reposts ?? EMPTY_GROUP,
88 },
89 loading: false,
90 uri,
91 });
92 } catch (error) {
93 const message = normalizeError(error);
94 if (nextRequestId !== requestId || uri !== activeUri()) {
95 return;
96 }
97
98 setState({ error: message, loading: false, uri });
99 logger.error("failed to load post engagement", { keyValues: { error: message, uri } });
100 }
101 }
102
103 function selectTab(tab: PostEngagementTab) {
104 if (tab === activeTab()) {
105 return;
106 }
107
108 void navigate(buildPostEngagementTabRoute(location.pathname, location.search, tab));
109 }
110
111 function openProfile(item: DiagnosticBacklinkItem) {
112 const actor = item.profile?.handle?.trim() || item.did?.trim();
113 if (!actor) {
114 return;
115 }
116
117 void navigate(buildProfileRoute(actor));
118 }
119
120 function openQuote(item: DiagnosticBacklinkItem) {
121 if (!item.uri) {
122 return;
123 }
124
125 void postNavigation.openPostScreen(item.uri);
126 }
127
128 return (
129 <section class="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)">
130 <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">
131 <div class="min-w-0">
132 <p class="m-0 text-xl font-semibold tracking-tight text-on-surface">Post Engagement</p>
133 <p class="m-0 mt-1 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{activeTabLabel()}</p>
134 </div>
135 <button
136 type="button"
137 class="ui-control ui-control-hoverable inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm text-on-surface"
138 onClick={() => void postNavigation.backFromPost()}>
139 <Icon aria-hidden iconClass="i-ri-arrow-left-line" />
140 Back
141 </button>
142 </header>
143
144 <nav class="flex flex-wrap gap-2 px-3 pb-3 pt-3 max-[520px]:px-2" aria-label="Engagement tabs">
145 <For each={TABS}>
146 {(tab) => (
147 <button
148 type="button"
149 class="rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150 ease-out"
150 classList={{
151 "tone-muted text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.2)]": activeTab() === tab.key,
152 "text-on-surface-variant hover:bg-surface-bright hover:text-on-surface": activeTab() !== tab.key,
153 }}
154 onClick={() => selectTab(tab.key)}>
155 {tab.label} ({activeCount(state.groups[tab.key])})
156 </button>
157 )}
158 </For>
159 </nav>
160
161 <div class="min-h-0 overflow-y-auto px-3 pb-4 max-[520px]:px-2">
162 <Switch>
163 <Match when={!activeUri()}>
164 <PanelMessage title="Post unavailable" body="This engagement route is missing a valid post URI." />
165 </Match>
166 <Match when={state.loading}>
167 <EngagementSkeleton />
168 </Match>
169 <Match when={state.error}>
170 <PanelMessage title="Couldn’t load engagement" body={state.error ?? "Try refreshing this view."} />
171 </Match>
172 <Match when={(activeGroup().records ?? []).length === 0}>
173 <PanelMessage
174 title={`No ${activeTabLabel().toLowerCase()} yet`}
175 body={`This post does not have visible ${activeTabLabel().toLowerCase()} right now.`} />
176 </Match>
177 <Match when={true}>
178 <EngagementList
179 items={activeGroup().records}
180 kind={activeTab()}
181 onOpenProfile={openProfile}
182 onOpenQuote={openQuote} />
183 </Match>
184 </Switch>
185 </div>
186 </section>
187 );
188}
189
190function activeCount(group: DiagnosticBacklinkGroup) {
191 return group.total ?? group.records.length;
192}
193
194function EngagementList(
195 props: {
196 items: DiagnosticBacklinkItem[];
197 kind: PostEngagementTab;
198 onOpenProfile: (item: DiagnosticBacklinkItem) => void;
199 onOpenQuote: (item: DiagnosticBacklinkItem) => void;
200 },
201) {
202 return (
203 <div class="grid gap-3">
204 <For each={props.items}>
205 {(item) => (
206 <EngagementRow
207 item={item}
208 kind={props.kind}
209 onOpenProfile={props.onOpenProfile}
210 onOpenQuote={props.onOpenQuote} />
211 )}
212 </For>
213 </div>
214 );
215}
216
217function EngagementRow(
218 props: {
219 item: DiagnosticBacklinkItem;
220 kind: PostEngagementTab;
221 onOpenProfile: (item: DiagnosticBacklinkItem) => void;
222 onOpenQuote: (item: DiagnosticBacklinkItem) => void;
223 },
224) {
225 const actorLabel = createMemo(() =>
226 props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown account"
227 );
228 const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did));
229 const quoteInteractive = createMemo(() => props.kind === "quotes" && !!props.item.uri);
230 const profileInteractive = createMemo(() =>
231 props.kind !== "quotes" && !!(props.item.profile?.handle || props.item.did)
232 );
233 const interactive = createMemo(() => quoteInteractive() || profileInteractive());
234 const quoteText = createMemo(() => getQuoteText(props.item));
235 const quoteAuthor = createMemo(() => getQuoteAuthor(props.item));
236 const profileLabels = () => collectModerationLabels(props.item.profile);
237 const avatarDecision = useModerationDecision(profileLabels, "avatar");
238 const profileDecision = useModerationDecision(profileLabels, "profileList");
239
240 return (
241 <button
242 type="button"
243 class="tone-muted flex w-full items-start gap-3 rounded-3xl border-0 p-4 text-left shadow-(--inset-shadow) transition duration-150 hover:bg-surface-bright disabled:cursor-default disabled:hover:bg-panel-muted"
244 disabled={!interactive()}
245 onClick={() => {
246 if (quoteInteractive()) {
247 props.onOpenQuote(props.item);
248 return;
249 }
250
251 props.onOpenProfile(props.item);
252 }}>
253 <ModeratedAvatar
254 avatar={props.item.profile?.avatar}
255 class="ui-input-strong h-11 w-11 shrink-0 overflow-hidden rounded-full"
256 hidden={avatarDecision().filter || avatarDecision().blur !== "none"}
257 label={initials(actorLabel())}
258 fallbackClass="text-xs font-semibold text-on-surface-variant" />
259 <div class="min-w-0 flex-1">
260 <div class="flex flex-wrap items-center gap-2">
261 <p class="m-0 text-sm font-medium text-on-surface">{actorLabel()}</p>
262 <Show when={props.item.collection}>
263 {(collection) => (
264 <span class="tone-muted rounded-full px-2.5 py-1 text-xs text-on-surface-variant shadow-(--inset-shadow)">
265 {collection()}
266 </span>
267 )}
268 </Show>
269 </div>
270 <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p>
271 <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} />
272 <Show
273 when={props.kind === "quotes"}
274 fallback={
275 <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p>
276 }>
277 <div class="mt-2">
278 <QuotedPostPreview
279 author={quoteAuthor()}
280 class="ui-input-strong rounded-2xl p-3 shadow-(--inset-shadow)"
281 text={quoteText() ?? ""}
282 title="Quoted post"
283 truncate />
284 </div>
285 </Show>
286 </div>
287 <Show when={interactive()}>
288 <div class="pt-1 text-on-surface-variant">
289 <Icon iconClass="i-ri-arrow-right-up-line" />
290 </div>
291 </Show>
292 </button>
293 );
294}
295
296function getQuoteRecord(item: DiagnosticBacklinkItem) {
297 return asRecord(item.value);
298}
299
300function getQuoteText(item: DiagnosticBacklinkItem) {
301 const text = getQuoteRecord(item)?.text;
302 return typeof text === "string" && text.trim().length > 0 ? text : null;
303}
304
305function getQuoteAuthor(item: DiagnosticBacklinkItem): ProfileViewBasic | null {
306 const did = item.profile?.did?.trim() || item.did?.trim();
307 const handle = item.profile?.handle?.trim() || did;
308 if (!did || !handle) {
309 return null;
310 }
311
312 return {
313 did,
314 handle,
315 avatar: item.profile?.avatar ?? null,
316 displayName: item.profile?.displayName ?? null,
317 labels: item.profile?.labels ?? null,
318 };
319}
320
321function PanelMessage(props: { body: string; title: string }) {
322 return (
323 <div class="grid min-h-112 place-items-center px-6 py-10">
324 <div class="grid max-w-lg gap-3 text-center">
325 <p class="m-0 text-base font-medium text-on-surface">{props.title}</p>
326 <p class="m-0 text-sm text-on-surface-variant">{props.body}</p>
327 </div>
328 </div>
329 );
330}
331
332function EngagementSkeleton() {
333 return (
334 <div class="grid gap-3">
335 <For each={Array.from({ length: 4 })}>
336 {() => (
337 <div class="tone-muted rounded-3xl p-5 shadow-(--inset-shadow)">
338 <div class="flex gap-3">
339 <div class="skeleton-block h-11 w-11 rounded-full" />
340 <div class="grid min-w-0 flex-1 gap-2">
341 <div class="skeleton-block h-4 w-32 rounded-full" />
342 <div class="skeleton-block h-3 w-24 rounded-full" />
343 <div class="skeleton-block h-3 w-full rounded-full" />
344 </div>
345 </div>
346 </div>
347 )}
348 </For>
349 </div>
350 );
351}