BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel";
2import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision";
3import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar";
4import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow";
5import { Icon } from "$/components/shared/Icon";
6import { useAppSession } from "$/contexts/app-session";
7import type {
8 DiagnosticBlockItem,
9 DiagnosticDidProfile,
10 DiagnosticLabel,
11 DiagnosticList,
12 DiagnosticStarterPack,
13} from "$/lib/api/diagnostics";
14import { DiagnosticsController } from "$/lib/api/diagnostics";
15import { collectModerationLabels } from "$/lib/moderation";
16import { asRecord, getStringProperty } from "$/lib/type-guards";
17import { shouldIgnoreKey } from "$/lib/utils/events";
18import { formatHandle, initials, normalizeError } from "$/lib/utils/text";
19import * as logger from "@tauri-apps/plugin-log";
20import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";
21import { createStore } from "solid-js/store";
22import { Motion, Presence } from "solid-motionone";
23import {
24 DiagnosticsBlockSkeleton,
25 DiagnosticsLabelSkeleton,
26 DiagnosticsListSkeleton,
27 DiagnosticsStarterPackSkeleton,
28} from "./DiagnosticsSkeleton";
29
30type DiagnosticsTab = "lists" | "labels" | "blocks" | "starterPacks" | "backlinks";
31
32type DiagnosticsPanelProps = {
33 did?: string | null;
34 embedded?: boolean;
35 onClose?: () => void;
36 onOpenExplorerTarget?: (target: string) => void;
37 recordUri?: string | null;
38};
39
40type DiagnosticsState = {
41 blockedBy: DiagnosticDidProfile[];
42 blockedByError: string | null;
43 blockedByLoading: boolean;
44 blocking: DiagnosticBlockItem[];
45 blockingError: string | null;
46 blockingLoading: boolean;
47 labels: DiagnosticLabel[];
48 labelsError: string | null;
49 labelsLoading: boolean;
50 labelsSourceProfiles: Record<string, unknown>;
51 lists: DiagnosticList[];
52 listsError: string | null;
53 listsLoading: boolean;
54 starterPacks: DiagnosticStarterPack[];
55 starterPacksError: string | null;
56 starterPacksLoading: boolean;
57};
58
59const DIAGNOSTICS_TABS: Array<{ label: string; value: DiagnosticsTab }> = [
60 { label: "Lists", value: "lists" },
61 { label: "Labels", value: "labels" },
62 { label: "Blocks", value: "blocks" },
63 { label: "Starter Packs", value: "starterPacks" },
64 { label: "Backlinks", value: "backlinks" },
65];
66
67function createInitialState(): DiagnosticsState {
68 return {
69 blockedBy: [],
70 blockedByError: null,
71 blockedByLoading: true,
72 blocking: [],
73 blockingError: null,
74 blockingLoading: true,
75 labels: [],
76 labelsError: null,
77 labelsLoading: true,
78 labelsSourceProfiles: {},
79 lists: [],
80 listsError: null,
81 listsLoading: true,
82 starterPacks: [],
83 starterPacksError: null,
84 starterPacksLoading: true,
85 };
86}
87
88function createIdleState(): DiagnosticsState {
89 return {
90 blockedBy: [],
91 blockedByError: null,
92 blockedByLoading: false,
93 blocking: [],
94 blockingError: null,
95 blockingLoading: false,
96 labels: [],
97 labelsError: null,
98 labelsLoading: false,
99 labelsSourceProfiles: {},
100 lists: [],
101 listsError: null,
102 listsLoading: false,
103 starterPacks: [],
104 starterPacksError: null,
105 starterPacksLoading: false,
106 };
107}
108
109function purposeLabel(purpose: string | null | undefined) {
110 const normalized = (purpose || "").toLowerCase();
111
112 switch (normalized) {
113 case "app.bsky.graph.defs#curatelist":
114 case "curate":
115 case "curation": {
116 return "Curation";
117 }
118 case "app.bsky.graph.defs#modlist":
119 case "modlist":
120 case "moderation": {
121 return "Moderation";
122 }
123 case "app.bsky.graph.defs#referencelist":
124 case "reference": {
125 return "Reference";
126 }
127 default: {
128 if (normalized.endsWith("#curatelist")) {
129 return "Curation";
130 }
131
132 if (normalized.endsWith("#modlist")) {
133 return "Moderation";
134 }
135
136 if (normalized.endsWith("#referencelist")) {
137 return "Reference";
138 }
139
140 return "Other";
141 }
142 }
143}
144
145function groupListsByPurpose(lists: DiagnosticList[]) {
146 const grouped = [
147 { label: "Curation", items: lists.filter((list) => purposeLabel(list.purpose) === "Curation") },
148 { label: "Moderation", items: lists.filter((list) => purposeLabel(list.purpose) === "Moderation") },
149 { label: "Reference", items: lists.filter((list) => purposeLabel(list.purpose) === "Reference") },
150 {
151 label: "Other",
152 items: lists.filter((list) => purposeLabel(list.purpose) === "Other"),
153 },
154 ].filter((group) => group.items.length > 0);
155
156 return grouped.length > 0 ? grouped : [{ label: "Lists", items: lists }];
157}
158
159function getDiagnosticEntryHandle(item: DiagnosticBlockItem | DiagnosticDidProfile) {
160 if (item.profile?.handle) {
161 return item.profile.handle;
162 }
163
164 if ("did" in item) {
165 return item.did;
166 }
167
168 return item.subjectDid ?? item.profile?.did ?? "Unknown";
169}
170
171function getLabelDefinition(value: string | null | undefined) {
172 switch ((value || "").toLowerCase()) {
173 case "!hide": {
174 return "Hidden content label.";
175 }
176 case "!hide-media": {
177 return "Media visibility label.";
178 }
179 case "!hide-replies": {
180 return "Replies visibility label.";
181 }
182 case "!no-unauthenticated": {
183 return "Requires a signed-in view.";
184 }
185 case "!warn": {
186 return "Advisory moderation label.";
187 }
188 default: {
189 return "Service-applied moderation metadata.";
190 }
191 }
192}
193
194function getLabelEffect(label: DiagnosticLabel) {
195 if (label.neg) {
196 return "This label negates a previous moderation decision.";
197 }
198
199 switch ((label.val || "").toLowerCase()) {
200 case "!hide":
201 case "!hide-media":
202 case "!hide-replies": {
203 return "It can change how the record or account is shown in supporting clients.";
204 }
205 case "!no-unauthenticated": {
206 return "It can limit visibility for signed-out browsing.";
207 }
208 default: {
209 return "Its exact effect depends on the labeling service and client policy.";
210 }
211 }
212}
213
214function getSourceProfileName(sourceProfiles: Record<string, unknown>, src: string | null | undefined) {
215 if (!src) {
216 return "Unknown service";
217 }
218
219 const profile = asRecord(sourceProfiles[src]);
220 if (!profile) {
221 return src;
222 }
223
224 return getStringProperty(profile, "displayName") ?? formatHandle(getStringProperty(profile, "handle"), null) ?? src;
225}
226
227export function DiagnosticsPanel(props: DiagnosticsPanelProps) {
228 const session = useAppSession();
229 const [state, setState] = createStore<DiagnosticsState>(createInitialState());
230 const [activeTab, setActiveTab] = createSignal<DiagnosticsTab>("lists");
231 const [blocksExpanded, setBlocksExpanded] = createSignal(false);
232 const activeDid = createMemo(() => props.did?.trim() || session.activeDid || "");
233 const activeRecordUri = createMemo(() => props.recordUri?.trim() || "");
234 const isSelf = createMemo(() => activeDid() === session.activeDid);
235 let requestId = 0;
236
237 createEffect(() => {
238 const did = activeDid();
239 if (!did) {
240 setState(createIdleState());
241 return;
242 }
243
244 const currentRequest = ++requestId;
245 setActiveTab("lists");
246 setBlocksExpanded(false);
247 setState(createInitialState());
248
249 void loadLists(currentRequest, did);
250 void loadLabels(currentRequest, did);
251 void loadBlocks(currentRequest, did);
252 void loadStarterPacks(currentRequest, did);
253 });
254
255 function handleKeyDown(event: KeyboardEvent) {
256 if (shouldIgnoreKey(event) || event.altKey || event.shiftKey) {
257 return;
258 }
259
260 if (event.key >= "1" && event.key <= "5") {
261 event.preventDefault();
262 const nextTab = DIAGNOSTICS_TABS[Number(event.key) - 1]?.value;
263 if (nextTab) {
264 setActiveTab(nextTab);
265 }
266 return;
267 }
268
269 if (event.key === "Escape") {
270 props.onClose?.();
271 }
272 }
273
274 onMount(() => {
275 document.addEventListener("keydown", handleKeyDown);
276 onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
277 });
278
279 async function loadLists(currentRequest: number, did: string) {
280 try {
281 const response = await DiagnosticsController.getAccountLists(did);
282 if (currentRequest !== requestId) return;
283 setState({ lists: response.lists, listsError: null, listsLoading: false });
284 } catch (error) {
285 const message = normalizeError(error);
286 if (currentRequest !== requestId) return;
287 setState({ listsError: message, listsLoading: false });
288 logger.warn("failed to load diagnostics lists", { keyValues: { did, error: message } });
289 }
290 }
291
292 async function loadLabels(currentRequest: number, did: string) {
293 try {
294 const response = await DiagnosticsController.getAccountLabels(did);
295 if (currentRequest !== requestId) return;
296 setState({
297 labels: response.labels,
298 labelsError: null,
299 labelsLoading: false,
300 labelsSourceProfiles: response.sourceProfiles,
301 });
302 } catch (error) {
303 const message = normalizeError(error);
304 if (currentRequest !== requestId) return;
305 setState({ labelsError: message, labelsLoading: false, labelsSourceProfiles: {} });
306 logger.warn("failed to load diagnostics labels", { keyValues: { did, error: message } });
307 }
308 }
309
310 async function loadBlocks(currentRequest: number, did: string) {
311 const [blockedBy, blocking] = await Promise.allSettled([
312 DiagnosticsController.getAccountBlockedBy(did, 25),
313 DiagnosticsController.getAccountBlocking(did),
314 ]);
315
316 if (currentRequest !== requestId) {
317 return;
318 }
319
320 if (blockedBy.status === "fulfilled") {
321 setState({ blockedBy: blockedBy.value.items, blockedByError: null, blockedByLoading: false });
322 } else {
323 const message = normalizeError(blockedBy.reason);
324 setState({ blockedByError: message, blockedByLoading: false });
325 logger.warn("failed to load diagnostics blocked-by data", { keyValues: { did, error: message } });
326 }
327
328 if (blocking.status === "fulfilled") {
329 setState({ blocking: blocking.value.items, blockingError: null, blockingLoading: false });
330 } else {
331 const message = normalizeError(blocking.reason);
332 setState({ blockingError: message, blockingLoading: false });
333 logger.warn("failed to load diagnostics blocking data", { keyValues: { did, error: message } });
334 }
335 }
336
337 async function loadStarterPacks(currentRequest: number, did: string) {
338 try {
339 const response = await DiagnosticsController.getAccountStarterPacks(did);
340 if (currentRequest !== requestId) return;
341 setState({ starterPacks: response.starterPacks, starterPacksError: null, starterPacksLoading: false });
342 } catch (error) {
343 const message = normalizeError(error);
344 if (currentRequest !== requestId) return;
345 setState({ starterPacksError: message, starterPacksLoading: false });
346 logger.warn("failed to load diagnostics starter packs", { keyValues: { did, error: message } });
347 }
348 }
349
350 return (
351 <article class="grid min-h-0 grid-rows-[auto_auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-(--inset-shadow)">
352 <DiagnosticsHeader
353 did={activeDid()}
354 embedded={props.embedded ?? false}
355 isSelf={isSelf()}
356 onClose={props.onClose} />
357 <DiagnosticsTabs activeTab={activeTab()} onSelectTab={setActiveTab} />
358 <DiagnosticsViewport
359 activeTab={activeTab()}
360 blocksExpanded={blocksExpanded()}
361 isSelf={isSelf()}
362 onOpenExplorerTarget={props.onOpenExplorerTarget}
363 onRetryBlockedBy={() => void loadBlocks(requestId, activeDid())}
364 onRetryBlocking={() => void loadBlocks(requestId, activeDid())}
365 onRetryLabels={() => void loadLabels(requestId, activeDid())}
366 onRetryLists={() => void loadLists(requestId, activeDid())}
367 onRetryStarterPacks={() => void loadStarterPacks(requestId, activeDid())}
368 onToggleBlocks={() => setBlocksExpanded((value) => !value)}
369 recordUri={activeRecordUri()}
370 state={state} />
371 </article>
372 );
373}
374
375function DiagnosticsHeader(props: { did: string; embedded: boolean; isSelf: boolean; onClose?: () => void }) {
376 return (
377 <header class="grid gap-4 px-6 pb-4 pt-6">
378 <div class="flex items-start justify-between gap-4">
379 <div class="grid gap-1">
380 <p class="overline-copy text-xs text-on-surface-variant">Context</p>
381 <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Social Diagnostics</h1>
382 <p class="m-0 text-sm text-on-surface-variant">
383 {props.isSelf ? "Your boundaries and public footprint" : "Public social context for this account"}
384 </p>
385 </div>
386 <Show when={!props.embedded && props.onClose}>
387 <button
388 type="button"
389 class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-surface-container-high text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface"
390 onClick={() => props.onClose?.()}
391 title="Close diagnostics panel">
392 <Icon kind="close" aria-hidden />
393 </button>
394 </Show>
395 </div>
396
397 <p class="m-0 break-all rounded-2xl bg-surface-container-high px-4 py-3 font-mono text-xs text-on-surface-variant">
398 {props.did || "No account selected"}
399 </p>
400 </header>
401 );
402}
403
404function DiagnosticsTabs(props: { activeTab: DiagnosticsTab; onSelectTab: (tab: DiagnosticsTab) => void }) {
405 const activeIndex = createMemo(() => DIAGNOSTICS_TABS.findIndex((tab) => tab.value === props.activeTab));
406
407 return (
408 <nav class="px-3 pb-3" aria-label="Diagnostics tabs">
409 <div class="ui-input-strong relative flex gap-1 rounded-full p-1">
410 <Motion.div
411 class="absolute inset-y-1 rounded-full bg-surface-container-high shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)]"
412 animate={{ x: `${activeIndex() * 100}%` }}
413 style={{ width: `${100 / DIAGNOSTICS_TABS.length}%` }}
414 transition={{ duration: 0.18 }} />
415 <For each={DIAGNOSTICS_TABS}>
416 {(tab) => (
417 <button
418 type="button"
419 aria-pressed={props.activeTab === tab.value}
420 class="relative z-10 flex-1 rounded-full px-3 py-2 text-sm font-medium transition duration-150"
421 classList={{
422 "text-on-surface": props.activeTab === tab.value,
423 "text-on-surface-variant": props.activeTab !== tab.value,
424 }}
425 onClick={() => props.onSelectTab(tab.value)}>
426 {tab.label}
427 </button>
428 )}
429 </For>
430 </div>
431 </nav>
432 );
433}
434
435function DiagnosticsViewport(
436 props: {
437 activeTab: DiagnosticsTab;
438 blocksExpanded: boolean;
439 isSelf: boolean;
440 onOpenExplorerTarget?: (target: string) => void;
441 onRetryBlockedBy: () => void;
442 onRetryBlocking: () => void;
443 onRetryLabels: () => void;
444 onRetryLists: () => void;
445 onRetryStarterPacks: () => void;
446 onToggleBlocks: () => void;
447 recordUri: string;
448 state: DiagnosticsState;
449 },
450) {
451 return (
452 <div class="min-h-0 overflow-y-auto px-3 pb-3">
453 <Presence>
454 <Show when={props.activeTab === "lists"} keyed>
455 <DiagnosticsListsTab
456 error={props.state.listsError}
457 lists={props.state.lists}
458 loading={props.state.listsLoading}
459 onOpenExplorerTarget={props.onOpenExplorerTarget}
460 onRetry={props.onRetryLists} />
461 </Show>
462 <Show when={props.activeTab === "labels"} keyed>
463 <DiagnosticsLabelsTab
464 error={props.state.labelsError}
465 labels={props.state.labels}
466 loading={props.state.labelsLoading}
467 onRetry={props.onRetryLabels}
468 sourceProfiles={props.state.labelsSourceProfiles} />
469 </Show>
470 <Show when={props.activeTab === "blocks"} keyed>
471 <DiagnosticsBlocksTab
472 blockedBy={props.state.blockedBy}
473 blockedByError={props.state.blockedByError}
474 blockedByLoading={props.state.blockedByLoading}
475 blocking={props.state.blocking}
476 blockingError={props.state.blockingError}
477 blockingLoading={props.state.blockingLoading}
478 expanded={props.blocksExpanded}
479 isSelf={props.isSelf}
480 onRetryBlockedBy={props.onRetryBlockedBy}
481 onRetryBlocking={props.onRetryBlocking}
482 onToggleExpanded={props.onToggleBlocks} />
483 </Show>
484 <Show when={props.activeTab === "starterPacks"} keyed>
485 <DiagnosticsStarterPacksTab
486 error={props.state.starterPacksError}
487 loading={props.state.starterPacksLoading}
488 onOpenExplorerTarget={props.onOpenExplorerTarget}
489 onRetry={props.onRetryStarterPacks}
490 starterPacks={props.state.starterPacks} />
491 </Show>
492 <Show when={props.activeTab === "backlinks"} keyed>
493 <section class="grid gap-3">
494 <DiagnosticsTabIntro
495 description="Backlinks are record-specific engagement context. Open a record to inspect likes, reposts, replies, and quote posts."
496 title="Backlinks" />
497 <RecordBacklinksPanel uri={props.recordUri || null} />
498 </section>
499 </Show>
500 </Presence>
501 </div>
502 );
503}
504
505function DiagnosticsListsTab(
506 props: {
507 error: string | null;
508 lists: DiagnosticList[];
509 loading: boolean;
510 onOpenExplorerTarget?: (target: string) => void;
511 onRetry: () => void;
512 },
513) {
514 return (
515 <section class="grid gap-3">
516 <DiagnosticsTabIntro
517 title="Lists"
518 description="Lists are collections of users and can be used for moderation or curation." />
519 <Switch
520 fallback={
521 <div class="grid gap-4">
522 <For
523 each={groupListsByPurpose(props.lists)}
524 fallback={
525 <DiagnosticsEmptyState copy="Lists explain where this account appears in the network. There may simply be none yet." />
526 }>
527 {(group) => (
528 <div class="grid gap-3">
529 <p class="m-0 text-xs uppercase tracking-[0.14em] text-on-surface-variant">{group.label}</p>
530 <div class="grid gap-3">
531 <For each={group.items}>
532 {(list) => <ListCard list={list} onOpenExplorerTarget={props.onOpenExplorerTarget} />}
533 </For>
534 </div>
535 </div>
536 )}
537 </For>
538 </div>
539 }>
540 <Match when={props.loading}>
541 <DiagnosticsListSkeleton />
542 </Match>
543 <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match>
544 </Switch>
545 </section>
546 );
547}
548
549function DiagnosticsLabelsTab(
550 props: {
551 error: string | null;
552 labels: DiagnosticLabel[];
553 loading: boolean;
554 onRetry: () => void;
555 sourceProfiles: Record<string, unknown>;
556 },
557) {
558 return (
559 <section class="grid gap-3">
560 <DiagnosticsTabIntro
561 title="Labels"
562 description="Labels are moderation metadata from labeling services. They are shown as uniform chips with source attribution." />
563 <Switch
564 fallback={
565 <Motion.div
566 class="flex flex-wrap gap-2"
567 initial={{ opacity: 0, scale: 0.98 }}
568 animate={{ opacity: 1, scale: 1 }}
569 transition={{ duration: 0.16 }}>
570 <For
571 fallback={
572 <DiagnosticsEmptyState copy="Labels are service-applied metadata that can affect visibility. No visible labels are being returned for this account right now." />
573 }
574 each={props.labels}>
575 {(label, index) => (
576 <LabelChip
577 index={index()}
578 label={label}
579 sourceName={getSourceProfileName(props.sourceProfiles, label.src)} />
580 )}
581 </For>
582 </Motion.div>
583 }>
584 <Match when={props.loading}>
585 <DiagnosticsLabelSkeleton />
586 </Match>
587 <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match>
588 </Switch>
589 </section>
590 );
591}
592
593function DiagnosticsBlocksTab(
594 props: {
595 blockedBy: DiagnosticDidProfile[];
596 blockedByError: string | null;
597 blockedByLoading: boolean;
598 blocking: DiagnosticBlockItem[];
599 blockingError: string | null;
600 blockingLoading: boolean;
601 expanded: boolean;
602 isSelf: boolean;
603 onRetryBlockedBy: () => void;
604 onRetryBlocking: () => void;
605 onToggleExpanded: () => void;
606 },
607) {
608 const blockedByCount = createMemo(() => props.blockedBy.length);
609 const blockingCount = createMemo(() => props.blocking.length);
610
611 return (
612 <section class="grid gap-3">
613 <DiagnosticsTabIntro
614 description="Blocking is a normal social boundary. Counts stay in the summary row; specific accounts appear only after a deliberate action."
615 title={props.isSelf ? "Your Boundaries" : "Blocks"} />
616 <div class="grid gap-3 sm:grid-cols-2">
617 <StatCard label={props.isSelf ? "Boundaries around you" : "Blocked by"} value={blockedByCount()} />
618 <StatCard label={props.isSelf ? "Your boundaries" : "Blocking"} value={blockingCount()} />
619 </div>
620 <div class="rounded-3xl bg-surface-container-high p-4 text-sm leading-relaxed text-on-surface-variant shadow-(--inset-shadow)">
621 Blocks are a normal part of social media. This data is public on the AT Protocol.
622 </div>
623 <button
624 type="button"
625 class="inline-flex w-fit items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 py-2 text-sm font-medium text-on-surface transition duration-150 hover:-translate-y-px"
626 onClick={() => props.onToggleExpanded()}>
627 <Icon kind={props.expanded ? "close" : "list"} aria-hidden />
628 {props.expanded ? "Hide details" : "Show details"}
629 </button>
630
631 <Presence>
632 <Show when={props.expanded}>
633 <Motion.div
634 class="grid gap-4"
635 initial={{ opacity: 0, height: 0 }}
636 animate={{ opacity: 1, height: "auto" }}
637 exit={{ opacity: 0, height: 0 }}
638 transition={{ duration: 0.18 }}>
639 <DiagnosticsBlock
640 error={props.blockedByError}
641 items={props.blockedBy}
642 loading={props.blockedByLoading}
643 onRetry={props.onRetryBlockedBy}
644 title={props.isSelf ? "Boundaries around you" : "Blocked by"} />
645 <DiagnosticsBlock
646 error={props.blockingError}
647 items={props.blocking}
648 loading={props.blockingLoading}
649 onRetry={props.onRetryBlocking}
650 title={props.isSelf ? "Your boundaries" : "Blocking"} />
651 </Motion.div>
652 </Show>
653 </Presence>
654 </section>
655 );
656}
657
658function DiagnosticsBlock(
659 props: {
660 error: string | null;
661 items: DiagnosticBlockItem[] | DiagnosticDidProfile[];
662 loading: boolean;
663 onRetry: () => void;
664 title: string;
665 },
666) {
667 const items = createMemo(() =>
668 props.items.map((item) => ({
669 available: item.availability === "available",
670 avatar: item.availability === "available" ? item.profile?.avatar ?? null : null,
671 description: item.availability === "available" ? item.profile?.description ?? null : null,
672 displayName: item.profile?.displayName ?? null,
673 handle: getDiagnosticEntryHandle(item),
674 labels: item.availability === "available" ? item.profile?.labels ?? null : null,
675 unavailableMessage: item.unavailableMessage ?? "Profile unavailable",
676 }))
677 );
678
679 return (
680 <Switch fallback={<BlockProfileList items={items()} title={props.title} />}>
681 <Match when={props.loading}>
682 <DiagnosticsBlockSkeleton />
683 </Match>
684 <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match>
685 </Switch>
686 );
687}
688
689function DiagnosticsStarterPacksTab(
690 props: {
691 error: string | null;
692 loading: boolean;
693 onOpenExplorerTarget?: (target: string) => void;
694 onRetry: () => void;
695 starterPacks: DiagnosticStarterPack[];
696 },
697) {
698 return (
699 <section class="grid gap-3">
700 <DiagnosticsTabIntro
701 title="Starter Packs"
702 description="Starter packs show how people are discovering this account. The cards stay compact and factual." />
703 <Switch
704 fallback={
705 <div class="grid gap-3">
706 <For
707 each={props.starterPacks}
708 fallback={
709 <DiagnosticsEmptyState copy="Starter packs are discovery context and may not exist for every account." />
710 }>
711 {(pack) => <StarterPackCard onOpenExplorerTarget={props.onOpenExplorerTarget} pack={pack} />}
712 </For>
713 </div>
714 }>
715 <Match when={props.loading}>
716 <DiagnosticsStarterPackSkeleton />
717 </Match>
718 <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match>
719 </Switch>
720 </section>
721 );
722}
723
724function DiagnosticsTabIntro(props: { description: string; title: string }) {
725 return (
726 <div class="grid gap-1 rounded-3xl bg-surface-container-high p-4 shadow-(--inset-shadow)">
727 <h2 class="m-0 text-base font-semibold text-on-surface">{props.title}</h2>
728 <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.description}</p>
729 </div>
730 );
731}
732
733function DiagnosticsError(props: { message: string | null; onRetry?: () => void }) {
734 return (
735 <div class="grid gap-3 rounded-3xl bg-surface-container-high p-4 text-sm text-on-surface-variant shadow-(--inset-shadow)">
736 <p class="m-0">{props.message}</p>
737 <Show when={props.onRetry}>
738 <button
739 type="button"
740 class="inline-flex w-fit items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 py-2 text-sm font-medium text-on-surface transition duration-150 hover:-translate-y-px"
741 onClick={() => props.onRetry?.()}>
742 <Icon kind="refresh" aria-hidden />
743 Retry
744 </button>
745 </Show>
746 </div>
747 );
748}
749
750function DiagnosticsEmptyState(props: { copy: string }) {
751 return (
752 <div class="rounded-3xl bg-surface-container-high p-4 text-sm text-on-surface-variant shadow-(--inset-shadow)">
753 {props.copy}
754 </div>
755 );
756}
757
758function StatCard(props: { label: string; value: number }) {
759 return (
760 <div class="rounded-3xl bg-surface-container-high p-4 shadow-(--inset-shadow)">
761 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</p>
762 <p class="m-0 mt-2 text-3xl font-semibold text-on-surface">{props.value}</p>
763 </div>
764 );
765}
766
767function LabelChip(props: { index: number; label: DiagnosticLabel; sourceName: string }) {
768 const title = createMemo(() =>
769 [
770 `Label: ${props.label.val ?? "Unknown"}`,
771 `Definition: ${getLabelDefinition(props.label.val)}`,
772 `Source: ${props.sourceName}`,
773 `Effect: ${getLabelEffect(props.label)}`,
774 ].join("\n")
775 );
776
777 return (
778 <Motion.span
779 class="inline-flex items-center gap-2 rounded-full bg-surface-bright px-3 py-2 text-sm text-on-secondary-container"
780 initial={{ opacity: 0, scale: 0.9 }}
781 animate={{ opacity: 1, scale: 1 }}
782 title={title()}
783 transition={{ delay: Math.min(props.index * 0.02, 0.12), duration: 0.14 }}>
784 <span class="h-2 w-2 rounded-full bg-primary/35" />
785 <span>{props.label.val ?? "label"}</span>
786 <span class="text-xs text-on-surface-variant/90">{props.sourceName}</span>
787 </Motion.span>
788 );
789}
790
791function ListCard(props: { list: DiagnosticList; onOpenExplorerTarget?: (target: string) => void }) {
792 const count = () => props.list.memberCount ?? props.list.listItemCount ?? 0;
793 const title = () => props.list.title ?? props.list.name ?? "Untitled list";
794
795 return (
796 <div class="rounded-3xl bg-surface-container-high p-4 shadow-(--inset-shadow) transition duration-150 hover:bg-surface-bright">
797 <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
798 <div class="min-w-0">
799 <div class="flex flex-wrap items-center gap-2">
800 <p class="m-0 text-base font-semibold text-on-surface">{title()}</p>
801 <span class="rounded-full bg-primary/12 px-3 py-1 text-xs text-primary">
802 {purposeLabel(props.list.purpose)}
803 </span>
804 </div>
805 <p class="m-0 mt-1 text-sm text-on-surface-variant">
806 Owner: {formatHandle(props.list.creator?.handle, null)}
807 </p>
808 <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant">
809 {props.list.description ?? "No description provided."}
810 </p>
811 </div>
812
813 <div class="grid shrink-0 justify-items-start gap-2 text-left lg:justify-items-end lg:text-right">
814 <span class="text-xs text-on-surface-variant">{count()} members</span>
815 <Show when={props.list.uri}>
816 {uri => (
817 <button
818 type="button"
819 class="inline-flex items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 py-2 text-sm text-on-surface transition duration-150 hover:-translate-y-px"
820 disabled={!props.onOpenExplorerTarget}
821 onClick={() => props.onOpenExplorerTarget?.(uri())}>
822 <Icon kind="ext-link" aria-hidden />
823 Open list
824 </button>
825 )}
826 </Show>
827 </div>
828 </div>
829 </div>
830 );
831}
832
833function StarterPackCard(props: { onOpenExplorerTarget?: (target: string) => void; pack: DiagnosticStarterPack }) {
834 const count = () => props.pack.listItemCount ?? props.pack.record?.listItemsSample?.length ?? 0;
835 const title = () => props.pack.title ?? props.pack.name ?? props.pack.record?.name ?? "Starter pack";
836
837 return (
838 <div class="rounded-3xl bg-surface-container-high p-4 shadow-(--inset-shadow) transition duration-150 hover:bg-surface-bright">
839 <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
840 <div class="min-w-0">
841 <p class="m-0 text-base font-semibold text-on-surface">{title()}</p>
842 <p class="m-0 mt-1 text-sm text-on-surface-variant">
843 Creator: {formatHandle(props.pack.creator?.handle ?? null, null)}
844 </p>
845 <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant">
846 {props.pack.description ?? props.pack.record?.description ?? "No description provided."}
847 </p>
848 </div>
849
850 <div class="grid shrink-0 justify-items-start gap-2 sm:justify-items-end">
851 <span class="rounded-full bg-surface-bright px-3 py-1 text-xs text-on-surface-variant">
852 {count()} members
853 </span>
854 <Show when={props.pack.uri}>
855 {uri => (
856 <button
857 type="button"
858 class="inline-flex items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 py-2 text-sm text-on-surface transition duration-150 hover:-translate-y-px"
859 disabled={!props.onOpenExplorerTarget}
860 onClick={() => props.onOpenExplorerTarget?.(uri())}>
861 <Icon kind="ext-link" aria-hidden />
862 AT Explorer
863 </button>
864 )}
865 </Show>
866 </div>
867 </div>
868 </div>
869 );
870}
871
872function BlockProfileList(
873 props: {
874 items: Array<
875 {
876 available: boolean;
877 avatar?: string | null;
878 description?: string | null;
879 displayName?: string | null;
880 handle: string;
881 labels?: DiagnosticLabel[] | null;
882 unavailableMessage: string;
883 }
884 >;
885 title: string;
886 },
887) {
888 return (
889 <div class="grid gap-3 rounded-3xl bg-surface-container-high p-4 shadow-(--inset-shadow)">
890 <p class="m-0 text-sm font-semibold text-on-surface">{props.title}</p>
891 <div class="grid gap-3">
892 <For each={props.items}>{(item, index) => <BlockProfileRow index={index()} item={item} />}</For>
893 </div>
894 </div>
895 );
896}
897
898function BlockProfileRow(
899 props: {
900 index: number;
901 item: {
902 available: boolean;
903 avatar?: string | null;
904 description?: string | null;
905 displayName?: string | null;
906 handle: string;
907 labels?: DiagnosticLabel[] | null;
908 unavailableMessage: string;
909 };
910 },
911) {
912 const name = createMemo(() => props.item.displayName ?? props.item.handle);
913 const profileLabels = () => collectModerationLabels({ labels: props.item.labels ?? null });
914 const avatarDecision = useModerationDecision(profileLabels, "avatar");
915 const profileDecision = useModerationDecision(profileLabels, "profileList");
916
917 return (
918 <Motion.div
919 class="flex items-start gap-3 rounded-2xl p-3"
920 classList={{ "ui-input-strong": props.item.available, "tone-muted opacity-70": !props.item.available }}
921 aria-disabled={!props.item.available}
922 initial={{ opacity: 0, y: 8 }}
923 animate={{ opacity: 1, y: 0 }}
924 transition={{ delay: Math.min(props.index * 0.04, 0.16), duration: 0.16 }}>
925 <Show
926 when={props.item.available}
927 fallback={
928 <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-container-high text-xs font-semibold text-on-surface-variant">
929 <Icon kind="danger" aria-hidden />
930 </div>
931 }>
932 <ModeratedAvatar
933 avatar={props.item.avatar}
934 class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-surface-container-high"
935 hidden={avatarDecision().filter || avatarDecision().blur !== "none"}
936 label={initials(name())}
937 fallbackClass="text-xs font-semibold text-on-surface-variant" />
938 </Show>
939
940 <div class="min-w-0">
941 <p class="m-0 text-sm font-medium text-on-surface">{name()}</p>
942 <p class="m-0 text-xs text-on-surface-variant">{formatHandle(props.item.handle, null)}</p>
943 <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} />
944 <Show when={props.item.available && props.item.description}>
945 {(description) => <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{description()}</p>}
946 </Show>
947 <Show when={!props.item.available}>
948 <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{props.item.unavailableMessage}</p>
949 </Show>
950 </div>
951 </Motion.div>
952 );
953}