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: add backlinks panel component for displaying backlinks in explorer and profile views

+989 -229
-4
docs/tasks/08-multicolumn.md
··· 81 81 82 82 ### Parking Lot 83 83 84 - - [ ] Column templates / saved layouts (e.g., "Research", "Timeline + Notifications") 85 - - [ ] Notification column type 86 84 - [x] Search results column type 87 - - [ ] Column-level auto-refresh interval override 88 - - [ ] Shared scroll sync between related columns
-6
docs/tasks/09-profile.md
··· 47 47 - [x] Paginated actor list with compact cards (avatar, name, handle, bio snippet, follow button) 48 48 - [x] `Presence` slide-up overlay with backdrop blur 49 49 - [x] Cursor-based pagination with infinite scroll or "Load more" 50 - 51 - ### Parking Lot 52 - 53 - - [ ] DM button (requires `chat.bsky.convo.*` implementation) 54 - - [ ] Profile edit screen (display name, bio, avatar, banner, website, pronouns) 55 - - [ ] Mute / block actions from profile view
-7
docs/tasks/10-jetstream.md
··· 20 20 - [ ] Pause/resume button to freeze the stream without disconnecting 21 21 - [ ] Record count and events-per-second indicator 22 22 - [ ] Click record to navigate to its full record view in the explorer 23 - 24 - ### Parking Lot 25 - 26 - These require update to the spec & more research before implementation. 27 - 28 - - [ ] **Frontend**: Firehose Viewer 29 - - [ ] **Frontend**: Spacedust integration (see [Task 11](./11-spacedust.md))
-6
docs/tasks/11-spacedust.md
··· 45 45 - [ ] Spacedust instance URL configuration (alongside Constellation URL in settings) 46 46 - [ ] Toggle: use Spacedust for real-time notifications (vs. polling `listNotifications`) 47 47 - [ ] Toggle: `instant` mode (bypass 21-second buffer - faster but noisier) 48 - 49 - ### Parking Lot 50 - 51 - - [ ] Spacedust as a column type in multicolumn view (live notification stream) 52 - - [ ] Aggregate Spacedust events into a "live activity" dashboard 53 - - [ ] Spacedust for real-time search result updates
+26 -38
docs/tasks/12-social-diagnostics.md
··· 29 29 30 30 ### Frontend - Diagnostics Panel 31 31 32 - - [ ] Tabbed panel component with 5 tabs: Lists, Labels, Blocks, Starter Packs, Backlinks 33 - - [ ] Tab switching with `Motion` sliding indicator underline 34 - - [ ] Number key shortcuts (`1`–`5`) for tab switching 35 - - [ ] `Escape` to close panel 32 + - [x] Tabbed panel component with 5 tabs: Lists, Labels, Blocks, Starter Packs, Backlinks 33 + - [x] Tab switching with `Motion` sliding indicator underline 34 + - [x] Number key shortcuts (`1`–`5`) for tab switching 35 + - [x] `Escape` to close panel 36 36 37 37 ### Frontend - Lists Tab 38 38 39 - - [ ] List cards: name, owner, description, purpose badge, member count 40 - - [ ] Grouped by purpose (curation / moderation / reference) 41 - - [ ] Skeleton loading matching card dimensions 42 - - [ ] Neutral framing - no aggregate risk scoring or warning badges 39 + - [x] List cards: name, owner, description, purpose badge, member count 40 + - [x] Grouped by purpose (curation / moderation / reference) 41 + - [x] Skeleton loading matching card dimensions 42 + - [x] Neutral framing - no aggregate risk scoring or warning badges 43 43 44 44 ### Frontend - Labels Tab 45 45 46 - - [ ] Label chips with source attribution (labeling service name) 47 - - [ ] Uniform muted styling - no severity color-coding 48 - - [ ] Tooltip with label definition, source, and visibility effect 49 - - [ ] Explanatory empty state (what labels are, not "no labels found") 50 - - [ ] `Motion` scale-in on load 46 + - [x] Label chips with source attribution (labeling service name) 47 + - [x] Uniform muted styling - no severity color-coding 48 + - [x] Tooltip with label definition, source, and visibility effect 49 + - [x] Explanatory empty state (what labels are, not "no labels found") 50 + - [x] `Motion` scale-in on load 51 51 52 52 ### Frontend - Blocks Tab 53 53 54 - - [ ] Counts-only default view (no names or profile cards on first load) 55 - - [ ] "Show details" expand with contextualizing copy (*"Blocks are a normal part of social media..."*) 56 - - [ ] `Presence` height animation on expand with staggered card fade-in 57 - - [ ] No warning banners, color-coding, or language implying abnormality 58 - - [ ] Self-view framing: "Your boundaries" (not "Who blocked you") 54 + - [x] Counts-only default view (no names or profile cards on first load) 55 + - [x] "Show details" expand with contextualizing copy (*"Blocks are a normal part of social media..."*) 56 + - [x] `Presence` height animation on expand with staggered card fade-in 57 + - [x] No warning banners, color-coding, or language implying abnormality 58 + - [x] Self-view framing: "Your boundaries" (not "Who blocked you") 59 59 60 60 ### Frontend - Starter Packs Tab 61 61 62 - - [ ] Compact starter pack cards: title, creator, description, member count 63 - - [ ] Link to view in AT Explorer 62 + - [x] Compact starter pack cards: title, creator, description, member count 63 + - [x] Link to view in AT Explorer 64 64 65 65 ### Frontend - Backlinks Tab (Record Context) 66 66 67 - - [ ] Grouped by type: likes, reposts, replies, quote posts 68 - - [ ] Count per type with expandable sections 69 - - [ ] Individual actor/record cards within sections 67 + - [x] Grouped by type: likes, reposts, replies, quote posts 68 + - [x] Count per type with expandable sections 69 + - [x] Individual actor/record cards within sections 70 70 71 71 ### Frontend - Integration Points 72 72 73 - - [ ] Profile view: "Context" tab (alongside Posts/Replies/Media/Likes) - not the default tab 74 - - [ ] AT Explorer record view: backlinks supplementary panel (engagement data only, no moderation data) 75 - - [ ] AT Explorer repo view: follower/following counts from Constellation (no block counts in summaries) 76 - 77 - ### UX Tone Review 78 - 79 - - [ ] Audit all copy for neutral language - no "risk", "warning", "suspicious", "flagged" 80 - - [ ] Ensure all sensitive sections use progressive disclosure (summary → details on click) 81 - - [ ] Verify self-view ("my account") uses empowering framing, not anxiety-inducing 82 - 83 - ### Parking Lot 84 - 85 - - [ ] Network relationship diff over time (requires historical snapshots) 86 - - [ ] Profile/identity history timeline (handle/DID/PDS changes) 87 - - [ ] Starter Pack search? 73 + - [x] Profile view: "Context" tab (alongside Posts/Replies/Media/Likes) - not the default tab 74 + - [x] AT Explorer record view: backlinks supplementary panel (engagement data only, no moderation data) 75 + - [x] AT Explorer repo view: follower/following counts from Constellation (no block counts in summaries)
+41
docs/todo.md
··· 1 + --- 2 + title: "To-Do List/Parking Lot" 3 + updated: 2026-03-31 4 + --- 5 + 6 + ## Bugs 7 + 8 + 1. Lists & Labels Not Working 9 + 10 + ## High Priority Updates 11 + 12 + - [ ] Video player 13 + - [ ] Download videos and media attachments 14 + - [ ] Profile RSS 15 + - OK. So making an RSS reader with share to BlueSky would be cool... 16 + 17 + ## Multicolumn Layouts 18 + 19 + - [ ] Templates / saved layouts (e.g., "Research", "Timeline + Notifications") 20 + - [ ] Notification column type 21 + - [ ] Column-level auto-refresh interval override 22 + - [ ] Related, but separate, refreshing the "main"/"home" feed 23 + - [ ] Shared scroll sync between related columns 24 + 25 + ## Profiles 26 + 27 + - [ ] DM button -> Creates a DM if does not exist 28 + - [ ] Profile edit screen (display name, bio, avatar, banner, website, pronouns) 29 + - [ ] Mute / block actions from profile view 30 + 31 + ## Spacedust & Jetstream 32 + 33 + - [ ] Spacedust as a column type in multicolumn view (live notification stream) 34 + - [ ] (raw) Firehose Viewer 35 + - [ ] Jetstream viewer 36 + 37 + ## Diagnostics 38 + 39 + - [ ] Network relationship diff over time (requires historical snapshots) 40 + - [ ] Profile/identity history timeline (handle/DID/PDS changes) 41 + - [ ] Starter Pack search?
+15 -1
src/components/deck/DiagnosticsColumn.tsx
··· 1 + import { queueExplorerTarget } from "$/lib/explorer-navigation"; 2 + import { useNavigate } from "@solidjs/router"; 1 3 import { DiagnosticsPanel } from "./DiagnosticsPanel"; 2 4 3 5 type DiagnosticsColumnProps = { did: string; onClose?: () => void }; 4 6 5 7 export function DiagnosticsColumn(props: DiagnosticsColumnProps) { 6 - return <DiagnosticsPanel did={props.did} onClose={props.onClose ?? (() => void 0)} />; 8 + const navigate = useNavigate(); 9 + 10 + function handleOpenExplorerTarget(target: string) { 11 + queueExplorerTarget(target); 12 + void navigate("/explorer"); 13 + } 14 + 15 + return ( 16 + <DiagnosticsPanel 17 + did={props.did} 18 + onClose={props.onClose ?? (() => void 0)} 19 + onOpenExplorerTarget={handleOpenExplorerTarget} /> 20 + ); 7 21 }
+21 -2
src/components/deck/DiagnosticsPanel.test.tsx
··· 8 8 const getAccountBlockedByMock = vi.hoisted(() => vi.fn()); 9 9 const getAccountBlockingMock = vi.hoisted(() => vi.fn()); 10 10 const getAccountStarterPacksMock = vi.hoisted(() => vi.fn()); 11 + const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 11 12 12 13 vi.mock( 13 14 "$/lib/api/diagnostics", ··· 17 18 getAccountLabels: getAccountLabelsMock, 18 19 getAccountLists: getAccountListsMock, 19 20 getAccountStarterPacks: getAccountStarterPacksMock, 21 + getRecordBacklinks: getRecordBacklinksMock, 20 22 }), 21 23 ); 22 24 ··· 35 37 getAccountBlockedByMock.mockReset(); 36 38 getAccountBlockingMock.mockReset(); 37 39 getAccountStarterPacksMock.mockReset(); 40 + getRecordBacklinksMock.mockReset(); 38 41 39 42 getAccountListsMock.mockResolvedValue({ 40 43 lists: [{ ··· 55 58 }); 56 59 getAccountLabelsMock.mockResolvedValue({ 57 60 labels: [{ src: "did:plc:labeler", val: "!hide" }], 58 - sourceProfiles: {}, 61 + sourceProfiles: { "did:plc:labeler": { displayName: "Safety Service", handle: "safety.service" } }, 59 62 cursor: null, 60 63 }); 61 64 getAccountBlockedByMock.mockResolvedValue({ ··· 77 80 total: 1, 78 81 truncated: false, 79 82 }); 83 + getRecordBacklinksMock.mockResolvedValue({ 84 + likes: { cursor: null, records: [], total: 0 }, 85 + quotes: { cursor: null, records: [], total: 0 }, 86 + replies: { cursor: null, records: [], total: 0 }, 87 + reposts: { cursor: null, records: [], total: 0 }, 88 + }); 80 89 }); 81 90 82 91 it("renders the tab shell and switches tabs with keys", async () => { ··· 103 112 renderPanel(); 104 113 105 114 fireEvent.click(await screen.findByRole("button", { name: "Blocks" })); 106 - expect(await screen.findByText("Blocked by")).toBeInTheDocument(); 115 + expect(await screen.findByText("Boundaries around you")).toBeInTheDocument(); 107 116 expect(screen.getByRole("button", { name: /show details/i })).toBeInTheDocument(); 108 117 109 118 fireEvent.click(screen.getByRole("button", { name: /show details/i })); ··· 112 121 fireEvent.click(screen.getByRole("button", { name: "Starter Packs" })); 113 122 expect(await screen.findByText("Newcomers")).toBeInTheDocument(); 114 123 expect(screen.getByText("8 members")).toBeInTheDocument(); 124 + }); 125 + 126 + it("explains backlinks when no record URI is selected", async () => { 127 + renderPanel(); 128 + 129 + fireEvent.click(await screen.findByRole("button", { name: "Backlinks" })); 130 + 131 + expect(await screen.findByText(/Backlinks are record-specific engagement context/i)).toBeInTheDocument(); 132 + expect(screen.getByText(/Open a post or record to inspect the public references pointing at it/i)) 133 + .toBeInTheDocument(); 115 134 }); 116 135 });
+373 -135
src/components/deck/DiagnosticsPanel.tsx
··· 1 + import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; 1 2 import { useAppSession } from "$/contexts/app-session"; 2 3 import { 3 4 type DiagnosticBlockItem, ··· 11 12 getAccountLists, 12 13 getAccountStarterPacks, 13 14 } from "$/lib/api/diagnostics"; 15 + import { asRecord, getStringProperty } from "$/lib/type-guards"; 16 + import { shouldIgnoreKey } from "$/lib/utils/events"; 14 17 import { normalizeError } from "$/lib/utils/text"; 15 18 import * as logger from "@tauri-apps/plugin-log"; 16 19 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; ··· 20 23 21 24 type DiagnosticsTab = "lists" | "labels" | "blocks" | "starterPacks" | "backlinks"; 22 25 23 - type DiagnosticsPanelProps = { did: string; onClose: () => void }; 26 + type DiagnosticsPanelProps = { 27 + did?: string | null; 28 + embedded?: boolean; 29 + onClose?: () => void; 30 + onOpenExplorerTarget?: (target: string) => void; 31 + recordUri?: string | null; 32 + }; 24 33 25 34 type DiagnosticsState = { 26 - lists: DiagnosticList[]; 27 - listsError: string | null; 28 - listsLoading: boolean; 29 - labels: DiagnosticLabel[]; 30 - labelsError: string | null; 31 - labelsLoading: boolean; 32 35 blockedBy: DiagnosticDidProfile[]; 33 36 blockedByError: string | null; 34 37 blockedByLoading: boolean; 35 38 blocking: DiagnosticBlockItem[]; 36 39 blockingError: string | null; 37 40 blockingLoading: boolean; 41 + labels: DiagnosticLabel[]; 42 + labelsError: string | null; 43 + labelsLoading: boolean; 44 + labelsSourceProfiles: Record<string, unknown>; 45 + lists: DiagnosticList[]; 46 + listsError: string | null; 47 + listsLoading: boolean; 38 48 starterPacks: DiagnosticStarterPack[]; 39 49 starterPacksError: string | null; 40 50 starterPacksLoading: boolean; ··· 59 69 labels: [], 60 70 labelsError: null, 61 71 labelsLoading: true, 72 + labelsSourceProfiles: {}, 62 73 lists: [], 63 74 listsError: null, 64 75 listsLoading: true, ··· 68 79 }; 69 80 } 70 81 82 + function createIdleState(): DiagnosticsState { 83 + return { 84 + blockedBy: [], 85 + blockedByError: null, 86 + blockedByLoading: false, 87 + blocking: [], 88 + blockingError: null, 89 + blockingLoading: false, 90 + labels: [], 91 + labelsError: null, 92 + labelsLoading: false, 93 + labelsSourceProfiles: {}, 94 + lists: [], 95 + listsError: null, 96 + listsLoading: false, 97 + starterPacks: [], 98 + starterPacksError: null, 99 + starterPacksLoading: false, 100 + }; 101 + } 102 + 71 103 function purposeLabel(purpose: string | null | undefined) { 72 104 switch ((purpose || "").toLowerCase()) { 73 105 case "curate": ··· 105 137 return name.trim().slice(0, 1).toUpperCase() || "?"; 106 138 } 107 139 140 + function formatHandle(handle: string | null | undefined) { 141 + if (!handle) { 142 + return "Unknown"; 143 + } 144 + 145 + return handle.startsWith("did:") || handle.startsWith("@") ? handle : `@${handle}`; 146 + } 147 + 148 + function getDiagnosticEntryHandle(item: DiagnosticBlockItem | DiagnosticDidProfile) { 149 + if (item.profile?.handle) { 150 + return item.profile.handle; 151 + } 152 + 153 + if ("did" in item) { 154 + return item.did; 155 + } 156 + 157 + return item.subjectDid ?? item.profile?.did ?? "Unknown"; 158 + } 159 + 160 + function getLabelDefinition(value: string | null | undefined) { 161 + switch ((value || "").toLowerCase()) { 162 + case "!hide": { 163 + return "Hidden content label."; 164 + } 165 + case "!hide-media": { 166 + return "Media visibility label."; 167 + } 168 + case "!hide-replies": { 169 + return "Replies visibility label."; 170 + } 171 + case "!no-unauthenticated": { 172 + return "Requires a signed-in view."; 173 + } 174 + case "!warn": { 175 + return "Advisory moderation label."; 176 + } 177 + default: { 178 + return "Service-applied moderation metadata."; 179 + } 180 + } 181 + } 182 + 183 + function getLabelEffect(label: DiagnosticLabel) { 184 + if (label.neg) { 185 + return "This label negates a previous moderation decision."; 186 + } 187 + 188 + switch ((label.val || "").toLowerCase()) { 189 + case "!hide": 190 + case "!hide-media": 191 + case "!hide-replies": { 192 + return "It can change how the record or account is shown in supporting clients."; 193 + } 194 + case "!no-unauthenticated": { 195 + return "It can limit visibility for signed-out browsing."; 196 + } 197 + default: { 198 + return "Its exact effect depends on the labeling service and client policy."; 199 + } 200 + } 201 + } 202 + 203 + function getSourceProfileName(sourceProfiles: Record<string, unknown>, src: string | null | undefined) { 204 + if (!src) { 205 + return "Unknown service"; 206 + } 207 + 208 + const profile = asRecord(sourceProfiles[src]); 209 + if (!profile) { 210 + return src; 211 + } 212 + 213 + return getStringProperty(profile, "displayName") ?? formatHandle(getStringProperty(profile, "handle")) ?? src; 214 + } 215 + 108 216 export function DiagnosticsPanel(props: DiagnosticsPanelProps) { 109 217 const session = useAppSession(); 110 218 const [state, setState] = createStore<DiagnosticsState>(createInitialState()); 111 219 const [activeTab, setActiveTab] = createSignal<DiagnosticsTab>("lists"); 112 220 const [blocksExpanded, setBlocksExpanded] = createSignal(false); 113 - const activeDid = createMemo(() => props.did.trim() || session.activeDid || ""); 221 + const activeDid = createMemo(() => props.did?.trim() || session.activeDid || ""); 222 + const activeRecordUri = createMemo(() => props.recordUri?.trim() || ""); 114 223 const isSelf = createMemo(() => activeDid() === session.activeDid); 115 224 let requestId = 0; 116 225 117 226 createEffect(() => { 118 227 const did = activeDid(); 119 228 if (!did) { 229 + setState(createIdleState()); 120 230 return; 121 231 } 122 232 ··· 132 242 }); 133 243 134 244 function handleKeyDown(event: KeyboardEvent) { 135 - if (event.key >= "1" && event.key <= "5" && !event.metaKey && !event.ctrlKey && !event.altKey) { 245 + if (shouldIgnoreKey(event) || event.altKey || event.shiftKey) { 246 + return; 247 + } 248 + 249 + if (event.key >= "1" && event.key <= "5") { 136 250 event.preventDefault(); 137 251 const nextTab = DIAGNOSTICS_TABS[Number(event.key) - 1]?.value; 138 252 if (nextTab) { 139 253 setActiveTab(nextTab); 140 254 } 255 + return; 141 256 } 142 257 143 258 if (event.key === "Escape") { 144 - props.onClose(); 259 + props.onClose?.(); 145 260 } 146 261 } 147 262 ··· 167 282 try { 168 283 const response = await getAccountLabels(did); 169 284 if (currentRequest !== requestId) return; 170 - setState({ labels: response.labels, labelsError: null, labelsLoading: false }); 285 + setState({ 286 + labels: response.labels, 287 + labelsError: null, 288 + labelsLoading: false, 289 + labelsSourceProfiles: response.sourceProfiles, 290 + }); 171 291 } catch (error) { 172 292 const message = normalizeError(error); 173 293 if (currentRequest !== requestId) return; 174 - setState({ labelsError: message, labelsLoading: false }); 294 + setState({ labelsError: message, labelsLoading: false, labelsSourceProfiles: {} }); 175 295 logger.warn("failed to load diagnostics labels", { keyValues: { did, error: message } }); 176 296 } 177 297 } 178 298 179 299 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 } }); 300 + const [blockedBy, blocking] = await Promise.allSettled([getAccountBlockedBy(did, 25), getAccountBlocking(did)]); 301 + 302 + if (currentRequest !== requestId) { 303 + return; 304 + } 305 + 306 + if (blockedBy.status === "fulfilled") { 307 + setState({ blockedBy: blockedBy.value.items, blockedByError: null, blockedByLoading: false }); 308 + } else { 309 + const message = normalizeError(blockedBy.reason); 310 + setState({ blockedByError: message, blockedByLoading: false }); 311 + logger.warn("failed to load diagnostics blocked-by data", { keyValues: { did, error: message } }); 312 + } 313 + 314 + if (blocking.status === "fulfilled") { 315 + setState({ blocking: blocking.value.items, blockingError: null, blockingLoading: false }); 316 + } else { 317 + const message = normalizeError(blocking.reason); 318 + setState({ blockingError: message, blockingLoading: false }); 319 + logger.warn("failed to load diagnostics blocking data", { keyValues: { did, error: message } }); 196 320 } 197 321 } 198 322 ··· 211 335 212 336 return ( 213 337 <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} /> 338 + <DiagnosticsHeader 339 + did={activeDid()} 340 + embedded={props.embedded ?? false} 341 + isSelf={isSelf()} 342 + onClose={props.onClose} /> 215 343 <DiagnosticsTabs activeTab={activeTab()} onSelectTab={setActiveTab} /> 216 344 <DiagnosticsViewport 217 345 activeTab={activeTab()} 218 346 blocksExpanded={blocksExpanded()} 347 + isSelf={isSelf()} 348 + onOpenExplorerTarget={props.onOpenExplorerTarget} 349 + onRetryBlockedBy={() => void loadBlocks(requestId, activeDid())} 350 + onRetryBlocking={() => void loadBlocks(requestId, activeDid())} 351 + onRetryLabels={() => void loadLabels(requestId, activeDid())} 352 + onRetryLists={() => void loadLists(requestId, activeDid())} 353 + onRetryStarterPacks={() => void loadStarterPacks(requestId, activeDid())} 219 354 onToggleBlocks={() => setBlocksExpanded((value) => !value)} 355 + recordUri={activeRecordUri()} 220 356 state={state} /> 221 357 </article> 222 358 ); 223 359 } 224 360 225 - function DiagnosticsHeader(props: { did: string; isSelf: boolean; onClose: () => void }) { 361 + function DiagnosticsHeader(props: { did: string; embedded: boolean; isSelf: boolean; onClose?: () => void }) { 226 362 return ( 227 363 <header class="grid gap-4 px-6 pb-4 pt-6"> 228 364 <div class="flex items-start justify-between gap-4"> ··· 230 366 <p class="overline-copy text-xs text-on-surface-variant">Context</p> 231 367 <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Social Diagnostics</h1> 232 368 <p class="m-0 text-sm text-on-surface-variant"> 233 - {props.isSelf ? "Your boundaries and footprint" : "Public social context for this account"} 369 + {props.isSelf ? "Your boundaries and public footprint" : "Public social context for this account"} 234 370 </p> 235 371 </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> 372 + <Show when={!props.embedded && props.onClose}> 373 + <button 374 + type="button" 375 + 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" 376 + onClick={() => props.onClose?.()} 377 + title="Close diagnostics panel"> 378 + <Icon kind="close" aria-hidden="true" /> 379 + </button> 380 + </Show> 243 381 </div> 244 382 245 383 <p class="m-0 break-all rounded-2xl bg-surface-container-high px-4 py-3 font-mono text-xs text-on-surface-variant"> ··· 281 419 } 282 420 283 421 function DiagnosticsViewport( 284 - props: { activeTab: DiagnosticsTab; blocksExpanded: boolean; onToggleBlocks: () => void; state: DiagnosticsState }, 422 + props: { 423 + activeTab: DiagnosticsTab; 424 + blocksExpanded: boolean; 425 + isSelf: boolean; 426 + onOpenExplorerTarget?: (target: string) => void; 427 + onRetryBlockedBy: () => void; 428 + onRetryBlocking: () => void; 429 + onRetryLabels: () => void; 430 + onRetryLists: () => void; 431 + onRetryStarterPacks: () => void; 432 + onToggleBlocks: () => void; 433 + recordUri: string; 434 + state: DiagnosticsState; 435 + }, 285 436 ) { 286 437 return ( 287 438 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 288 439 <Presence> 289 440 <Show when={props.activeTab === "lists"} keyed> 290 441 <DiagnosticsListsTab 291 - lists={props.state.lists} 292 442 error={props.state.listsError} 293 - loading={props.state.listsLoading} /> 443 + lists={props.state.lists} 444 + loading={props.state.listsLoading} 445 + onOpenExplorerTarget={props.onOpenExplorerTarget} 446 + onRetry={props.onRetryLists} /> 294 447 </Show> 295 448 <Show when={props.activeTab === "labels"} keyed> 296 449 <DiagnosticsLabelsTab 450 + error={props.state.labelsError} 297 451 labels={props.state.labels} 298 - error={props.state.labelsError} 299 - loading={props.state.labelsLoading} /> 452 + loading={props.state.labelsLoading} 453 + onRetry={props.onRetryLabels} 454 + sourceProfiles={props.state.labelsSourceProfiles} /> 300 455 </Show> 301 456 <Show when={props.activeTab === "blocks"} keyed> 302 457 <DiagnosticsBlocksTab ··· 307 462 blockingError={props.state.blockingError} 308 463 blockingLoading={props.state.blockingLoading} 309 464 expanded={props.blocksExpanded} 465 + isSelf={props.isSelf} 466 + onRetryBlockedBy={props.onRetryBlockedBy} 467 + onRetryBlocking={props.onRetryBlocking} 310 468 onToggleExpanded={props.onToggleBlocks} /> 311 469 </Show> 312 470 <Show when={props.activeTab === "starterPacks"} keyed> 313 471 <DiagnosticsStarterPacksTab 314 472 error={props.state.starterPacksError} 315 473 loading={props.state.starterPacksLoading} 474 + onOpenExplorerTarget={props.onOpenExplorerTarget} 475 + onRetry={props.onRetryStarterPacks} 316 476 starterPacks={props.state.starterPacks} /> 317 477 </Show> 318 478 <Show when={props.activeTab === "backlinks"} keyed> 319 - <DiagnosticsBacklinksTab /> 479 + <section class="grid gap-3"> 480 + <DiagnosticsTabIntro 481 + description="Backlinks are record-specific engagement context. Open a record to inspect likes, reposts, replies, and quote posts." 482 + title="Backlinks" /> 483 + <RecordBacklinksPanel uri={props.recordUri || null} /> 484 + </section> 320 485 </Show> 321 486 </Presence> 322 487 </div> 323 488 ); 324 489 } 325 490 326 - function DiagnosticsListsTab(props: { lists: DiagnosticList[]; error: string | null; loading: boolean }) { 491 + function DiagnosticsListsTab( 492 + props: { 493 + error: string | null; 494 + lists: DiagnosticList[]; 495 + loading: boolean; 496 + onOpenExplorerTarget?: (target: string) => void; 497 + onRetry: () => void; 498 + }, 499 + ) { 327 500 return ( 328 501 <section class="grid gap-3"> 329 502 <DiagnosticsTabIntro 330 503 title="Lists" 331 - description="Lists are ordinary social structure. The view keeps purpose and membership context visible without scoring or judgment." /> 504 + description="Lists are ordinary social structure. Purpose, owner context, and membership stay visible without scoring or judgment." /> 332 505 <Switch 333 506 fallback={ 334 507 <div class="grid gap-4"> ··· 341 514 <div class="grid gap-3"> 342 515 <p class="m-0 text-xs uppercase tracking-[0.14em] text-on-surface-variant">{group.label}</p> 343 516 <div class="grid gap-3"> 344 - <For each={group.items}>{(list) => <ListCard list={list} />}</For> 517 + <For each={group.items}> 518 + {(list) => <ListCard list={list} onOpenExplorerTarget={props.onOpenExplorerTarget} />} 519 + </For> 345 520 </div> 346 521 </div> 347 522 )} ··· 351 526 <Match when={props.loading}> 352 527 <DiagnosticsListSkeleton /> 353 528 </Match> 354 - <Match when={props.error}>{error => <DiagnosticsError message={error()} />}</Match> 529 + <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match> 355 530 </Switch> 356 531 </section> 357 532 ); 358 533 } 359 534 360 - function DiagnosticsLabelsTab(props: { labels: DiagnosticLabel[]; error: string | null; loading: boolean }) { 535 + function DiagnosticsLabelsTab( 536 + props: { 537 + error: string | null; 538 + labels: DiagnosticLabel[]; 539 + loading: boolean; 540 + onRetry: () => void; 541 + sourceProfiles: Record<string, unknown>; 542 + }, 543 + ) { 361 544 return ( 362 545 <section class="grid gap-3"> 363 546 <DiagnosticsTabIntro ··· 372 555 transition={{ duration: 0.16 }}> 373 556 <For 374 557 fallback={ 375 - <DiagnosticsEmptyState copy="Labels are service-applied metadata that can affect visibility. This account currently has no visible labels." /> 558 + <DiagnosticsEmptyState copy="Labels are service-applied metadata that can affect visibility. No visible labels are being returned for this account right now." /> 376 559 } 377 560 each={props.labels}> 378 - {(label, index) => <LabelChip label={label} index={index()} />} 561 + {(label, index) => ( 562 + <LabelChip 563 + index={index()} 564 + label={label} 565 + sourceName={getSourceProfileName(props.sourceProfiles, label.src)} /> 566 + )} 379 567 </For> 380 568 </Motion.div> 381 569 }> 382 570 <Match when={props.loading}> 383 571 <DiagnosticsLabelSkeleton /> 384 572 </Match> 385 - <Match when={props.error}>{error => <DiagnosticsError message={error()} />}</Match> 573 + <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match> 386 574 </Switch> 387 575 </section> 388 576 ); ··· 397 585 blockingError: string | null; 398 586 blockingLoading: boolean; 399 587 expanded: boolean; 588 + isSelf: boolean; 589 + onRetryBlockedBy: () => void; 590 + onRetryBlocking: () => void; 400 591 onToggleExpanded: () => void; 401 592 }, 402 593 ) { ··· 406 597 return ( 407 598 <section class="grid gap-3"> 408 599 <DiagnosticsTabIntro 409 - title={"Blocks"} 410 - description={"Blocking is a normal boundary. Counts are shown first; details are revealed only on request."} /> 600 + description="Blocking is a normal social boundary. Counts stay in the summary row; specific accounts appear only after a deliberate action." 601 + title={props.isSelf ? "Your Boundaries" : "Blocks"} /> 411 602 <div class="grid gap-3 sm:grid-cols-2"> 412 - <StatCard label="Blocked by" value={blockedByCount()} /> 413 - <StatCard label="Blocking" value={blockingCount()} /> 603 + <StatCard label={props.isSelf ? "Boundaries around you" : "Blocked by"} value={blockedByCount()} /> 604 + <StatCard label={props.isSelf ? "Your boundaries" : "Blocking"} value={blockingCount()} /> 414 605 </div> 415 606 <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."} 607 + Blocks are a normal part of social media. This data is public on the AT Protocol. 417 608 </div> 418 609 <button 419 610 type="button" ··· 432 623 exit={{ opacity: 0, height: 0 }} 433 624 transition={{ duration: 0.18 }}> 434 625 <DiagnosticsBlock 435 - kind="blockedBy" 626 + error={props.blockedByError} 436 627 items={props.blockedBy} 437 628 loading={props.blockedByLoading} 438 - error={props.blockedByError} /> 629 + onRetry={props.onRetryBlockedBy} 630 + title={props.isSelf ? "Boundaries around you" : "Blocked by"} /> 439 631 <DiagnosticsBlock 440 - kind="blocking" 632 + error={props.blockingError} 441 633 items={props.blocking} 442 634 loading={props.blockingLoading} 443 - error={props.blockingError} /> 635 + onRetry={props.onRetryBlocking} 636 + title={props.isSelf ? "Your boundaries" : "Blocking"} /> 444 637 </Motion.div> 445 638 </Show> 446 639 </Presence> ··· 450 643 451 644 function DiagnosticsBlock( 452 645 props: { 453 - kind: "blockedBy" | "blocking"; 646 + error: string | null; 454 647 items: DiagnosticBlockItem[] | DiagnosticDidProfile[]; 455 648 loading: boolean; 456 - error: string | null; 649 + onRetry: () => void; 650 + title: string; 457 651 }, 458 652 ) { 459 653 const items = createMemo(() => 460 - props.items.map(item => ({ 654 + props.items.map((item) => ({ 461 655 avatar: item.profile?.avatar ?? null, 462 656 description: item.profile?.description ?? null, 463 657 displayName: item.profile?.displayName ?? null, 464 - handle: (item.profile?.handle ?? item.profile?.did) ?? "Unknown", 658 + handle: getDiagnosticEntryHandle(item), 465 659 })) 466 660 ); 467 661 468 662 return ( 469 - <Switch 470 - fallback={ 471 - <BlockProfileList title={props.kind === "blockedBy" ? "Blocked by" : "Your boundaries"} items={items()} /> 472 - }> 663 + <Switch fallback={<BlockProfileList items={items()} title={props.title} />}> 473 664 <Match when={props.loading}> 474 665 <DiagnosticsBlockSkeleton /> 475 666 </Match> 476 - <Match when={props.error}>{error => <DiagnosticsError message={error()} />}</Match> 667 + <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match> 477 668 </Switch> 478 669 ); 479 670 } 480 671 481 672 function DiagnosticsStarterPacksTab( 482 - props: { starterPacks: DiagnosticStarterPack[]; error: string | null; loading: boolean }, 673 + props: { 674 + error: string | null; 675 + loading: boolean; 676 + onOpenExplorerTarget?: (target: string) => void; 677 + onRetry: () => void; 678 + starterPacks: DiagnosticStarterPack[]; 679 + }, 483 680 ) { 484 681 return ( 485 682 <section class="grid gap-3"> 486 683 <DiagnosticsTabIntro 487 684 title="Starter Packs" 488 - description="Starter packs show how people are discovering this account. They stay compact and factual." /> 685 + description="Starter packs show how people are discovering this account. The cards stay compact and factual." /> 489 686 <Switch 490 687 fallback={ 491 688 <div class="grid gap-3"> ··· 494 691 fallback={ 495 692 <DiagnosticsEmptyState copy="Starter packs are discovery context and may not exist for every account." /> 496 693 }> 497 - {(pack) => <StarterPackCard pack={pack} />} 694 + {(pack) => <StarterPackCard onOpenExplorerTarget={props.onOpenExplorerTarget} pack={pack} />} 498 695 </For> 499 696 </div> 500 697 }> 501 698 <Match when={props.loading}> 502 699 <DiagnosticsStarterPackSkeleton /> 503 700 </Match> 504 - <Match when={props.error}> 505 - <DiagnosticsError message={props.error} /> 506 - </Match> 701 + <Match when={props.error}>{(error) => <DiagnosticsError message={error()} onRetry={props.onRetry} />}</Match> 507 702 </Switch> 508 703 </section> 509 704 ); 510 705 } 511 706 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 }) { 707 + function DiagnosticsTabIntro(props: { description: string; title: string }) { 529 708 return ( 530 709 <div class="grid gap-1 rounded-3xl bg-white/3 p-4"> 531 710 <h2 class="m-0 text-base font-semibold text-on-surface">{props.title}</h2> ··· 534 713 ); 535 714 } 536 715 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>; 716 + function DiagnosticsError(props: { message: string | null; onRetry?: () => void }) { 717 + return ( 718 + <div class="grid gap-3 rounded-3xl bg-white/3 p-4 text-sm text-on-surface-variant"> 719 + <p class="m-0">{props.message}</p> 720 + <Show when={props.onRetry}> 721 + <button 722 + type="button" 723 + 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" 724 + onClick={() => props.onRetry?.()}> 725 + <Icon kind="refresh" aria-hidden="true" /> 726 + Retry 727 + </button> 728 + </Show> 729 + </div> 730 + ); 539 731 } 540 732 541 733 function DiagnosticsEmptyState(props: { copy: string }) { ··· 551 743 ); 552 744 } 553 745 554 - function LabelChip(props: { label: DiagnosticLabel; index: number }) { 555 - const copy = () => [props.label.val ?? "label", props.label.src ?? "unknown service"].join(" · "); 746 + function LabelChip(props: { index: number; label: DiagnosticLabel; sourceName: string }) { 747 + const title = createMemo(() => 748 + [ 749 + `Label: ${props.label.val ?? "Unknown"}`, 750 + `Definition: ${getLabelDefinition(props.label.val)}`, 751 + `Source: ${props.sourceName}`, 752 + `Effect: ${getLabelEffect(props.label)}`, 753 + ].join("\n") 754 + ); 556 755 557 756 return ( 558 757 <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" 758 + class="inline-flex items-center gap-2 rounded-full bg-white/5 px-3 py-2 text-sm text-on-secondary-container" 560 759 initial={{ opacity: 0, scale: 0.9 }} 561 760 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" /> 761 + title={title()} 762 + transition={{ delay: Math.min(props.index * 0.02, 0.12), duration: 0.14 }}> 763 + <span class="h-2 w-2 rounded-full bg-white/20" /> 565 764 <span>{props.label.val ?? "label"}</span> 566 - <span class="text-xs text-on-surface-variant/80">{props.label.src ?? "unknown service"}</span> 765 + <span class="text-xs text-on-surface-variant/90">{props.sourceName}</span> 567 766 </Motion.span> 568 767 ); 569 768 } 570 769 571 - function ListCard(props: { list: DiagnosticList }) { 572 - const title = () => props.list.title ?? props.list.name ?? "Untitled list"; 770 + function ListCard(props: { list: DiagnosticList; onOpenExplorerTarget?: (target: string) => void }) { 573 771 const count = () => props.list.memberCount ?? props.list.listItemCount ?? 0; 772 + const title = () => props.list.title ?? props.list.name ?? "Untitled list"; 574 773 575 774 return ( 576 775 <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"> 776 + <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 578 777 <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> 778 + <div class="flex flex-wrap items-center gap-2"> 779 + <p class="m-0 text-base font-semibold text-on-surface">{title()}</p> 780 + <span class="rounded-full bg-primary/12 px-3 py-1 text-xs text-primary"> 781 + {purposeLabel(props.list.purpose)} 782 + </span> 783 + </div> 784 + <p class="m-0 mt-1 text-sm text-on-surface-variant">Owner: {formatHandle(props.list.creator?.handle)}</p> 583 785 <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant"> 584 786 {props.list.description ?? "No description provided."} 585 787 </p> 586 788 </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> 789 + 790 + <div class="grid shrink-0 justify-items-start gap-2 text-left lg:justify-items-end lg:text-right"> 591 791 <span class="text-xs text-on-surface-variant">{count()} members</span> 792 + <Show when={props.list.uri}> 793 + {uri => ( 794 + <button 795 + type="button" 796 + 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" 797 + disabled={!props.onOpenExplorerTarget} 798 + onClick={() => props.onOpenExplorerTarget?.(uri())}> 799 + <Icon kind="ext-link" aria-hidden="true" /> 800 + Open list 801 + </button> 802 + )} 803 + </Show> 592 804 </div> 593 805 </div> 594 806 </div> 595 807 ); 596 808 } 597 809 598 - function StarterPackCard(props: { pack: DiagnosticStarterPack }) { 599 - const title = () => props.pack.title ?? props.pack.name ?? props.pack.record?.name ?? "Starter pack"; 810 + function StarterPackCard(props: { onOpenExplorerTarget?: (target: string) => void; pack: DiagnosticStarterPack }) { 600 811 const count = () => props.pack.listItemCount ?? props.pack.record?.listItemsSample?.length ?? 0; 812 + const title = () => props.pack.title ?? props.pack.name ?? props.pack.record?.name ?? "Starter pack"; 601 813 602 814 return ( 603 815 <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"> 816 + <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> 605 817 <div class="min-w-0"> 606 818 <p class="m-0 text-base font-semibold text-on-surface">{title()}</p> 607 819 <p class="m-0 mt-1 text-sm text-on-surface-variant"> 608 - {props.pack.creator?.handle ? `@${props.pack.creator.handle}` : "Unknown creator"} 820 + Creator: {formatHandle(props.pack.creator?.handle ?? null)} 609 821 </p> 610 822 <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant"> 611 823 {props.pack.description ?? props.pack.record?.description ?? "No description provided."} 612 824 </p> 613 825 </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 826 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> 827 + <div class="grid shrink-0 justify-items-start gap-2 sm:justify-items-end"> 828 + <span class="rounded-full bg-white/5 px-3 py-1 text-xs text-on-surface-variant">{count()} members</span> 829 + <Show when={props.pack.uri}> 830 + {uri => ( 831 + <button 832 + type="button" 833 + 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" 834 + disabled={!props.onOpenExplorerTarget} 835 + onClick={() => props.onOpenExplorerTarget?.(uri())}> 836 + <Icon kind="ext-link" aria-hidden="true" /> 837 + AT Explorer 838 + </button> 839 + )} 840 + </Show> 841 + </div> 842 + </div> 625 843 </div> 626 844 ); 627 845 } ··· 637 855 <p class="m-0 text-sm font-semibold text-on-surface">{props.title}</p> 638 856 <div class="grid gap-3"> 639 857 <For each={props.items}> 640 - {(item) => { 858 + {(item, index) => { 641 859 const name = () => item.displayName ?? item.handle; 642 860 return ( 643 - <div class="flex items-start gap-3 rounded-2xl bg-black/20 p-3"> 861 + <Motion.div 862 + class="flex items-start gap-3 rounded-2xl bg-black/20 p-3" 863 + initial={{ opacity: 0, y: 8 }} 864 + animate={{ opacity: 1, y: 0 }} 865 + transition={{ delay: Math.min(index() * 0.04, 0.16), duration: 0.16 }}> 644 866 <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 867 <Show when={item.avatar} fallback={<span>{initials(name())}</span>}> 646 868 {(src) => <img alt="" class="h-full w-full object-cover" src={src()} />} ··· 648 870 </div> 649 871 <div class="min-w-0"> 650 872 <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> 873 + <p class="m-0 text-xs text-on-surface-variant">{formatHandle(item.handle)}</p> 652 874 <Show when={item.description}> 653 875 {(description) => ( 654 876 <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{description()}</p> 655 877 )} 656 878 </Show> 657 879 </div> 658 - </div> 880 + </Motion.div> 659 881 ); 660 882 }} 661 883 </For> ··· 667 889 function DiagnosticsListSkeleton() { 668 890 return ( 669 891 <div class="grid gap-4"> 670 - <For each={Array.from({ length: 3 })}>{() => <div class="h-28 rounded-3xl bg-white/3" />}</For> 892 + <For each={Array.from({ length: 3 })}> 893 + {() => ( 894 + <div class="grid h-32 gap-3 rounded-3xl bg-white/3 p-4"> 895 + <div class="h-4 w-28 rounded-full bg-white/6" /> 896 + <div class="h-4 w-44 rounded-full bg-white/6" /> 897 + <div class="h-4 w-full rounded-full bg-white/6" /> 898 + </div> 899 + )} 900 + </For> 671 901 </div> 672 902 ); 673 903 } ··· 675 905 function DiagnosticsLabelSkeleton() { 676 906 return ( 677 907 <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> 908 + <For each={Array.from({ length: 5 })}>{() => <div class="h-10 w-32 rounded-full bg-white/3" />}</For> 679 909 </div> 680 910 ); 681 911 } ··· 683 913 function DiagnosticsStarterPackSkeleton() { 684 914 return ( 685 915 <div class="grid gap-3"> 686 - <For each={Array.from({ length: 2 })}>{() => <div class="h-24 rounded-3xl bg-white/3" />}</For> 916 + <For each={Array.from({ length: 2 })}> 917 + {() => ( 918 + <div class="grid h-28 gap-3 rounded-3xl bg-white/3 p-4"> 919 + <div class="h-4 w-40 rounded-full bg-white/6" /> 920 + <div class="h-4 w-28 rounded-full bg-white/6" /> 921 + <div class="h-4 w-full rounded-full bg-white/6" /> 922 + </div> 923 + )} 924 + </For> 687 925 </div> 688 926 ); 689 927 } ··· 691 929 function DiagnosticsBlockSkeleton() { 692 930 return ( 693 931 <div class="grid gap-3"> 694 - <For each={Array.from({ length: 2 })}>{() => <div class="h-20 rounded-3xl bg-white/3" />}</For> 932 + <For each={Array.from({ length: 2 })}>{() => <div class="h-24 rounded-3xl bg-white/3" />}</For> 695 933 </div> 696 934 ); 697 935 }
+263
src/components/diagnostics/RecordBacklinksPanel.tsx
··· 1 + import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; 2 + import { normalizeError } from "$/lib/utils/text"; 3 + import * as logger from "@tauri-apps/plugin-log"; 4 + import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 5 + import { createStore } from "solid-js/store"; 6 + import { Motion, Presence } from "solid-motionone"; 7 + import { ArrowIcon, Icon } from "../shared/Icon"; 8 + 9 + type GroupKey = "likes" | "reposts" | "replies" | "quotes"; 10 + 11 + type RecordBacklinksPanelProps = { uri?: string | null }; 12 + 13 + type BacklinksState = { error: string | null; groups: Record<GroupKey, DiagnosticBacklinkGroup>; loading: boolean }; 14 + 15 + const EMPTY_GROUP: DiagnosticBacklinkGroup = { cursor: null, records: [], total: 0 }; 16 + 17 + const GROUP_ORDER: Array<{ copy: string; icon: "heart" | "repost" | "reply" | "quote"; key: GroupKey; label: string }> = 18 + [ 19 + { copy: "Records that liked this subject.", icon: "heart", key: "likes", label: "Likes" }, 20 + { copy: "Records that reposted this subject.", icon: "repost", key: "reposts", label: "Reposts" }, 21 + { copy: "Direct replies to this subject.", icon: "reply", key: "replies", label: "Replies" }, 22 + { copy: "Records that embedded this URI.", icon: "quote", key: "quotes", label: "Quote posts" }, 23 + ]; 24 + 25 + function createInitialState(): BacklinksState { 26 + return { 27 + error: null, 28 + groups: { likes: EMPTY_GROUP, quotes: EMPTY_GROUP, replies: EMPTY_GROUP, reposts: EMPTY_GROUP }, 29 + loading: true, 30 + }; 31 + } 32 + 33 + function createIdleState(): BacklinksState { 34 + return { 35 + error: null, 36 + groups: { likes: EMPTY_GROUP, quotes: EMPTY_GROUP, replies: EMPTY_GROUP, reposts: EMPTY_GROUP }, 37 + loading: false, 38 + }; 39 + } 40 + 41 + function initials(name: string) { 42 + return name.trim().slice(0, 1).toUpperCase() || "?"; 43 + } 44 + 45 + function formatHandle(handle: string | null | undefined, did: string | null | undefined) { 46 + if (handle) { 47 + return handle.startsWith("@") ? handle : `@${handle}`; 48 + } 49 + 50 + return did ?? "Unknown"; 51 + } 52 + 53 + export function RecordBacklinksPanel(props: RecordBacklinksPanelProps) { 54 + const [state, setState] = createStore<BacklinksState>(createInitialState()); 55 + const [expandedByKey, setExpandedByKey] = createStore<Record<GroupKey, boolean>>({ 56 + likes: false, 57 + quotes: false, 58 + replies: false, 59 + reposts: false, 60 + }); 61 + const activeUri = createMemo(() => props.uri?.trim() || ""); 62 + let requestId = 0; 63 + 64 + createEffect(() => { 65 + const uri = activeUri(); 66 + if (!uri) { 67 + setState(createIdleState()); 68 + return; 69 + } 70 + 71 + const currentRequest = ++requestId; 72 + setState(createInitialState()); 73 + setExpandedByKey({ likes: false, quotes: false, replies: false, reposts: false }); 74 + 75 + void loadBacklinks(currentRequest, uri); 76 + }); 77 + 78 + async function loadBacklinks(currentRequest: number, uri: string) { 79 + try { 80 + const response = await getRecordBacklinks(uri); 81 + if (currentRequest !== requestId) { 82 + return; 83 + } 84 + 85 + setState({ 86 + error: null, 87 + groups: { 88 + likes: response.likes ?? EMPTY_GROUP, 89 + quotes: response.quotes ?? EMPTY_GROUP, 90 + replies: response.replies ?? EMPTY_GROUP, 91 + reposts: response.reposts ?? EMPTY_GROUP, 92 + }, 93 + loading: false, 94 + }); 95 + } catch (error) { 96 + const message = normalizeError(error); 97 + if (currentRequest !== requestId) { 98 + return; 99 + } 100 + 101 + setState({ error: message, loading: false }); 102 + logger.warn("failed to load record backlinks", { keyValues: { error: message, uri } }); 103 + } 104 + } 105 + 106 + function toggleGroup(key: GroupKey) { 107 + setExpandedByKey(key, (value) => !value); 108 + } 109 + 110 + return ( 111 + <Switch 112 + fallback={ 113 + <div class="grid gap-3"> 114 + <For each={GROUP_ORDER}> 115 + {(group) => ( 116 + <BacklinkGroupCard 117 + copy={group.copy} 118 + expanded={expandedByKey[group.key]} 119 + icon={group.icon} 120 + items={state.groups[group.key].records} 121 + label={group.label} 122 + onToggle={() => toggleGroup(group.key)} 123 + total={state.groups[group.key].total ?? state.groups[group.key].records.length} /> 124 + )} 125 + </For> 126 + </div> 127 + }> 128 + <Match when={!activeUri()}> 129 + <div class="rounded-3xl bg-white/3 p-4 text-sm leading-relaxed text-on-surface-variant"> 130 + Backlinks are record-specific context. Open a post or record to inspect the public references pointing at it. 131 + </div> 132 + </Match> 133 + <Match when={state.loading}> 134 + <BacklinksSkeleton /> 135 + </Match> 136 + <Match when={state.error}> 137 + {(error) => ( 138 + <div class="grid gap-3 rounded-3xl bg-white/3 p-4"> 139 + <p class="m-0 text-sm text-on-surface-variant">{error()}</p> 140 + <button 141 + type="button" 142 + 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" 143 + onClick={() => void loadBacklinks(requestId, activeUri())}> 144 + <Icon kind="refresh" aria-hidden="true" /> 145 + Retry 146 + </button> 147 + </div> 148 + )} 149 + </Match> 150 + </Switch> 151 + ); 152 + } 153 + 154 + function BacklinkGroupCard( 155 + props: { 156 + copy: string; 157 + expanded: boolean; 158 + icon: "heart" | "repost" | "reply" | "quote"; 159 + items: DiagnosticBacklinkItem[]; 160 + label: string; 161 + onToggle: () => void; 162 + total: number | null | undefined; 163 + }, 164 + ) { 165 + const total = () => props.total ?? props.items.length; 166 + 167 + return ( 168 + <section class="overflow-hidden rounded-3xl bg-white/3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 169 + <button 170 + type="button" 171 + aria-expanded={props.expanded} 172 + class="flex w-full items-center justify-between gap-4 px-4 py-4 text-left transition duration-150 hover:bg-white/4" 173 + onClick={() => props.onToggle()}> 174 + <div class="flex min-w-0 items-start gap-3"> 175 + <div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-surface-container-high text-primary"> 176 + <Icon kind={props.icon} aria-hidden="true" /> 177 + </div> 178 + <div class="min-w-0"> 179 + <p class="m-0 text-sm font-semibold text-on-surface">{props.label}</p> 180 + <p class="m-0 mt-1 text-xs leading-relaxed text-on-surface-variant">{props.copy}</p> 181 + </div> 182 + </div> 183 + 184 + <div class="flex shrink-0 items-center gap-3"> 185 + <span class="rounded-full bg-white/5 px-3 py-1 text-xs text-on-surface-variant"> 186 + {total()} {total() === 1 ? "record" : "records"} 187 + </span> 188 + <Motion.span animate={{ rotate: props.expanded ? 0 : -90 }} transition={{ duration: 0.16 }}> 189 + <ArrowIcon class="text-on-surface-variant" direction="down" /> 190 + </Motion.span> 191 + </div> 192 + </button> 193 + 194 + <Presence> 195 + <Show when={props.expanded}> 196 + <Motion.div 197 + class="grid gap-3 px-4 pb-4" 198 + initial={{ opacity: 0, height: 0 }} 199 + animate={{ opacity: 1, height: "auto" }} 200 + exit={{ opacity: 0, height: 0 }} 201 + transition={{ duration: 0.18 }}> 202 + <For 203 + each={props.items} 204 + fallback={ 205 + <div class="rounded-2xl bg-black/20 p-4 text-sm text-on-surface-variant"> 206 + No visible records are available in this section right now. 207 + </div> 208 + }> 209 + {(item, index) => <BacklinkRecordCard item={item} index={index()} />} 210 + </For> 211 + </Motion.div> 212 + </Show> 213 + </Presence> 214 + </section> 215 + ); 216 + } 217 + 218 + function BacklinkRecordCard(props: { index: number; item: DiagnosticBacklinkItem }) { 219 + const actorLabel = createMemo(() => 220 + props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown" 221 + ); 222 + const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did)); 223 + 224 + return ( 225 + <Motion.div 226 + class="flex items-start gap-3 rounded-2xl bg-black/20 p-3" 227 + initial={{ opacity: 0, y: 8 }} 228 + animate={{ opacity: 1, y: 0 }} 229 + transition={{ delay: Math.min(props.index * 0.04, 0.16), duration: 0.16 }}> 230 + <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"> 231 + <Show when={props.item.profile?.avatar} fallback={<span>{initials(actorLabel())}</span>}> 232 + {(src) => <img alt="" class="h-full w-full object-cover" src={src()} />} 233 + </Show> 234 + </div> 235 + 236 + <div class="min-w-0"> 237 + <div class="flex flex-wrap items-center gap-2"> 238 + <p class="m-0 text-sm font-medium text-on-surface">{actorLabel()}</p> 239 + <span class="rounded-full bg-white/5 px-2.5 py-1 text-xs text-on-surface-variant"> 240 + {props.item.collection ?? "record"} 241 + </span> 242 + </div> 243 + <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p> 244 + <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p> 245 + </div> 246 + </Motion.div> 247 + ); 248 + } 249 + 250 + function BacklinksSkeleton() { 251 + return ( 252 + <div class="grid gap-3"> 253 + <For each={Array.from({ length: 4 })}> 254 + {() => ( 255 + <div class="grid gap-3 rounded-3xl bg-white/3 p-4"> 256 + <div class="h-4 w-24 rounded-full bg-white/6" /> 257 + <div class="h-4 w-full rounded-full bg-white/6" /> 258 + </div> 259 + )} 260 + </For> 261 + </div> 262 + ); 263 + }
+56
src/components/explorer/ExplorerPanel.test.tsx
··· 6 6 const describeServerMock = vi.hoisted(() => vi.fn()); 7 7 const exportRepoCarMock = vi.hoisted(() => vi.fn()); 8 8 const getRecordMock = vi.hoisted(() => vi.fn()); 9 + const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 10 + const getProfileMock = vi.hoisted(() => vi.fn()); 9 11 const listRecordsMock = vi.hoisted(() => vi.fn()); 10 12 const queryLabelsMock = vi.hoisted(() => vi.fn()); 11 13 const resolveInputMock = vi.hoisted(() => vi.fn()); ··· 24 26 }), 25 27 ); 26 28 29 + vi.mock("$/lib/api/profile", () => ({ getProfile: getProfileMock })); 30 + vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 27 31 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 28 32 29 33 function renderPanel() { ··· 36 40 describeServerMock.mockReset(); 37 41 exportRepoCarMock.mockReset(); 38 42 getRecordMock.mockReset(); 43 + getRecordBacklinksMock.mockReset(); 44 + getProfileMock.mockReset(); 39 45 listRecordsMock.mockReset(); 40 46 queryLabelsMock.mockReset(); 41 47 resolveInputMock.mockReset(); 42 48 listenMock.mockReset(); 43 49 44 50 exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); 51 + getProfileMock.mockResolvedValue({ 52 + did: "did:plc:alice", 53 + handle: "alice.test", 54 + followersCount: 28, 55 + followsCount: 14, 56 + }); 57 + getRecordBacklinksMock.mockResolvedValue({ 58 + likes: { cursor: null, records: [], total: 3 }, 59 + quotes: { cursor: null, records: [], total: 1 }, 60 + replies: { cursor: null, records: [], total: 2 }, 61 + reposts: { cursor: null, records: [], total: 4 }, 62 + }); 45 63 listenMock.mockResolvedValue(() => {}); 46 64 queryLabelsMock.mockResolvedValue({ labels: [] }); 47 65 }); ··· 70 88 expect(resolveInputMock).toHaveBeenCalledWith("@alice.test"); 71 89 expect(await screen.findByRole("button", { name: /app\.bsky\.feed\.like/u })).toBeInTheDocument(); 72 90 expect(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })).toBeInTheDocument(); 91 + expect(await screen.findByText("Followers")).toBeInTheDocument(); 92 + expect(screen.getByText("28")).toBeInTheDocument(); 93 + expect(screen.getByText("14")).toBeInTheDocument(); 73 94 expect(screen.queryByText("0 records")).not.toBeInTheDocument(); 74 95 expect(screen.queryByText("Count unavailable")).not.toBeInTheDocument(); 75 96 }); ··· 167 188 expect(resolveInputMock).toHaveBeenCalledWith("https://pds.example.com"); 168 189 expect(await screen.findByText("Hosted Repositories")).toBeInTheDocument(); 169 190 expect(screen.getByRole("button", { name: /did:plc:hosted/u })).toBeInTheDocument(); 191 + }); 192 + 193 + it("shows record backlinks as a supplementary explorer panel", async () => { 194 + resolveInputMock.mockResolvedValue({ 195 + input: "at://did:plc:alice/app.bsky.feed.post/123", 196 + inputKind: "atUri", 197 + targetKind: "record", 198 + normalizedInput: "at://did:plc:alice/app.bsky.feed.post/123", 199 + uri: "at://did:plc:alice/app.bsky.feed.post/123", 200 + did: "did:plc:alice", 201 + handle: "alice.test", 202 + pdsUrl: "https://pds.example.com", 203 + collection: "app.bsky.feed.post", 204 + rkey: "123", 205 + }); 206 + getRecordMock.mockResolvedValue({ 207 + cid: "cid-123", 208 + value: { $type: "app.bsky.feed.post", text: "Explorer record" }, 209 + }); 210 + getRecordBacklinksMock.mockResolvedValue({ 211 + likes: { cursor: null, records: [], total: 3 }, 212 + quotes: { cursor: null, records: [], total: 1 }, 213 + replies: { cursor: null, records: [], total: 2 }, 214 + reposts: { cursor: null, records: [], total: 4 }, 215 + }); 216 + 217 + renderPanel(); 218 + 219 + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); 220 + fireEvent.input(input, { target: { value: "at://did:plc:alice/app.bsky.feed.post/123" } }); 221 + fireEvent.submit(input.closest("form")!); 222 + 223 + expect(await screen.findByText("Backlinks")).toBeInTheDocument(); 224 + expect(await screen.findByText("3 records")).toBeInTheDocument(); 225 + expect(screen.getByText("4 records")).toBeInTheDocument(); 170 226 }); 171 227 });
+18 -4
src/components/explorer/ExplorerPanel.tsx
··· 7 7 queryLabels, 8 8 resolveInput, 9 9 } from "$/lib/api/explorer"; 10 + import { getProfile } from "$/lib/api/profile"; 10 11 import type { ExplorerNavigation, ExplorerTargetKind } from "$/lib/api/types/explorer"; 11 12 import { NAVIGATION_EVENT } from "$/lib/constants/events"; 13 + import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; 12 14 import { listen } from "@tauri-apps/api/event"; 13 15 import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 14 16 import { produce } from "solid-js/store"; ··· 127 129 } 128 130 case "repo": { 129 131 if (resolved.did) { 130 - const repoData = await describeRepo(resolved.did); 132 + const [repoData, profile] = await Promise.all([ 133 + describeRepo(resolved.did), 134 + getProfile(resolved.did).catch(() => null), 135 + ]); 131 136 const collections = extractCollections(repoData); 132 137 finalViewState = { 133 138 ...viewState, ··· 137 142 did: resolved.did, 138 143 handle: resolved.handle || resolved.did, 139 144 pdsUrl: resolved.pdsUrl, 145 + socialSummary: profile 146 + ? { followerCount: profile.followersCount ?? null, followingCount: profile.followsCount ?? null } 147 + : null, 140 148 }, 141 149 }; 142 150 } ··· 346 354 347 355 onMount(() => { 348 356 let unlisten: (() => void) | undefined; 357 + const pendingTarget = consumeQueuedExplorerTarget(); 349 358 350 359 void listen<ExplorerNavigation>(NAVIGATION_EVENT, (event) => { 351 360 const target = event.payload.target; ··· 356 365 357 366 document.addEventListener("keydown", handleKeyDown); 358 367 368 + if (pendingTarget) { 369 + void handleResolveInput(pendingTarget); 370 + } 371 + 359 372 onCleanup(() => { 360 373 unlisten?.(); 361 374 document.removeEventListener("keydown", handleKeyDown); ··· 423 436 424 437 <Match when={view.level === "repo" && view.repoData}> 425 438 <RepoView 439 + collections={view.repoData!.collections} 426 440 did={view.repoData!.did} 427 441 handle={view.repoData!.handle} 442 + onCollectionClick={(collection: string) => handleCollectionClick(view.repoData!.did, collection)} 428 443 pdsUrl={view.repoData!.pdsUrl} 429 - collections={view.repoData!.collections} 430 - onCollectionClick={(collection: string) => handleCollectionClick(view.repoData!.did, collection)} 431 - onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} /> 444 + onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} 445 + socialSummary={view.repoData!.socialSummary} /> 432 446 </Match> 433 447 434 448 <Match when={view.level === "collection" && view.collectionData}>
+7 -1
src/components/explorer/types.ts
··· 8 8 9 9 type RepoViewCollection = { nsid: string }; 10 10 11 - type RepoViewData = { collections: Array<RepoViewCollection>; handle: string; did: string; pdsUrl: string | null }; 11 + type RepoViewData = { 12 + collections: Array<RepoViewCollection>; 13 + did: string; 14 + handle: string; 15 + pdsUrl: string | null; 16 + socialSummary?: { followerCount: number | null; followingCount: number | null } | null; 17 + }; 12 18 13 19 type CollectionViewData = { 14 20 records: Array<Record<string, unknown>>;
+13 -1
src/components/explorer/views/RecordView.test.tsx
··· 1 1 import { render, screen } from "@solidjs/testing-library"; 2 - import { describe, expect, it } from "vitest"; 2 + import { describe, expect, it, vi } from "vitest"; 3 3 import { RecordView } from "./RecordView"; 4 4 5 + const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 6 + 7 + vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 8 + 5 9 describe("RecordView", () => { 6 10 it("renders falsey JSON values and moderation labels", () => { 11 + getRecordBacklinksMock.mockResolvedValue({ 12 + likes: { cursor: null, records: [], total: 0 }, 13 + quotes: { cursor: null, records: [], total: 0 }, 14 + replies: { cursor: null, records: [], total: 0 }, 15 + reposts: { cursor: null, records: [], total: 0 }, 16 + }); 17 + 7 18 render(() => ( 8 19 <RecordView 9 20 record={{ $type: "app.test.record", empty: "", flagged: false, nested: { count: 0 } }} ··· 16 27 expect(screen.getByText("\"\"")).toBeInTheDocument(); 17 28 expect(screen.getByText("false")).toBeInTheDocument(); 18 29 expect(screen.getByText("0")).toBeInTheDocument(); 30 + expect(screen.getByText("Backlinks")).toBeInTheDocument(); 19 31 expect(screen.getByText("Moderation Labels")).toBeInTheDocument(); 20 32 expect(screen.getByText("!warn")).toBeInTheDocument(); 21 33 });
+5
src/components/explorer/views/RecordView.tsx
··· 1 + import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; 1 2 import { type JsonValue, JsonValueAs } from "$/components/explorer/types"; 2 3 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 3 4 import { getStringProperty, isRecordLike, isString } from "$/lib/type-guards"; ··· 203 204 </CollapsibleSection> 204 205 205 206 <KnownRecordPreview record={props.record} /> 207 + 208 + <CollapsibleSection title="Backlinks"> 209 + <RecordBacklinksPanel uri={props.uri} /> 210 + </CollapsibleSection> 206 211 207 212 <Show when={props.labels.length > 0}> 208 213 <CollapsibleSection title="Moderation Labels">
+39 -13
src/components/explorer/views/RepoView.tsx
··· 1 1 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 - import { For } from "solid-js"; 2 + import { For, Show } from "solid-js"; 3 3 4 4 type RepoViewProps = { 5 + collections: Array<{ nsid: string }>; 5 6 did: string; 6 7 handle: string; 7 - pdsUrl: string | null; 8 - collections: Array<{ nsid: string }>; 9 8 onCollectionClick: (collection: string) => void; 10 9 onPdsClick: () => void; 10 + pdsUrl: string | null; 11 + socialSummary?: { followerCount: number | null; followingCount: number | null } | null; 11 12 }; 12 13 14 + function formatCount(value: number | null | undefined) { 15 + return value === null || value === undefined ? "Unavailable" : value.toLocaleString(); 16 + } 17 + 13 18 export function RepoView(props: RepoViewProps) { 14 19 return ( 15 20 <div class="grid gap-6"> 16 21 <section class="rounded-2xl border border-white/5 p-6"> 17 - <div class="flex items-center gap-3 mb-4"> 18 - <div class="w-12 h-12 rounded-xl flex items-center justify-center bg-primary/15"> 22 + <div class="mb-4 flex items-center gap-3"> 23 + <div class="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/15"> 19 24 <Icon kind="user" class="text-primary text-xl" /> 20 25 </div> 21 26 <div> ··· 24 29 </div> 25 30 </div> 26 31 27 - <div class="grid grid-cols-2 gap-4"> 28 - <div class="p-3 rounded-xl bg-white/5"> 29 - <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">DID</p> 32 + <div class="grid gap-4 md:grid-cols-2"> 33 + <div class="rounded-xl bg-white/5 p-3"> 34 + <p class="mb-1 text-xs uppercase tracking-wider text-on-surface-variant">DID</p> 30 35 <p class="text-xs font-mono truncate">{props.did}</p> 31 36 </div> 32 37 <button 33 38 onClick={() => props.onPdsClick()} 34 - class="p-3 rounded-xl bg-white/5 text-left hover:bg-white/8 transition-colors"> 35 - <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">PDS</p> 39 + class="rounded-xl bg-white/5 p-3 text-left transition-colors hover:bg-white/8"> 40 + <p class="mb-1 text-xs uppercase tracking-wider text-on-surface-variant">PDS</p> 36 41 <p class="text-xs font-mono truncate text-primary">{props.pdsUrl || "Unknown"}</p> 37 42 </button> 38 43 </div> 44 + 45 + <Show when={props.socialSummary}> 46 + {(summary) => ( 47 + <div class="mt-4 grid gap-3 sm:grid-cols-2"> 48 + <div class="rounded-2xl bg-white/4 p-4"> 49 + <p class="mb-1 text-xs uppercase tracking-[0.14em] text-on-surface-variant">Followers</p> 50 + <p class="text-2xl font-medium text-on-surface">{formatCount(summary().followerCount)}</p> 51 + <p class="mt-2 text-xs leading-relaxed text-on-surface-variant"> 52 + Public relationship context for this repository. 53 + </p> 54 + </div> 55 + <div class="rounded-2xl bg-white/4 p-4"> 56 + <p class="mb-1 text-xs uppercase tracking-[0.14em] text-on-surface-variant">Following</p> 57 + <p class="text-2xl font-medium text-on-surface">{formatCount(summary().followingCount)}</p> 58 + <p class="mt-2 text-xs leading-relaxed text-on-surface-variant"> 59 + Summary only. Block counts stay inside diagnostics. 60 + </p> 61 + </div> 62 + </div> 63 + )} 64 + </Show> 39 65 </section> 40 66 41 - <section class="rounded-2xl border border-white/5 overflow-hidden"> 42 - <div class="px-6 py-4 border-b border-white/5 bg-white/5"> 67 + <section class="overflow-hidden rounded-2xl border border-white/5"> 68 + <div class="border-b border-white/5 bg-white/5 px-6 py-4"> 43 69 <h2 class="text-lg font-medium">Collections</h2> 44 70 </div> 45 71 ··· 48 74 {(collection) => ( 49 75 <button 50 76 onClick={() => props.onCollectionClick(collection.nsid)} 51 - class="w-full flex items-center justify-between p-4 text-left hover:bg-white/5 transition-colors"> 77 + class="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-white/5"> 52 78 <div class="flex items-center gap-3"> 53 79 <Icon kind="folder" class="text-on-surface-variant" /> 54 80 <span class="text-sm">{collection.nsid}</span>
+52
src/components/profile/ProfilePanel.test.tsx
··· 6 6 const followActorMock = vi.hoisted(() => vi.fn()); 7 7 const getActorLikesMock = vi.hoisted(() => vi.fn()); 8 8 const getAuthorFeedMock = vi.hoisted(() => vi.fn()); 9 + const getAccountBlockedByMock = vi.hoisted(() => vi.fn()); 10 + const getAccountBlockingMock = vi.hoisted(() => vi.fn()); 11 + const getAccountLabelsMock = vi.hoisted(() => vi.fn()); 12 + const getAccountListsMock = vi.hoisted(() => vi.fn()); 13 + const getAccountStarterPacksMock = vi.hoisted(() => vi.fn()); 14 + const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 9 15 const getFollowersMock = vi.hoisted(() => vi.fn()); 10 16 const getFollowsMock = vi.hoisted(() => vi.fn()); 11 17 const getProfileMock = vi.hoisted(() => vi.fn()); ··· 22 28 getFollows: getFollowsMock, 23 29 getProfile: getProfileMock, 24 30 unfollowActor: unfollowActorMock, 31 + }), 32 + ); 33 + 34 + vi.mock( 35 + "$/lib/api/diagnostics", 36 + () => ({ 37 + getAccountBlockedBy: getAccountBlockedByMock, 38 + getAccountBlocking: getAccountBlockingMock, 39 + getAccountLabels: getAccountLabelsMock, 40 + getAccountLists: getAccountListsMock, 41 + getAccountStarterPacks: getAccountStarterPacksMock, 42 + getRecordBacklinks: getRecordBacklinksMock, 25 43 }), 26 44 ); 27 45 ··· 77 95 getProfileMock.mockResolvedValue(createProfile()); 78 96 getAuthorFeedMock.mockResolvedValue({ cursor: null, feed: [] }); 79 97 getActorLikesMock.mockResolvedValue({ cursor: null, feed: [] }); 98 + getAccountListsMock.mockResolvedValue({ 99 + lists: [{ 100 + description: "Builders and product people.", 101 + memberCount: 12, 102 + purpose: "curate", 103 + title: "Builders", 104 + creator: { handle: "mira.test" }, 105 + }], 106 + total: 1, 107 + truncated: false, 108 + }); 109 + getAccountLabelsMock.mockResolvedValue({ labels: [], sourceProfiles: {}, cursor: null }); 110 + getAccountBlockedByMock.mockResolvedValue({ cursor: null, items: [], total: 0 }); 111 + getAccountBlockingMock.mockResolvedValue({ cursor: null, items: [] }); 112 + getAccountStarterPacksMock.mockResolvedValue({ starterPacks: [], total: 0, truncated: false }); 113 + getRecordBacklinksMock.mockResolvedValue({ 114 + likes: { cursor: null, records: [], total: 0 }, 115 + quotes: { cursor: null, records: [], total: 0 }, 116 + replies: { cursor: null, records: [], total: 0 }, 117 + reposts: { cursor: null, records: [], total: 0 }, 118 + }); 80 119 getFollowersMock.mockResolvedValue({ actors: [], cursor: null }); 81 120 getFollowsMock.mockResolvedValue({ actors: [], cursor: null }); 82 121 followActorMock.mockResolvedValue({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); ··· 168 207 await waitFor(() => { 169 208 expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 170 209 }); 210 + }); 211 + 212 + it("renders diagnostics in the Context tab without making it the default tab", async () => { 213 + renderProfilePanel(); 214 + 215 + expect(await screen.findByRole("button", { name: "Follow" })).toBeInTheDocument(); 216 + expect(screen.queryByText("Social Diagnostics")).not.toBeInTheDocument(); 217 + 218 + fireEvent.click(screen.getByRole("button", { name: "Context" })); 219 + 220 + expect(await screen.findByText("Social Diagnostics")).toBeInTheDocument(); 221 + expect(await screen.findByText("Builders")).toBeInTheDocument(); 222 + expect(screen.getByText("Public social context for this account")).toBeInTheDocument(); 171 223 }); 172 224 });
+29 -10
src/components/profile/ProfilePanel.tsx
··· 1 + import { DiagnosticsPanel } from "$/components/deck/DiagnosticsPanel"; 1 2 import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 2 3 import { useAppSession } from "$/contexts/app-session"; 3 4 import { ··· 10 11 unfollowActor, 11 12 } from "$/lib/api/profile"; 12 13 import { buildMessagesRoute } from "$/lib/conversations"; 14 + import { queueExplorerTarget } from "$/lib/explorer-navigation"; 13 15 import { buildThreadRoute } from "$/lib/feeds"; 14 16 import { buildProfileRoute, filterProfileFeed, getProfileRouteActor, type ProfileTab } from "$/lib/profile"; 15 17 import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic } from "$/lib/types"; ··· 27 29 28 30 const FEED_PAGE_SIZE = 30; 29 31 30 - const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes"]; 32 + const PROFILE_TABS: ProfileTab[] = ["posts", "replies", "media", "likes", "context"]; 31 33 32 34 export function ProfilePanel(props: { actor: string | null; embedded?: boolean }) { 33 35 const navigate = useNavigate(); ··· 90 92 createEffect(() => { 91 93 const actor = activeActor(); 92 94 if (!actor || state.profileLoading || !!state.profileError) { 95 + return; 96 + } 97 + 98 + if (state.activeTab === "context") { 93 99 return; 94 100 } 95 101 ··· 220 226 navigate(buildThreadRoute(uri)); 221 227 } 222 228 229 + function openExplorerTarget(target: string) { 230 + queueExplorerTarget(target); 231 + void navigate("/explorer"); 232 + } 233 + 223 234 async function handleFollow() { 224 235 const profile = state.profile; 225 236 if (!profile || state.followLoading) { ··· 411 422 412 423 <ProfileTabs activeTab={state.activeTab} onSelect={selectTab} /> 413 424 414 - <ProfileFeedSection 415 - activeTab={state.activeTab} 416 - cursor={activeFeedState().cursor} 417 - error={activeFeedState().error} 418 - items={visibleItems()} 419 - loading={activeFeedState().loading} 420 - loadingMore={activeFeedState().loadingMore} 421 - onLoadMore={handleLoadMore} 422 - onOpenThread={openThread} /> 425 + <Show 426 + when={state.activeTab === "context"} 427 + fallback={ 428 + <ProfileFeedSection 429 + activeTab={state.activeTab} 430 + cursor={activeFeedState().cursor} 431 + error={activeFeedState().error} 432 + items={visibleItems()} 433 + loading={activeFeedState().loading} 434 + loadingMore={activeFeedState().loadingMore} 435 + onLoadMore={handleLoadMore} 436 + onOpenThread={openThread} /> 437 + }> 438 + <div class="px-3 pb-4 max-[520px]:px-2"> 439 + <DiagnosticsPanel did={profile().did} embedded onOpenExplorerTarget={openExplorerTarget} /> 440 + </div> 441 + </Show> 423 442 </> 424 443 )} 425 444 </Show>
+3
src/components/profile/profile-state.ts
··· 73 73 case "media": { 74 74 return "Media"; 75 75 } 76 + case "context": { 77 + return "Context"; 78 + } 76 79 default: { 77 80 return "Likes"; 78 81 }
+24
src/lib/explorer-navigation.ts
··· 1 + const PENDING_EXPLORER_TARGET_KEY = "lazurite:explorer:pending-target"; 2 + 3 + export function queueExplorerTarget(target: string) { 4 + const trimmed = target.trim(); 5 + if (!trimmed || globalThis.window === undefined) { 6 + return; 7 + } 8 + 9 + globalThis.sessionStorage.setItem(PENDING_EXPLORER_TARGET_KEY, trimmed); 10 + } 11 + 12 + export function consumeQueuedExplorerTarget() { 13 + if (globalThis.window === undefined) { 14 + return null; 15 + } 16 + 17 + const target = globalThis.sessionStorage.getItem(PENDING_EXPLORER_TARGET_KEY); 18 + if (!target) { 19 + return null; 20 + } 21 + 22 + globalThis.sessionStorage.removeItem(PENDING_EXPLORER_TARGET_KEY); 23 + return target; 24 + }
+4 -1
src/lib/profile.ts
··· 2 2 import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 3 3 import { asArray, asRecord, optionalNumber, optionalString } from "./type-guards"; 4 4 5 - export type ProfileTab = "posts" | "replies" | "media" | "likes"; 5 + export type ProfileTab = "posts" | "replies" | "media" | "likes" | "context"; 6 6 7 7 export function buildProfileRoute(actor?: string | null) { 8 8 const trimmed = actor?.trim(); ··· 100 100 } 101 101 case "media": { 102 102 return items.filter((item) => !!item.post.embed); 103 + } 104 + case "context": { 105 + return []; 103 106 } 104 107 default: { 105 108 return items;