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.

feat(wip): diagnostics UI

+1059 -122
+1
docs/tasks/12-social-diagnostics.md
··· 84 84 85 85 - [ ] Network relationship diff over time (requires historical snapshots) 86 86 - [ ] Profile/identity history timeline (handle/DID/PDS changes) 87 + - [ ] Starter Pack search?
+1
eslint.config.js
··· 39 39 "unicorn/prefer-top-level-await": "off", 40 40 "unicorn/prevent-abbreviations": "off", 41 41 "unicorn/prefer-ternary": "off", 42 + "unicorn/switch-case-braces": "warn", 42 43 "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 43 44 }, 44 45 },
+5 -3
src/components/ProfileSkeleton.tsx
··· 1 + import { For } from "solid-js"; 2 + 1 3 export function ProfileSkeleton() { 2 4 return ( 3 5 <div class="grid gap-[0.85rem]" aria-hidden="true"> 4 6 <span class="skeleton-block h-18 w-18 rounded-full" /> 5 - <span class="skeleton-block h-[0.85rem] w-[min(16rem,80%)] rounded-full" /> 6 - <span class="skeleton-block h-[0.85rem] w-[min(11rem,64%)] rounded-full" /> 7 - <span class="skeleton-block h-[0.85rem] w-[min(9rem,48%)] rounded-full" /> 7 + <For each={["w-[min(16rem,80%)]", "w-[min(11rem,64%)]", "w-[min(9rem,48%)]"]}> 8 + {w => <span class={`skeleton-block h-[0.85rem] ${w} rounded-full`} />} 9 + </For> 8 10 </div> 9 11 ); 10 12 }
+14 -3
src/components/deck/DeckColumn.tsx
··· 170 170 ); 171 171 } 172 172 173 - function ColumnBody(props: { column: Column; feedColumn?: ResolvedFeedColumn; onOpenThread: (uri: string) => void }) { 173 + function ColumnBody( 174 + props: { 175 + column: Column; 176 + feedColumn?: ResolvedFeedColumn; 177 + onClose: (id: string) => void; 178 + onOpenThread: (uri: string) => void; 179 + }, 180 + ) { 174 181 const diagnosticsConfig = () => parseDiagnosticsConfig(props.column.config); 175 182 const searchConfig = () => parseSearchConfig(props.column.config); 176 183 const profileConfig = () => parseProfileConfig(props.column.config); ··· 186 193 </div> 187 194 </Match> 188 195 <Match when={props.column.kind === "diagnostics"}> 189 - <DiagnosticsColumn did={diagnosticsConfig()?.did ?? ""} /> 196 + <DiagnosticsColumn did={diagnosticsConfig()?.did ?? ""} onClose={() => props.onClose(props.column.id)} /> 190 197 </Match> 191 198 <Match when={props.column.kind === "messages"}> 192 199 <BlurredMessagesBody /> ··· 252 259 onMoveRight={() => props.onMoveRight(props.column.id)} 253 260 onWidthCycle={() => props.onWidthChange(props.column.id, cycleWidth(props.column.width))} /> 254 261 <div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]"> 255 - <ColumnBody column={props.column} feedColumn={props.feedColumn} onOpenThread={props.onOpenThread} /> 262 + <ColumnBody 263 + column={props.column} 264 + feedColumn={props.feedColumn} 265 + onClose={props.onClose} 266 + onOpenThread={props.onOpenThread} /> 256 267 </div> 257 268 </section> 258 269 );
+4 -16
src/components/deck/DiagnosticsColumn.tsx
··· 1 - type DiagnosticsColumnProps = { did: string }; 1 + import { DiagnosticsPanel } from "./DiagnosticsPanel"; 2 2 3 - /** 4 - * @todo implement this 5 - */ 3 + type DiagnosticsColumnProps = { did: string; onClose?: () => void }; 4 + 6 5 export function DiagnosticsColumn(props: DiagnosticsColumnProps) { 7 - return ( 8 - <div class="flex min-h-0 flex-col items-center justify-center gap-3 px-6 py-12 text-center"> 9 - <span class="flex items-center justify-center text-[2rem] text-on-surface-variant"> 10 - <i class="i-ri-stethoscope-line" /> 11 - </span> 12 - <p class="m-0 text-sm font-medium text-on-surface">Social Diagnostics</p> 13 - <p class="m-0 text-xs text-on-surface-variant"> 14 - Diagnostics for <span class="font-mono text-primary">{props.did}</span>. 15 - </p> 16 - <p class="m-0 text-xs text-on-surface-variant opacity-60">Full diagnostics panel coming soon.</p> 17 - </div> 18 - ); 6 + return <DiagnosticsPanel did={props.did} onClose={props.onClose ?? (() => void 0)} />; 19 7 }
+116
src/components/deck/DiagnosticsPanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { DiagnosticsPanel } from "./DiagnosticsPanel"; 5 + 6 + const getAccountListsMock = vi.hoisted(() => vi.fn()); 7 + const getAccountLabelsMock = vi.hoisted(() => vi.fn()); 8 + const getAccountBlockedByMock = vi.hoisted(() => vi.fn()); 9 + const getAccountBlockingMock = vi.hoisted(() => vi.fn()); 10 + const getAccountStarterPacksMock = vi.hoisted(() => vi.fn()); 11 + 12 + vi.mock( 13 + "$/lib/api/diagnostics", 14 + () => ({ 15 + getAccountBlockedBy: getAccountBlockedByMock, 16 + getAccountBlocking: getAccountBlockingMock, 17 + getAccountLabels: getAccountLabelsMock, 18 + getAccountLists: getAccountListsMock, 19 + getAccountStarterPacks: getAccountStarterPacksMock, 20 + }), 21 + ); 22 + 23 + function renderPanel() { 24 + render(() => ( 25 + <AppTestProviders session={{ activeDid: "did:plc:test", activeHandle: "test.bsky.social" }}> 26 + <DiagnosticsPanel did="did:plc:test" onClose={vi.fn()} /> 27 + </AppTestProviders> 28 + )); 29 + } 30 + 31 + describe("DiagnosticsPanel", () => { 32 + beforeEach(() => { 33 + getAccountListsMock.mockReset(); 34 + getAccountLabelsMock.mockReset(); 35 + getAccountBlockedByMock.mockReset(); 36 + getAccountBlockingMock.mockReset(); 37 + getAccountStarterPacksMock.mockReset(); 38 + 39 + getAccountListsMock.mockResolvedValue({ 40 + lists: [{ 41 + description: "Builders and product people.", 42 + memberCount: 12, 43 + purpose: "curate", 44 + title: "Builders", 45 + creator: { handle: "mira.test" }, 46 + }, { 47 + description: "Moderation boundary set.", 48 + listItemCount: 5, 49 + purpose: "modlist", 50 + title: "Safety", 51 + creator: { handle: "safety.test" }, 52 + }], 53 + total: 2, 54 + truncated: false, 55 + }); 56 + getAccountLabelsMock.mockResolvedValue({ 57 + labels: [{ src: "did:plc:labeler", val: "!hide" }], 58 + sourceProfiles: {}, 59 + cursor: null, 60 + }); 61 + getAccountBlockedByMock.mockResolvedValue({ 62 + cursor: null, 63 + items: [{ did: "did:plc:blocker", profile: { handle: "blocker.test" } }], 64 + total: 1, 65 + }); 66 + getAccountBlockingMock.mockResolvedValue({ 67 + cursor: null, 68 + items: [{ subjectDid: "did:plc:boundary", profile: { handle: "boundary.test" } }], 69 + }); 70 + getAccountStarterPacksMock.mockResolvedValue({ 71 + starterPacks: [{ 72 + creator: { handle: "packer.test" }, 73 + description: "Starter pack desc.", 74 + listItemCount: 8, 75 + title: "Newcomers", 76 + }], 77 + total: 1, 78 + truncated: false, 79 + }); 80 + }); 81 + 82 + it("renders the tab shell and switches tabs with keys", async () => { 83 + renderPanel(); 84 + 85 + expect(await screen.findByText("Social Diagnostics")).toBeInTheDocument(); 86 + expect(screen.getByRole("button", { name: "Lists" })).toHaveAttribute("aria-pressed", "true"); 87 + 88 + fireEvent.keyDown(document, { key: "2" }); 89 + expect(screen.getByRole("button", { name: "Labels" })).toBeInTheDocument(); 90 + fireEvent.keyDown(document, { key: "Escape" }); 91 + }); 92 + 93 + it("groups lists and shows neutral labels", async () => { 94 + renderPanel(); 95 + 96 + fireEvent.click(await screen.findByRole("button", { name: "Lists" })); 97 + expect(screen.getAllByText("Curation").length).toBeGreaterThan(0); 98 + expect(screen.getByText("Builders")).toBeInTheDocument(); 99 + expect(screen.getAllByText("Moderation").length).toBeGreaterThan(0); 100 + }); 101 + 102 + it("shows blocks and starter packs with progressive disclosure", async () => { 103 + renderPanel(); 104 + 105 + fireEvent.click(await screen.findByRole("button", { name: "Blocks" })); 106 + expect(await screen.findByText("Blocked by")).toBeInTheDocument(); 107 + expect(screen.getByRole("button", { name: /show details/i })).toBeInTheDocument(); 108 + 109 + fireEvent.click(screen.getByRole("button", { name: /show details/i })); 110 + await waitFor(() => expect(screen.getAllByText("blocker.test").length).toBeGreaterThan(0)); 111 + 112 + fireEvent.click(screen.getByRole("button", { name: "Starter Packs" })); 113 + expect(await screen.findByText("Newcomers")).toBeInTheDocument(); 114 + expect(screen.getByText("8 members")).toBeInTheDocument(); 115 + }); 116 + });
+697
src/components/deck/DiagnosticsPanel.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 2 + import { 3 + type DiagnosticBlockItem, 4 + type DiagnosticDidProfile, 5 + type DiagnosticLabel, 6 + type DiagnosticList, 7 + type DiagnosticStarterPack, 8 + getAccountBlockedBy, 9 + getAccountBlocking, 10 + getAccountLabels, 11 + getAccountLists, 12 + getAccountStarterPacks, 13 + } from "$/lib/api/diagnostics"; 14 + import { normalizeError } from "$/lib/utils/text"; 15 + import * as logger from "@tauri-apps/plugin-log"; 16 + import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 17 + import { createStore } from "solid-js/store"; 18 + import { Motion, Presence } from "solid-motionone"; 19 + import { Icon } from "../shared/Icon"; 20 + 21 + type DiagnosticsTab = "lists" | "labels" | "blocks" | "starterPacks" | "backlinks"; 22 + 23 + type DiagnosticsPanelProps = { did: string; onClose: () => void }; 24 + 25 + type DiagnosticsState = { 26 + lists: DiagnosticList[]; 27 + listsError: string | null; 28 + listsLoading: boolean; 29 + labels: DiagnosticLabel[]; 30 + labelsError: string | null; 31 + labelsLoading: boolean; 32 + blockedBy: DiagnosticDidProfile[]; 33 + blockedByError: string | null; 34 + blockedByLoading: boolean; 35 + blocking: DiagnosticBlockItem[]; 36 + blockingError: string | null; 37 + blockingLoading: boolean; 38 + starterPacks: DiagnosticStarterPack[]; 39 + starterPacksError: string | null; 40 + starterPacksLoading: boolean; 41 + }; 42 + 43 + const DIAGNOSTICS_TABS: Array<{ label: string; value: DiagnosticsTab }> = [ 44 + { label: "Lists", value: "lists" }, 45 + { label: "Labels", value: "labels" }, 46 + { label: "Blocks", value: "blocks" }, 47 + { label: "Starter Packs", value: "starterPacks" }, 48 + { label: "Backlinks", value: "backlinks" }, 49 + ]; 50 + 51 + function createInitialState(): DiagnosticsState { 52 + return { 53 + blockedBy: [], 54 + blockedByError: null, 55 + blockedByLoading: true, 56 + blocking: [], 57 + blockingError: null, 58 + blockingLoading: true, 59 + labels: [], 60 + labelsError: null, 61 + labelsLoading: true, 62 + lists: [], 63 + listsError: null, 64 + listsLoading: true, 65 + starterPacks: [], 66 + starterPacksError: null, 67 + starterPacksLoading: true, 68 + }; 69 + } 70 + 71 + function purposeLabel(purpose: string | null | undefined) { 72 + switch ((purpose || "").toLowerCase()) { 73 + case "curate": 74 + case "curation": { 75 + return "Curation"; 76 + } 77 + case "modlist": 78 + case "moderation": { 79 + return "Moderation"; 80 + } 81 + case "reference": { 82 + return "Reference"; 83 + } 84 + default: { 85 + return "Other"; 86 + } 87 + } 88 + } 89 + 90 + function groupListsByPurpose(lists: DiagnosticList[]) { 91 + const grouped = [ 92 + { label: "Curation", items: lists.filter((list) => purposeLabel(list.purpose) === "Curation") }, 93 + { label: "Moderation", items: lists.filter((list) => purposeLabel(list.purpose) === "Moderation") }, 94 + { label: "Reference", items: lists.filter((list) => purposeLabel(list.purpose) === "Reference") }, 95 + { 96 + label: "Other", 97 + items: lists.filter((list) => purposeLabel(list.purpose) === "Other"), 98 + }, 99 + ].filter((group) => group.items.length > 0); 100 + 101 + return grouped.length > 0 ? grouped : [{ label: "Lists", items: lists }]; 102 + } 103 + 104 + function initials(name: string) { 105 + return name.trim().slice(0, 1).toUpperCase() || "?"; 106 + } 107 + 108 + export function DiagnosticsPanel(props: DiagnosticsPanelProps) { 109 + const session = useAppSession(); 110 + const [state, setState] = createStore<DiagnosticsState>(createInitialState()); 111 + const [activeTab, setActiveTab] = createSignal<DiagnosticsTab>("lists"); 112 + const [blocksExpanded, setBlocksExpanded] = createSignal(false); 113 + const activeDid = createMemo(() => props.did.trim() || session.activeDid || ""); 114 + const isSelf = createMemo(() => activeDid() === session.activeDid); 115 + let requestId = 0; 116 + 117 + createEffect(() => { 118 + const did = activeDid(); 119 + if (!did) { 120 + return; 121 + } 122 + 123 + const currentRequest = ++requestId; 124 + setActiveTab("lists"); 125 + setBlocksExpanded(false); 126 + setState(createInitialState()); 127 + 128 + void loadLists(currentRequest, did); 129 + void loadLabels(currentRequest, did); 130 + void loadBlocks(currentRequest, did); 131 + void loadStarterPacks(currentRequest, did); 132 + }); 133 + 134 + function handleKeyDown(event: KeyboardEvent) { 135 + if (event.key >= "1" && event.key <= "5" && !event.metaKey && !event.ctrlKey && !event.altKey) { 136 + event.preventDefault(); 137 + const nextTab = DIAGNOSTICS_TABS[Number(event.key) - 1]?.value; 138 + if (nextTab) { 139 + setActiveTab(nextTab); 140 + } 141 + } 142 + 143 + if (event.key === "Escape") { 144 + props.onClose(); 145 + } 146 + } 147 + 148 + onMount(() => { 149 + document.addEventListener("keydown", handleKeyDown); 150 + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)); 151 + }); 152 + 153 + async function loadLists(currentRequest: number, did: string) { 154 + try { 155 + const response = await getAccountLists(did); 156 + if (currentRequest !== requestId) return; 157 + setState({ lists: response.lists, listsError: null, listsLoading: false }); 158 + } catch (error) { 159 + const message = normalizeError(error); 160 + if (currentRequest !== requestId) return; 161 + setState({ listsError: message, listsLoading: false }); 162 + logger.warn("failed to load diagnostics lists", { keyValues: { did, error: message } }); 163 + } 164 + } 165 + 166 + async function loadLabels(currentRequest: number, did: string) { 167 + try { 168 + const response = await getAccountLabels(did); 169 + if (currentRequest !== requestId) return; 170 + setState({ labels: response.labels, labelsError: null, labelsLoading: false }); 171 + } catch (error) { 172 + const message = normalizeError(error); 173 + if (currentRequest !== requestId) return; 174 + setState({ labelsError: message, labelsLoading: false }); 175 + logger.warn("failed to load diagnostics labels", { keyValues: { did, error: message } }); 176 + } 177 + } 178 + 179 + async function loadBlocks(currentRequest: number, did: string) { 180 + try { 181 + const [blockedBy, blocking] = await Promise.all([getAccountBlockedBy(did, 25), getAccountBlocking(did)]); 182 + if (currentRequest !== requestId) return; 183 + setState({ 184 + blockedBy: blockedBy.items, 185 + blockedByError: null, 186 + blockedByLoading: false, 187 + blocking: blocking.items, 188 + blockingError: null, 189 + blockingLoading: false, 190 + }); 191 + } catch (error) { 192 + const message = normalizeError(error); 193 + if (currentRequest !== requestId) return; 194 + setState({ blockedByError: message, blockedByLoading: false, blockingError: message, blockingLoading: false }); 195 + logger.warn("failed to load diagnostics blocks", { keyValues: { did, error: message } }); 196 + } 197 + } 198 + 199 + async function loadStarterPacks(currentRequest: number, did: string) { 200 + try { 201 + const response = await getAccountStarterPacks(did); 202 + if (currentRequest !== requestId) return; 203 + setState({ starterPacks: response.starterPacks, starterPacksError: null, starterPacksLoading: false }); 204 + } catch (error) { 205 + const message = normalizeError(error); 206 + if (currentRequest !== requestId) return; 207 + setState({ starterPacksError: message, starterPacksLoading: false }); 208 + logger.warn("failed to load diagnostics starter packs", { keyValues: { did, error: message } }); 209 + } 210 + } 211 + 212 + return ( 213 + <article class="grid min-h-0 grid-rows-[auto_auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 214 + <DiagnosticsHeader did={activeDid()} isSelf={isSelf()} onClose={props.onClose} /> 215 + <DiagnosticsTabs activeTab={activeTab()} onSelectTab={setActiveTab} /> 216 + <DiagnosticsViewport 217 + activeTab={activeTab()} 218 + blocksExpanded={blocksExpanded()} 219 + onToggleBlocks={() => setBlocksExpanded((value) => !value)} 220 + state={state} /> 221 + </article> 222 + ); 223 + } 224 + 225 + function DiagnosticsHeader(props: { did: string; isSelf: boolean; onClose: () => void }) { 226 + return ( 227 + <header class="grid gap-4 px-6 pb-4 pt-6"> 228 + <div class="flex items-start justify-between gap-4"> 229 + <div class="grid gap-1"> 230 + <p class="overline-copy text-xs text-on-surface-variant">Context</p> 231 + <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Social Diagnostics</h1> 232 + <p class="m-0 text-sm text-on-surface-variant"> 233 + {props.isSelf ? "Your boundaries and footprint" : "Public social context for this account"} 234 + </p> 235 + </div> 236 + <button 237 + type="button" 238 + 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" 239 + onClick={() => props.onClose()} 240 + title="Close diagnostics panel"> 241 + <Icon kind="close" aria-hidden="true" /> 242 + </button> 243 + </div> 244 + 245 + <p class="m-0 break-all rounded-2xl bg-surface-container-high px-4 py-3 font-mono text-xs text-on-surface-variant"> 246 + {props.did || "No account selected"} 247 + </p> 248 + </header> 249 + ); 250 + } 251 + 252 + function DiagnosticsTabs(props: { activeTab: DiagnosticsTab; onSelectTab: (tab: DiagnosticsTab) => void }) { 253 + const activeIndex = createMemo(() => DIAGNOSTICS_TABS.findIndex((tab) => tab.value === props.activeTab)); 254 + 255 + return ( 256 + <nav class="px-3 pb-3" aria-label="Diagnostics tabs"> 257 + <div class="relative flex gap-1 rounded-full bg-black/30 p-1"> 258 + <Motion.div 259 + class="absolute inset-y-1 rounded-full bg-white/7 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)]" 260 + animate={{ x: `${activeIndex() * 100}%` }} 261 + style={{ width: `${100 / DIAGNOSTICS_TABS.length}%` }} 262 + transition={{ duration: 0.18 }} /> 263 + <For each={DIAGNOSTICS_TABS}> 264 + {(tab) => ( 265 + <button 266 + type="button" 267 + aria-pressed={props.activeTab === tab.value} 268 + class="relative z-10 flex-1 rounded-full px-3 py-2 text-sm font-medium transition duration-150" 269 + classList={{ 270 + "text-on-surface": props.activeTab === tab.value, 271 + "text-on-surface-variant": props.activeTab !== tab.value, 272 + }} 273 + onClick={() => props.onSelectTab(tab.value)}> 274 + {tab.label} 275 + </button> 276 + )} 277 + </For> 278 + </div> 279 + </nav> 280 + ); 281 + } 282 + 283 + function DiagnosticsViewport( 284 + props: { activeTab: DiagnosticsTab; blocksExpanded: boolean; onToggleBlocks: () => void; state: DiagnosticsState }, 285 + ) { 286 + return ( 287 + <div class="min-h-0 overflow-y-auto px-3 pb-3"> 288 + <Presence> 289 + <Show when={props.activeTab === "lists"} keyed> 290 + <DiagnosticsListsTab 291 + lists={props.state.lists} 292 + error={props.state.listsError} 293 + loading={props.state.listsLoading} /> 294 + </Show> 295 + <Show when={props.activeTab === "labels"} keyed> 296 + <DiagnosticsLabelsTab 297 + labels={props.state.labels} 298 + error={props.state.labelsError} 299 + loading={props.state.labelsLoading} /> 300 + </Show> 301 + <Show when={props.activeTab === "blocks"} keyed> 302 + <DiagnosticsBlocksTab 303 + blockedBy={props.state.blockedBy} 304 + blockedByError={props.state.blockedByError} 305 + blockedByLoading={props.state.blockedByLoading} 306 + blocking={props.state.blocking} 307 + blockingError={props.state.blockingError} 308 + blockingLoading={props.state.blockingLoading} 309 + expanded={props.blocksExpanded} 310 + onToggleExpanded={props.onToggleBlocks} /> 311 + </Show> 312 + <Show when={props.activeTab === "starterPacks"} keyed> 313 + <DiagnosticsStarterPacksTab 314 + error={props.state.starterPacksError} 315 + loading={props.state.starterPacksLoading} 316 + starterPacks={props.state.starterPacks} /> 317 + </Show> 318 + <Show when={props.activeTab === "backlinks"} keyed> 319 + <DiagnosticsBacklinksTab /> 320 + </Show> 321 + </Presence> 322 + </div> 323 + ); 324 + } 325 + 326 + function DiagnosticsListsTab(props: { lists: DiagnosticList[]; error: string | null; loading: boolean }) { 327 + return ( 328 + <section class="grid gap-3"> 329 + <DiagnosticsTabIntro 330 + title="Lists" 331 + description="Lists are ordinary social structure. The view keeps purpose and membership context visible without scoring or judgment." /> 332 + <Switch 333 + fallback={ 334 + <div class="grid gap-4"> 335 + <For 336 + each={groupListsByPurpose(props.lists)} 337 + fallback={ 338 + <DiagnosticsEmptyState copy="Lists explain where this account appears in the network. There may simply be none yet." /> 339 + }> 340 + {(group) => ( 341 + <div class="grid gap-3"> 342 + <p class="m-0 text-xs uppercase tracking-[0.14em] text-on-surface-variant">{group.label}</p> 343 + <div class="grid gap-3"> 344 + <For each={group.items}>{(list) => <ListCard list={list} />}</For> 345 + </div> 346 + </div> 347 + )} 348 + </For> 349 + </div> 350 + }> 351 + <Match when={props.loading}> 352 + <DiagnosticsListSkeleton /> 353 + </Match> 354 + <Match when={props.error}>{error => <DiagnosticsError message={error()} />}</Match> 355 + </Switch> 356 + </section> 357 + ); 358 + } 359 + 360 + function DiagnosticsLabelsTab(props: { labels: DiagnosticLabel[]; error: string | null; loading: boolean }) { 361 + return ( 362 + <section class="grid gap-3"> 363 + <DiagnosticsTabIntro 364 + title="Labels" 365 + description="Labels are moderation metadata from labeling services. They are shown as uniform chips with source attribution." /> 366 + <Switch 367 + fallback={ 368 + <Motion.div 369 + class="flex flex-wrap gap-2" 370 + initial={{ opacity: 0, scale: 0.98 }} 371 + animate={{ opacity: 1, scale: 1 }} 372 + transition={{ duration: 0.16 }}> 373 + <For 374 + fallback={ 375 + <DiagnosticsEmptyState copy="Labels are service-applied metadata that can affect visibility. This account currently has no visible labels." /> 376 + } 377 + each={props.labels}> 378 + {(label, index) => <LabelChip label={label} index={index()} />} 379 + </For> 380 + </Motion.div> 381 + }> 382 + <Match when={props.loading}> 383 + <DiagnosticsLabelSkeleton /> 384 + </Match> 385 + <Match when={props.error}>{error => <DiagnosticsError message={error()} />}</Match> 386 + </Switch> 387 + </section> 388 + ); 389 + } 390 + 391 + function DiagnosticsBlocksTab( 392 + props: { 393 + blockedBy: DiagnosticDidProfile[]; 394 + blockedByError: string | null; 395 + blockedByLoading: boolean; 396 + blocking: DiagnosticBlockItem[]; 397 + blockingError: string | null; 398 + blockingLoading: boolean; 399 + expanded: boolean; 400 + onToggleExpanded: () => void; 401 + }, 402 + ) { 403 + const blockedByCount = createMemo(() => props.blockedBy.length); 404 + const blockingCount = createMemo(() => props.blocking.length); 405 + 406 + return ( 407 + <section class="grid gap-3"> 408 + <DiagnosticsTabIntro 409 + title={"Blocks"} 410 + description={"Blocking is a normal boundary. Counts are shown first; details are revealed only on request."} /> 411 + <div class="grid gap-3 sm:grid-cols-2"> 412 + <StatCard label="Blocked by" value={blockedByCount()} /> 413 + <StatCard label="Blocking" value={blockingCount()} /> 414 + </div> 415 + <div class="rounded-3xl bg-white/3 p-4 text-sm leading-relaxed text-on-surface-variant"> 416 + {"Blocks are a normal part of social media. This data is public on the AT Protocol."} 417 + </div> 418 + <button 419 + type="button" 420 + 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" 421 + onClick={() => props.onToggleExpanded()}> 422 + <Icon kind={props.expanded ? "close" : "list"} aria-hidden="true" /> 423 + {props.expanded ? "Hide details" : "Show details"} 424 + </button> 425 + 426 + <Presence> 427 + <Show when={props.expanded}> 428 + <Motion.div 429 + class="grid gap-4" 430 + initial={{ opacity: 0, height: 0 }} 431 + animate={{ opacity: 1, height: "auto" }} 432 + exit={{ opacity: 0, height: 0 }} 433 + transition={{ duration: 0.18 }}> 434 + <DiagnosticsBlock 435 + kind="blockedBy" 436 + items={props.blockedBy} 437 + loading={props.blockedByLoading} 438 + error={props.blockedByError} /> 439 + <DiagnosticsBlock 440 + kind="blocking" 441 + items={props.blocking} 442 + loading={props.blockingLoading} 443 + error={props.blockingError} /> 444 + </Motion.div> 445 + </Show> 446 + </Presence> 447 + </section> 448 + ); 449 + } 450 + 451 + function DiagnosticsBlock( 452 + props: { 453 + kind: "blockedBy" | "blocking"; 454 + items: DiagnosticBlockItem[] | DiagnosticDidProfile[]; 455 + loading: boolean; 456 + error: string | null; 457 + }, 458 + ) { 459 + const items = createMemo(() => 460 + props.items.map(item => ({ 461 + avatar: item.profile?.avatar ?? null, 462 + description: item.profile?.description ?? null, 463 + displayName: item.profile?.displayName ?? null, 464 + handle: (item.profile?.handle ?? item.profile?.did) ?? "Unknown", 465 + })) 466 + ); 467 + 468 + return ( 469 + <Switch 470 + fallback={ 471 + <BlockProfileList title={props.kind === "blockedBy" ? "Blocked by" : "Your boundaries"} items={items()} /> 472 + }> 473 + <Match when={props.loading}> 474 + <DiagnosticsBlockSkeleton /> 475 + </Match> 476 + <Match when={props.error}>{error => <DiagnosticsError message={error()} />}</Match> 477 + </Switch> 478 + ); 479 + } 480 + 481 + function DiagnosticsStarterPacksTab( 482 + props: { starterPacks: DiagnosticStarterPack[]; error: string | null; loading: boolean }, 483 + ) { 484 + return ( 485 + <section class="grid gap-3"> 486 + <DiagnosticsTabIntro 487 + title="Starter Packs" 488 + description="Starter packs show how people are discovering this account. They stay compact and factual." /> 489 + <Switch 490 + fallback={ 491 + <div class="grid gap-3"> 492 + <For 493 + each={props.starterPacks} 494 + fallback={ 495 + <DiagnosticsEmptyState copy="Starter packs are discovery context and may not exist for every account." /> 496 + }> 497 + {(pack) => <StarterPackCard pack={pack} />} 498 + </For> 499 + </div> 500 + }> 501 + <Match when={props.loading}> 502 + <DiagnosticsStarterPackSkeleton /> 503 + </Match> 504 + <Match when={props.error}> 505 + <DiagnosticsError message={props.error} /> 506 + </Match> 507 + </Switch> 508 + </section> 509 + ); 510 + } 511 + 512 + function DiagnosticsBacklinksTab() { 513 + return ( 514 + <section class="grid gap-3"> 515 + <DiagnosticsTabIntro 516 + title="Backlinks" 517 + description="Record backlinks are shown in AT Explorer record view, grouped by likes, reposts, replies, and quotes." /> 518 + <div class="grid gap-3 sm:grid-cols-2"> 519 + <BacklinkPreviewCard title="Likes" copy="Record references tied to likes." /> 520 + <BacklinkPreviewCard title="Reposts" copy="Record references tied to reposts." /> 521 + <BacklinkPreviewCard title="Replies" copy="Direct replies to a record." /> 522 + <BacklinkPreviewCard title="Quotes" copy="Records that embed the URI." /> 523 + </div> 524 + </section> 525 + ); 526 + } 527 + 528 + function DiagnosticsTabIntro(props: { title: string; description: string }) { 529 + return ( 530 + <div class="grid gap-1 rounded-3xl bg-white/3 p-4"> 531 + <h2 class="m-0 text-base font-semibold text-on-surface">{props.title}</h2> 532 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.description}</p> 533 + </div> 534 + ); 535 + } 536 + 537 + function DiagnosticsError(props: { message: string | null }) { 538 + return <div class="rounded-3xl bg-white/3 p-4 text-sm text-on-surface-variant">{props.message}</div>; 539 + } 540 + 541 + function DiagnosticsEmptyState(props: { copy: string }) { 542 + return <div class="rounded-3xl bg-white/3 p-4 text-sm text-on-surface-variant">{props.copy}</div>; 543 + } 544 + 545 + function StatCard(props: { label: string; value: number }) { 546 + return ( 547 + <div class="rounded-3xl bg-white/3 p-4"> 548 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.label}</p> 549 + <p class="m-0 mt-2 text-3xl font-semibold text-on-surface">{props.value}</p> 550 + </div> 551 + ); 552 + } 553 + 554 + function LabelChip(props: { label: DiagnosticLabel; index: number }) { 555 + const copy = () => [props.label.val ?? "label", props.label.src ?? "unknown service"].join(" · "); 556 + 557 + return ( 558 + <Motion.span 559 + class="inline-flex items-center gap-2 rounded-full bg-white/5 px-3 py-2 text-sm text-on-surface-variant" 560 + initial={{ opacity: 0, scale: 0.9 }} 561 + animate={{ opacity: 1, scale: 1 }} 562 + title={copy()} 563 + transition={{ duration: 0.14, delay: Math.min(props.index * 0.02, 0.12) }}> 564 + <span class="h-2 w-2 rounded-full bg-white/25" /> 565 + <span>{props.label.val ?? "label"}</span> 566 + <span class="text-xs text-on-surface-variant/80">{props.label.src ?? "unknown service"}</span> 567 + </Motion.span> 568 + ); 569 + } 570 + 571 + function ListCard(props: { list: DiagnosticList }) { 572 + const title = () => props.list.title ?? props.list.name ?? "Untitled list"; 573 + const count = () => props.list.memberCount ?? props.list.listItemCount ?? 0; 574 + 575 + return ( 576 + <div class="rounded-3xl bg-white/3 p-4 transition duration-150 hover:bg-white/5"> 577 + <div class="flex items-start justify-between gap-4"> 578 + <div class="min-w-0"> 579 + <p class="m-0 text-base font-semibold text-on-surface">{title()}</p> 580 + <p class="m-0 mt-1 text-sm text-on-surface-variant"> 581 + {props.list.creator?.handle ? `@${props.list.creator.handle}` : "Unknown owner"} 582 + </p> 583 + <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant"> 584 + {props.list.description ?? "No description provided."} 585 + </p> 586 + </div> 587 + <div class="grid justify-items-end gap-2 shrink-0 text-right"> 588 + <span class="rounded-full bg-white/5 px-3 py-1 text-xs text-on-surface-variant"> 589 + {purposeLabel(props.list.purpose)} 590 + </span> 591 + <span class="text-xs text-on-surface-variant">{count()} members</span> 592 + </div> 593 + </div> 594 + </div> 595 + ); 596 + } 597 + 598 + function StarterPackCard(props: { pack: DiagnosticStarterPack }) { 599 + const title = () => props.pack.title ?? props.pack.name ?? props.pack.record?.name ?? "Starter pack"; 600 + const count = () => props.pack.listItemCount ?? props.pack.record?.listItemsSample?.length ?? 0; 601 + 602 + return ( 603 + <div class="rounded-3xl bg-white/3 p-4 transition duration-150 hover:bg-white/5"> 604 + <div class="flex items-start justify-between gap-4"> 605 + <div class="min-w-0"> 606 + <p class="m-0 text-base font-semibold text-on-surface">{title()}</p> 607 + <p class="m-0 mt-1 text-sm text-on-surface-variant"> 608 + {props.pack.creator?.handle ? `@${props.pack.creator.handle}` : "Unknown creator"} 609 + </p> 610 + <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant"> 611 + {props.pack.description ?? props.pack.record?.description ?? "No description provided."} 612 + </p> 613 + </div> 614 + <span class="rounded-full bg-white/5 px-3 py-1 text-xs text-on-surface-variant">{count()} members</span> 615 + </div> 616 + </div> 617 + ); 618 + } 619 + 620 + function BacklinkPreviewCard(props: { copy: string; title: string }) { 621 + return ( 622 + <div class="rounded-3xl bg-white/3 p-4"> 623 + <p class="m-0 text-base font-semibold text-on-surface">{props.title}</p> 624 + <p class="m-0 mt-2 text-sm text-on-surface-variant">{props.copy}</p> 625 + </div> 626 + ); 627 + } 628 + 629 + function BlockProfileList( 630 + props: { 631 + items: Array<{ avatar?: string | null; description?: string | null; displayName?: string | null; handle: string }>; 632 + title: string; 633 + }, 634 + ) { 635 + return ( 636 + <div class="grid gap-3 rounded-3xl bg-white/3 p-4"> 637 + <p class="m-0 text-sm font-semibold text-on-surface">{props.title}</p> 638 + <div class="grid gap-3"> 639 + <For each={props.items}> 640 + {(item) => { 641 + const name = () => item.displayName ?? item.handle; 642 + return ( 643 + <div class="flex items-start gap-3 rounded-2xl bg-black/20 p-3"> 644 + <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/8 text-xs font-semibold text-on-surface-variant"> 645 + <Show when={item.avatar} fallback={<span>{initials(name())}</span>}> 646 + {(src) => <img alt="" class="h-full w-full object-cover" src={src()} />} 647 + </Show> 648 + </div> 649 + <div class="min-w-0"> 650 + <p class="m-0 text-sm font-medium text-on-surface">{name()}</p> 651 + <p class="m-0 text-xs text-on-surface-variant">{item.handle}</p> 652 + <Show when={item.description}> 653 + {(description) => ( 654 + <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{description()}</p> 655 + )} 656 + </Show> 657 + </div> 658 + </div> 659 + ); 660 + }} 661 + </For> 662 + </div> 663 + </div> 664 + ); 665 + } 666 + 667 + function DiagnosticsListSkeleton() { 668 + return ( 669 + <div class="grid gap-4"> 670 + <For each={Array.from({ length: 3 })}>{() => <div class="h-28 rounded-3xl bg-white/3" />}</For> 671 + </div> 672 + ); 673 + } 674 + 675 + function DiagnosticsLabelSkeleton() { 676 + return ( 677 + <div class="flex flex-wrap gap-2"> 678 + <For each={Array.from({ length: 5 })}>{() => <div class="h-10 w-28 rounded-full bg-white/3" />}</For> 679 + </div> 680 + ); 681 + } 682 + 683 + function DiagnosticsStarterPackSkeleton() { 684 + return ( 685 + <div class="grid gap-3"> 686 + <For each={Array.from({ length: 2 })}>{() => <div class="h-24 rounded-3xl bg-white/3" />}</For> 687 + </div> 688 + ); 689 + } 690 + 691 + function DiagnosticsBlockSkeleton() { 692 + return ( 693 + <div class="grid gap-3"> 694 + <For each={Array.from({ length: 2 })}>{() => <div class="h-20 rounded-3xl bg-white/3" />}</For> 695 + </div> 696 + ); 697 + }
+1 -1
src/components/explorer/ExplorerPanel.tsx
··· 481 481 <div class="h-8 w-1/3 rounded-lg bg-white/5" /> 482 482 <div class="h-4 w-1/4 rounded bg-white/5" /> 483 483 <div class="grid gap-2 mt-4"> 484 - <For each={[1, 2, 3, 4, 5]}>{() => <div class="h-16 rounded-xl bg-white/5" />}</For> 484 + <For each={Array.from({ length: 5 })}>{() => <div class="h-16 rounded-xl bg-white/5" />}</For> 485 485 </div> 486 486 </div> 487 487 );
+45 -47
src/components/feeds/FeedDrawer.tsx
··· 1 - import { Icon } from "$/components/shared/Icon"; 1 + import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 2 import { getFeedName } from "$/lib/feeds"; 3 3 import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 4 4 import { For, Show } from "solid-js"; ··· 48 48 ) { 49 49 return ( 50 50 <> 51 - <PinnedFeedsSection {...props} /> 52 - <UnpinnedFeedsSection {...props} /> 51 + <Show when={props.pinnedFeeds.length > 0}> 52 + <PinnedFeedsSection {...props} /> 53 + </Show> 54 + <Show when={props.drawerFeeds.length > 0}> 55 + <UnpinnedFeedsSection {...props} /> 56 + </Show> 53 57 <Show when={props.pinnedFeeds.length === 0 && props.drawerFeeds.length === 0}> 54 58 <p class="mt-8 text-center text-sm text-on-surface-variant">No saved feeds yet.</p> 55 59 </Show> ··· 67 71 }, 68 72 ) { 69 73 return ( 70 - <Show when={props.pinnedFeeds.length > 0}> 71 - <div class="mt-6"> 72 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Pinned Feeds</p> 73 - <div class="mt-3 grid gap-2"> 74 - <For each={props.pinnedFeeds}> 75 - {(feed, index) => ( 76 - <DrawerPinnedFeedRow 77 - feed={feed} 78 - generator={props.generators[feed.value]} 79 - index={index()} 80 - isFirst={index() === 0} 81 - isLast={index() === props.pinnedFeeds.length - 1} 82 - onSelect={() => props.onSelectFeed(feed.id)} 83 - onUnpin={() => props.onUnpinFeed(feed.id)} 84 - onMoveUp={() => props.onReorderPinned(feed.id, "up")} 85 - onMoveDown={() => props.onReorderPinned(feed.id, "down")} /> 86 - )} 87 - </For> 88 - </div> 74 + <div class="mt-6"> 75 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Pinned Feeds</p> 76 + <div class="mt-3 grid gap-2"> 77 + <For each={props.pinnedFeeds}> 78 + {(feed, index) => ( 79 + <DrawerPinnedFeedRow 80 + feed={feed} 81 + generator={props.generators[feed.value]} 82 + index={index()} 83 + isFirst={index() === 0} 84 + isLast={index() === props.pinnedFeeds.length - 1} 85 + onSelect={() => props.onSelectFeed(feed.id)} 86 + onUnpin={() => props.onUnpinFeed(feed.id)} 87 + onMoveUp={() => props.onReorderPinned(feed.id, "up")} 88 + onMoveDown={() => props.onReorderPinned(feed.id, "down")} /> 89 + )} 90 + </For> 89 91 </div> 90 - </Show> 92 + </div> 91 93 ); 92 94 } 93 95 ··· 100 102 }, 101 103 ) { 102 104 return ( 103 - <Show when={props.drawerFeeds.length > 0}> 104 - <div class="mt-6"> 105 - <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Saved Feeds</p> 106 - <div class="mt-3 grid gap-2"> 107 - <For each={props.drawerFeeds}> 108 - {(feed) => ( 109 - <DrawerUnpinnedFeedRow 110 - feed={feed} 111 - generator={props.generators[feed.value]} 112 - onSelect={() => props.onSelectFeed(feed.id)} 113 - onPin={() => props.onPinFeed(feed.id)} /> 114 - )} 115 - </For> 116 - </div> 105 + <div class="mt-6"> 106 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">Saved Feeds</p> 107 + <div class="mt-3 grid gap-2"> 108 + <For each={props.drawerFeeds}> 109 + {(feed) => ( 110 + <DrawerUnpinnedFeedRow 111 + feed={feed} 112 + generator={props.generators[feed.value]} 113 + onSelect={() => props.onSelectFeed(feed.id)} 114 + onPin={() => props.onPinFeed(feed.id)} /> 115 + )} 116 + </For> 117 117 </div> 118 - </Show> 118 + </div> 119 119 ); 120 120 } 121 121 ··· 149 149 onMoveDown: () => void; 150 150 }, 151 151 ) { 152 + const feedName = () => getFeedName(props.feed, props.generator?.displayName); 152 153 return ( 153 154 <div class="flex items-center gap-2 rounded-2xl bg-white/4 px-3 py-3 transition duration-150 ease-out hover:bg-white/6"> 154 155 <button class="flex min-w-0 flex-1 items-center gap-3 text-left" type="button" onClick={() => props.onSelect()}> 155 156 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 156 157 <div class="min-w-0 flex-1"> 157 - <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface"> 158 - {getFeedName(props.feed, props.generator?.displayName)} 159 - </p> 158 + <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface">{feedName()}</p> 160 159 <p class="m-0 break-all text-xs text-on-surface-variant">{props.feed.value}</p> 161 160 </div> 162 161 </button> ··· 167 166 disabled={props.isFirst} 168 167 title="Move up" 169 168 onClick={() => props.onMoveUp()}> 170 - <Icon aria-hidden="true" iconClass="i-ri-arrow-up-line" /> 169 + <ArrowIcon direction="up" aria-hidden="true" /> 171 170 </button> 172 171 <button 173 172 class="inline-flex h-8 w-8 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface disabled:opacity-30" ··· 175 174 disabled={props.isLast} 176 175 title="Move down" 177 176 onClick={() => props.onMoveDown()}> 178 - <Icon aria-hidden="true" iconClass="i-ri-arrow-down-line" /> 177 + <ArrowIcon direction="down" aria-hidden="true" /> 179 178 </button> 180 179 <button 181 180 class="inline-flex h-8 w-8 items-center justify-center rounded-lg border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-primary" 182 181 type="button" 183 182 title="Unpin from tabs" 184 183 onClick={() => props.onUnpin()}> 185 - <Icon aria-hidden="true" iconClass="i-ri-unpin-line" /> 184 + <Icon aria-hidden="true" kind="unpin" /> 186 185 </button> 187 186 </div> 188 187 </div> ··· 192 191 function DrawerUnpinnedFeedRow( 193 192 props: { feed: SavedFeedItem; generator?: FeedGeneratorView; onSelect: () => void; onPin: () => void }, 194 193 ) { 194 + const feedName = () => getFeedName(props.feed, props.generator?.displayName); 195 195 return ( 196 196 <div class="flex items-center gap-2 rounded-2xl bg-white/4 px-3 py-3 transition duration-150 ease-out hover:bg-white/6"> 197 197 <button class="flex min-w-0 flex-1 items-center gap-3 text-left" type="button" onClick={() => props.onSelect()}> 198 198 <FeedChipAvatar feed={props.feed} generator={props.generator} /> 199 199 <div class="min-w-0 flex-1"> 200 - <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface"> 201 - {getFeedName(props.feed, props.generator?.displayName)} 202 - </p> 200 + <p class="m-0 truncate text-[0.88rem] font-semibold text-on-surface">{feedName()}</p> 203 201 <p class="m-0 break-all text-xs text-on-surface-variant">{props.feed.value}</p> 204 202 </div> 205 203 </button>
+5 -7
src/components/feeds/FeedEmpty.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 - import { Show } from "solid-js"; 2 + import { For, Show } from "solid-js"; 3 3 4 4 export function LoadingMoreIndicator(props: { loading: boolean }) { 5 5 return ( ··· 31 31 <div class="min-w-0 flex-1"> 32 32 <div class="skeleton-block h-4 w-48 rounded-full" /> 33 33 <div class="mt-3 grid gap-2"> 34 - <div class="skeleton-block h-3.5 w-full rounded-full" /> 35 - <div class="skeleton-block h-3.5 w-[88%] rounded-full" /> 36 - <div class="skeleton-block h-3.5 w-[70%] rounded-full" /> 34 + <For each={["w-full", "w-[90%]", "w-[95%]"]}> 35 + {w => <span class={`skeleton-block h-3.5 ${w} rounded-full`} />} 36 + </For> 37 37 </div> 38 38 </div> 39 39 </div> ··· 44 44 export function FeedSkeleton() { 45 45 return ( 46 46 <div class="grid gap-3"> 47 - <SkeletonCard /> 48 - <SkeletonCard /> 49 - <SkeletonCard /> 47 + <For each={Array.from({ length: 3 })}>{() => <SkeletonCard />}</For> 50 48 </div> 51 49 ); 52 50 }
+40 -38
src/components/profile/ProfileActorList.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 1 2 import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 2 3 import type { ProfileViewBasic } from "$/lib/types"; 3 4 import { createMemo, For, onMount, Show } from "solid-js"; 4 5 import { Motion } from "solid-motionone"; 5 - import { Icon } from "../shared/Icon"; 6 6 import type { ActorListState } from "./profile-state"; 7 7 8 8 function ActorListHeader(props: { onClose: () => void; title: string }) { ··· 30 30 disabled={props.loadingMore} 31 31 type="button" 32 32 onClick={() => props.onLoadMore()}> 33 - <Show when={props.loadingMore}> 34 - <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 33 + <Show when={props.loadingMore} fallback={<span>Load more</span>}> 34 + <> 35 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 36 + <span>Loading...</span> 37 + </> 35 38 </Show> 36 - {props.loadingMore ? "Loading..." : "Load more"} 37 39 </button> 38 40 </div> 39 41 ); 40 42 } 41 43 42 - function ActorListContent( 43 - props: { 44 - actorList: ActorListState; 45 - onFollowActor: (actor: ProfileViewBasic) => void; 46 - onSelectActor: (actor: ProfileViewBasic) => void; 47 - onUnfollowActor: (actor: ProfileViewBasic) => void; 48 - sessionDid: string | null; 49 - title: string; 50 - }, 51 - ) { 44 + type ActorListContentProps = { 45 + actorList: ActorListState; 46 + onFollowActor: (actor: ProfileViewBasic) => void; 47 + onSelectActor: (actor: ProfileViewBasic) => void; 48 + onUnfollowActor: (actor: ProfileViewBasic) => void; 49 + sessionDid: string | null; 50 + title: string; 51 + }; 52 + 53 + function ActorListContent(props: ActorListContentProps) { 52 54 return ( 53 55 <Show when={!props.actorList.loading} fallback={<ActorListSkeleton />}> 54 56 <Show ··· 82 84 ); 83 85 } 84 86 85 - function ActorCard( 86 - props: { 87 - actor: ProfileViewBasic; 88 - followLoading: boolean; 89 - isSelf: boolean; 90 - onFollow: () => void; 91 - onSelect: () => void; 92 - onUnfollow: () => void; 93 - }, 94 - ) { 87 + type ActorCardProps = { 88 + actor: ProfileViewBasic; 89 + followLoading: boolean; 90 + isSelf: boolean; 91 + onFollow: () => void; 92 + onSelect: () => void; 93 + onUnfollow: () => void; 94 + }; 95 + 96 + function ActorCard(props: ActorCardProps) { 95 97 const label = createMemo(() => getAvatarLabel(props.actor)); 96 98 const name = createMemo(() => getDisplayName(props.actor)); 97 99 const isFollowing = createMemo(() => !!props.actor.viewer?.following); ··· 191 193 <div class="flex items-start gap-3"> 192 194 <span class="skeleton-block h-11 w-11 shrink-0 rounded-full" /> 193 195 <div class="grid flex-1 gap-1.5"> 194 - <span class="skeleton-block h-3.5 w-32 rounded-full" /> 195 - <span class="skeleton-block h-3 w-24 rounded-full" /> 196 - <span class="skeleton-block h-3 w-full rounded-full" /> 196 + <For each={["w-32", "w-24", "w-full"]}> 197 + {(w) => <span class={`skeleton-block h-3.5 ${w} rounded-full`} />} 198 + </For> 197 199 </div> 198 200 </div> 199 201 </div> ··· 203 205 ); 204 206 } 205 207 206 - export function ActorListOverlay( 207 - props: { 208 - actorList: ActorListState; 209 - onClose: () => void; 210 - onFollowActor: (actor: ProfileViewBasic) => void; 211 - onLoadMore: () => void; 212 - onSelectActor: (actor: ProfileViewBasic) => void; 213 - onUnfollowActor: (actor: ProfileViewBasic) => void; 214 - sessionDid: string | null; 215 - }, 216 - ) { 208 + type ActorListOverlayProps = { 209 + actorList: ActorListState; 210 + onClose: () => void; 211 + onFollowActor: (actor: ProfileViewBasic) => void; 212 + onLoadMore: () => void; 213 + onSelectActor: (actor: ProfileViewBasic) => void; 214 + onUnfollowActor: (actor: ProfileViewBasic) => void; 215 + sessionDid: string | null; 216 + }; 217 + 218 + export function ActorListOverlay(props: ActorListOverlayProps) { 217 219 const title = createMemo(() => props.actorList.kind === "followers" ? "Followers" : "Following"); 218 220 let overlayRef: HTMLDivElement | undefined; 219 221
+14 -7
src/components/shared/Icon.tsx
··· 56 56 | "deck" 57 57 | "list" 58 58 | "rss" 59 - | "messages"; 59 + | "messages" 60 + | "unpin"; 60 61 61 62 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 62 63 class?: string; ··· 173 174 <Match when={local.kind === "messages"}> 174 175 <i class="i-ri-message-3-line" /> 175 176 </Match> 177 + <Match when={local.kind === "unpin"}> 178 + <i class="i-ri-unpin-line" /> 179 + </Match> 176 180 </Switch> 177 181 </span> 178 182 ); ··· 212 216 ); 213 217 } 214 218 215 - export function ArrowIcon(props: { class?: string; direction: "up" | "down" | "left" | "right" }) { 219 + export function ArrowIcon( 220 + props: JSX.HTMLAttributes<HTMLSpanElement> & { class?: string; direction: "up" | "down" | "left" | "right" }, 221 + ) { 222 + const [local, rest] = splitProps(props, ["class", "direction"]); 216 223 return ( 217 - <span class="flex items-center justify-center" classList={{ [props.class ?? ""]: !!props.class }}> 224 + <span class="flex items-center justify-center" classList={{ [local.class ?? ""]: !!local.class }} {...rest}> 218 225 <Switch> 219 - <Match when={props.direction === "up"}> 226 + <Match when={local.direction === "up"}> 220 227 <i class="i-ri-arrow-up-s-line" /> 221 228 </Match> 222 - <Match when={props.direction === "down"}> 229 + <Match when={local.direction === "down"}> 223 230 <i class="i-ri-arrow-down-s-line" /> 224 231 </Match> 225 - <Match when={props.direction === "left"}> 232 + <Match when={local.direction === "left"}> 226 233 <i class="i-ri-arrow-left-s-line" /> 227 234 </Match> 228 - <Match when={props.direction === "right"}> 235 + <Match when={local.direction === "right"}> 229 236 <i class="i-ri-arrow-right-s-line" /> 230 237 </Match> 231 238 </Switch>
+116
src/lib/api/diagnostics.ts
··· 1 + import { invoke } from "@tauri-apps/api/core"; 2 + 3 + type TProfile = { did?: string | null; handle?: string | null; displayName?: string | null; avatar?: string | null }; 4 + 5 + export type DiagnosticList = { 6 + avatar?: string | null; 7 + description?: string | null; 8 + memberCount?: number | null; 9 + purpose?: string | null; 10 + listItemCount?: number | null; 11 + name?: string | null; 12 + title?: string | null; 13 + uri?: string | null; 14 + creator?: TProfile | null; 15 + }; 16 + 17 + export type DiagnosticLabel = { 18 + src?: string | null; 19 + uri?: string | null; 20 + val?: string | null; 21 + neg?: boolean | null; 22 + cts?: string[] | null; 23 + exp?: string | null; 24 + sig?: string | null; 25 + }; 26 + 27 + export type DiagnosticDidProfile = { did: string; profile?: (TProfile & { description?: string | null }) | null }; 28 + 29 + export type DiagnosticBlockItem = { 30 + cid?: string | null; 31 + createdAt?: string | null; 32 + profile?: (TProfile & { description?: string | null }) | null; 33 + subjectDid?: string | null; 34 + uri?: string | null; 35 + value?: Record<string, unknown> | null; 36 + }; 37 + 38 + export type DiagnosticStarterPack = { 39 + avatar?: string | null; 40 + cid?: string | null; 41 + creator?: (TProfile & { description?: string | null }) | null; 42 + description?: string | null; 43 + indexedAt?: string | null; 44 + listItemCount?: number | null; 45 + name?: string | null; 46 + record?: { 47 + description?: string | null; 48 + listItemsSample?: Array<{ subject?: string | null }> | null; 49 + name?: string | null; 50 + } | null; 51 + title?: string | null; 52 + uri?: string | null; 53 + }; 54 + 55 + export type DiagnosticBacklinkItem = { 56 + did?: string | null; 57 + collection?: string | null; 58 + rkey?: string | null; 59 + profile?: (TProfile & { description?: string | null }) | null; 60 + uri?: string | null; 61 + }; 62 + 63 + export type DiagnosticBacklinkGroup = { 64 + cursor?: string | null; 65 + records: DiagnosticBacklinkItem[]; 66 + total?: number | null; 67 + }; 68 + 69 + export type AccountListsResult = { lists: DiagnosticList[]; total: number; truncated: boolean }; 70 + export type AccountLabelsResult = { 71 + labels: DiagnosticLabel[]; 72 + sourceProfiles: Record<string, unknown>; 73 + cursor: string | null; 74 + }; 75 + export type AccountBlockedByResult = { items: DiagnosticDidProfile[]; total: number; cursor: string | null }; 76 + 77 + export type AccountBlockingItem = DiagnosticBlockItem; 78 + 79 + export type AccountBlockingResult = { items: AccountBlockingItem[]; cursor: string | null }; 80 + 81 + export type AccountStarterPacksResult = { starterPacks: DiagnosticStarterPack[]; total: number; truncated: boolean }; 82 + 83 + export type RecordBacklinksResult = { 84 + likes: DiagnosticBacklinkGroup; 85 + reposts: DiagnosticBacklinkGroup; 86 + replies: DiagnosticBacklinkGroup; 87 + quotes: DiagnosticBacklinkGroup; 88 + }; 89 + 90 + export function getAccountLists(did: string): Promise<AccountListsResult> { 91 + return invoke("get_account_lists", { did }); 92 + } 93 + 94 + export function getAccountLabels(did: string): Promise<AccountLabelsResult> { 95 + return invoke("get_account_labels", { did }); 96 + } 97 + 98 + export function getAccountBlockedBy( 99 + did: string, 100 + limit?: number | null, 101 + cursor?: string | null, 102 + ): Promise<AccountBlockedByResult> { 103 + return invoke("get_account_blocked_by", { did, limit: limit ?? null, cursor: cursor ?? null }); 104 + } 105 + 106 + export function getAccountBlocking(did: string, cursor?: string | null): Promise<AccountBlockingResult> { 107 + return invoke("get_account_blocking", { did, cursor: cursor ?? null }); 108 + } 109 + 110 + export function getAccountStarterPacks(did: string): Promise<AccountStarterPacksResult> { 111 + return invoke("get_account_starter_packs", { did }); 112 + } 113 + 114 + export function getRecordBacklinks(uri: string): Promise<RecordBacklinksResult> { 115 + return invoke("get_record_backlinks", { uri }); 116 + }