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 953 lines 35 kB view raw
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}