diff --git a/docs/tasks/08-multicolumn.md b/docs/tasks/08-multicolumn.md index d7a3f89..6a878d7 100644 --- a/docs/tasks/08-multicolumn.md +++ b/docs/tasks/08-multicolumn.md @@ -81,8 +81,4 @@ TweetDeck-style multicolumn layout allowing users to view multiple feeds and/or ### Parking Lot -- [ ] Column templates / saved layouts (e.g., "Research", "Timeline + Notifications") -- [ ] Notification column type - [x] Search results column type -- [ ] Column-level auto-refresh interval override -- [ ] Shared scroll sync between related columns diff --git a/docs/tasks/09-profile.md b/docs/tasks/09-profile.md index 7cedcea..0beee51 100644 --- a/docs/tasks/09-profile.md +++ b/docs/tasks/09-profile.md @@ -47,9 +47,3 @@ Depends on: Milestone 03 (Feeds - post card, feed loading), Milestone 02 (Auth - - [x] Paginated actor list with compact cards (avatar, name, handle, bio snippet, follow button) - [x] `Presence` slide-up overlay with backdrop blur - [x] Cursor-based pagination with infinite scroll or "Load more" - -### Parking Lot - -- [ ] DM button (requires `chat.bsky.convo.*` implementation) -- [ ] Profile edit screen (display name, bio, avatar, banner, website, pronouns) -- [ ] Mute / block actions from profile view diff --git a/docs/tasks/10-jetstream.md b/docs/tasks/10-jetstream.md index 69bfa06..2599df3 100644 --- a/docs/tasks/10-jetstream.md +++ b/docs/tasks/10-jetstream.md @@ -20,10 +20,3 @@ Spec: [explorer.md](../specs/explorer.md) - [ ] Pause/resume button to freeze the stream without disconnecting - [ ] Record count and events-per-second indicator - [ ] Click record to navigate to its full record view in the explorer - -### Parking Lot - -These require update to the spec & more research before implementation. - -- [ ] **Frontend**: Firehose Viewer -- [ ] **Frontend**: Spacedust integration (see [Task 11](./11-spacedust.md)) diff --git a/docs/tasks/11-spacedust.md b/docs/tasks/11-spacedust.md index edab56e..4fac0d8 100644 --- a/docs/tasks/11-spacedust.md +++ b/docs/tasks/11-spacedust.md @@ -45,9 +45,3 @@ Where Jetstream (Task 10) streams raw firehose records, Spacedust streams *resol - [ ] Spacedust instance URL configuration (alongside Constellation URL in settings) - [ ] Toggle: use Spacedust for real-time notifications (vs. polling `listNotifications`) - [ ] Toggle: `instant` mode (bypass 21-second buffer - faster but noisier) - -### Parking Lot - -- [ ] Spacedust as a column type in multicolumn view (live notification stream) -- [ ] Aggregate Spacedust events into a "live activity" dashboard -- [ ] Spacedust for real-time search result updates diff --git a/docs/tasks/12-social-diagnostics.md b/docs/tasks/12-social-diagnostics.md index 1f968b6..fb90566 100644 --- a/docs/tasks/12-social-diagnostics.md +++ b/docs/tasks/12-social-diagnostics.md @@ -29,59 +29,47 @@ Spec: [social-diagnostics.md](../specs/social-diagnostics.md) ### Frontend - Diagnostics Panel -- [ ] Tabbed panel component with 5 tabs: Lists, Labels, Blocks, Starter Packs, Backlinks -- [ ] Tab switching with `Motion` sliding indicator underline -- [ ] Number key shortcuts (`1`โ€“`5`) for tab switching -- [ ] `Escape` to close panel +- [x] Tabbed panel component with 5 tabs: Lists, Labels, Blocks, Starter Packs, Backlinks +- [x] Tab switching with `Motion` sliding indicator underline +- [x] Number key shortcuts (`1`โ€“`5`) for tab switching +- [x] `Escape` to close panel ### Frontend - Lists Tab -- [ ] List cards: name, owner, description, purpose badge, member count -- [ ] Grouped by purpose (curation / moderation / reference) -- [ ] Skeleton loading matching card dimensions -- [ ] Neutral framing - no aggregate risk scoring or warning badges +- [x] List cards: name, owner, description, purpose badge, member count +- [x] Grouped by purpose (curation / moderation / reference) +- [x] Skeleton loading matching card dimensions +- [x] Neutral framing - no aggregate risk scoring or warning badges ### Frontend - Labels Tab -- [ ] Label chips with source attribution (labeling service name) -- [ ] Uniform muted styling - no severity color-coding -- [ ] Tooltip with label definition, source, and visibility effect -- [ ] Explanatory empty state (what labels are, not "no labels found") -- [ ] `Motion` scale-in on load +- [x] Label chips with source attribution (labeling service name) +- [x] Uniform muted styling - no severity color-coding +- [x] Tooltip with label definition, source, and visibility effect +- [x] Explanatory empty state (what labels are, not "no labels found") +- [x] `Motion` scale-in on load ### Frontend - Blocks Tab -- [ ] Counts-only default view (no names or profile cards on first load) -- [ ] "Show details" expand with contextualizing copy (*"Blocks are a normal part of social media..."*) -- [ ] `Presence` height animation on expand with staggered card fade-in -- [ ] No warning banners, color-coding, or language implying abnormality -- [ ] Self-view framing: "Your boundaries" (not "Who blocked you") +- [x] Counts-only default view (no names or profile cards on first load) +- [x] "Show details" expand with contextualizing copy (*"Blocks are a normal part of social media..."*) +- [x] `Presence` height animation on expand with staggered card fade-in +- [x] No warning banners, color-coding, or language implying abnormality +- [x] Self-view framing: "Your boundaries" (not "Who blocked you") ### Frontend - Starter Packs Tab -- [ ] Compact starter pack cards: title, creator, description, member count -- [ ] Link to view in AT Explorer +- [x] Compact starter pack cards: title, creator, description, member count +- [x] Link to view in AT Explorer ### Frontend - Backlinks Tab (Record Context) -- [ ] Grouped by type: likes, reposts, replies, quote posts -- [ ] Count per type with expandable sections -- [ ] Individual actor/record cards within sections +- [x] Grouped by type: likes, reposts, replies, quote posts +- [x] Count per type with expandable sections +- [x] Individual actor/record cards within sections ### Frontend - Integration Points -- [ ] Profile view: "Context" tab (alongside Posts/Replies/Media/Likes) - not the default tab -- [ ] AT Explorer record view: backlinks supplementary panel (engagement data only, no moderation data) -- [ ] AT Explorer repo view: follower/following counts from Constellation (no block counts in summaries) - -### UX Tone Review - -- [ ] Audit all copy for neutral language - no "risk", "warning", "suspicious", "flagged" -- [ ] Ensure all sensitive sections use progressive disclosure (summary โ†’ details on click) -- [ ] Verify self-view ("my account") uses empowering framing, not anxiety-inducing - -### Parking Lot - -- [ ] Network relationship diff over time (requires historical snapshots) -- [ ] Profile/identity history timeline (handle/DID/PDS changes) -- [ ] Starter Pack search? +- [x] Profile view: "Context" tab (alongside Posts/Replies/Media/Likes) - not the default tab +- [x] AT Explorer record view: backlinks supplementary panel (engagement data only, no moderation data) +- [x] AT Explorer repo view: follower/following counts from Constellation (no block counts in summaries) diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..31df376 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,41 @@ +--- +title: "To-Do List/Parking Lot" +updated: 2026-03-31 +--- + +## Bugs + +1. Lists & Labels Not Working + +## High Priority Updates + +- [ ] Video player +- [ ] Download videos and media attachments +- [ ] Profile RSS + - OK. So making an RSS reader with share to BlueSky would be cool... + +## Multicolumn Layouts + +- [ ] Templates / saved layouts (e.g., "Research", "Timeline + Notifications") +- [ ] Notification column type +- [ ] Column-level auto-refresh interval override + - [ ] Related, but separate, refreshing the "main"/"home" feed +- [ ] Shared scroll sync between related columns + +## Profiles + +- [ ] DM button -> Creates a DM if does not exist +- [ ] Profile edit screen (display name, bio, avatar, banner, website, pronouns) +- [ ] Mute / block actions from profile view + +## Spacedust & Jetstream + +- [ ] Spacedust as a column type in multicolumn view (live notification stream) +- [ ] (raw) Firehose Viewer +- [ ] Jetstream viewer + +## Diagnostics + +- [ ] Network relationship diff over time (requires historical snapshots) +- [ ] Profile/identity history timeline (handle/DID/PDS changes) +- [ ] Starter Pack search? diff --git a/src/components/deck/DiagnosticsColumn.tsx b/src/components/deck/DiagnosticsColumn.tsx index 48e5546..7faf2fd 100644 --- a/src/components/deck/DiagnosticsColumn.tsx +++ b/src/components/deck/DiagnosticsColumn.tsx @@ -1,7 +1,21 @@ +import { queueExplorerTarget } from "$/lib/explorer-navigation"; +import { useNavigate } from "@solidjs/router"; import { DiagnosticsPanel } from "./DiagnosticsPanel"; type DiagnosticsColumnProps = { did: string; onClose?: () => void }; export function DiagnosticsColumn(props: DiagnosticsColumnProps) { - return void 0)} />; + const navigate = useNavigate(); + + function handleOpenExplorerTarget(target: string) { + queueExplorerTarget(target); + void navigate("/explorer"); + } + + return ( + void 0)} + onOpenExplorerTarget={handleOpenExplorerTarget} /> + ); } diff --git a/src/components/deck/DiagnosticsPanel.test.tsx b/src/components/deck/DiagnosticsPanel.test.tsx index ae30d91..4fdf3d5 100644 --- a/src/components/deck/DiagnosticsPanel.test.tsx +++ b/src/components/deck/DiagnosticsPanel.test.tsx @@ -8,6 +8,7 @@ const getAccountLabelsMock = vi.hoisted(() => vi.fn()); const getAccountBlockedByMock = vi.hoisted(() => vi.fn()); const getAccountBlockingMock = vi.hoisted(() => vi.fn()); const getAccountStarterPacksMock = vi.hoisted(() => vi.fn()); +const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); vi.mock( "$/lib/api/diagnostics", @@ -17,6 +18,7 @@ vi.mock( getAccountLabels: getAccountLabelsMock, getAccountLists: getAccountListsMock, getAccountStarterPacks: getAccountStarterPacksMock, + getRecordBacklinks: getRecordBacklinksMock, }), ); @@ -35,6 +37,7 @@ describe("DiagnosticsPanel", () => { getAccountBlockedByMock.mockReset(); getAccountBlockingMock.mockReset(); getAccountStarterPacksMock.mockReset(); + getRecordBacklinksMock.mockReset(); getAccountListsMock.mockResolvedValue({ lists: [{ @@ -55,7 +58,7 @@ describe("DiagnosticsPanel", () => { }); getAccountLabelsMock.mockResolvedValue({ labels: [{ src: "did:plc:labeler", val: "!hide" }], - sourceProfiles: {}, + sourceProfiles: { "did:plc:labeler": { displayName: "Safety Service", handle: "safety.service" } }, cursor: null, }); getAccountBlockedByMock.mockResolvedValue({ @@ -77,6 +80,12 @@ describe("DiagnosticsPanel", () => { total: 1, truncated: false, }); + getRecordBacklinksMock.mockResolvedValue({ + likes: { cursor: null, records: [], total: 0 }, + quotes: { cursor: null, records: [], total: 0 }, + replies: { cursor: null, records: [], total: 0 }, + reposts: { cursor: null, records: [], total: 0 }, + }); }); it("renders the tab shell and switches tabs with keys", async () => { @@ -103,7 +112,7 @@ describe("DiagnosticsPanel", () => { renderPanel(); fireEvent.click(await screen.findByRole("button", { name: "Blocks" })); - expect(await screen.findByText("Blocked by")).toBeInTheDocument(); + expect(await screen.findByText("Boundaries around you")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /show details/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /show details/i })); @@ -113,4 +122,14 @@ describe("DiagnosticsPanel", () => { expect(await screen.findByText("Newcomers")).toBeInTheDocument(); expect(screen.getByText("8 members")).toBeInTheDocument(); }); + + it("explains backlinks when no record URI is selected", async () => { + renderPanel(); + + fireEvent.click(await screen.findByRole("button", { name: "Backlinks" })); + + expect(await screen.findByText(/Backlinks are record-specific engagement context/i)).toBeInTheDocument(); + expect(screen.getByText(/Open a post or record to inspect the public references pointing at it/i)) + .toBeInTheDocument(); + }); }); diff --git a/src/components/deck/DiagnosticsPanel.tsx b/src/components/deck/DiagnosticsPanel.tsx index 3d3ebc9..9dff6aa 100644 --- a/src/components/deck/DiagnosticsPanel.tsx +++ b/src/components/deck/DiagnosticsPanel.tsx @@ -1,3 +1,4 @@ +import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; import { useAppSession } from "$/contexts/app-session"; import { type DiagnosticBlockItem, @@ -11,6 +12,8 @@ import { getAccountLists, getAccountStarterPacks, } from "$/lib/api/diagnostics"; +import { asRecord, getStringProperty } from "$/lib/type-guards"; +import { shouldIgnoreKey } from "$/lib/utils/events"; import { normalizeError } from "$/lib/utils/text"; import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; @@ -20,21 +23,28 @@ import { Icon } from "../shared/Icon"; type DiagnosticsTab = "lists" | "labels" | "blocks" | "starterPacks" | "backlinks"; -type DiagnosticsPanelProps = { did: string; onClose: () => void }; +type DiagnosticsPanelProps = { + did?: string | null; + embedded?: boolean; + onClose?: () => void; + onOpenExplorerTarget?: (target: string) => void; + recordUri?: string | null; +}; type DiagnosticsState = { - lists: DiagnosticList[]; - listsError: string | null; - listsLoading: boolean; - labels: DiagnosticLabel[]; - labelsError: string | null; - labelsLoading: boolean; blockedBy: DiagnosticDidProfile[]; blockedByError: string | null; blockedByLoading: boolean; blocking: DiagnosticBlockItem[]; blockingError: string | null; blockingLoading: boolean; + labels: DiagnosticLabel[]; + labelsError: string | null; + labelsLoading: boolean; + labelsSourceProfiles: Record; + lists: DiagnosticList[]; + listsError: string | null; + listsLoading: boolean; starterPacks: DiagnosticStarterPack[]; starterPacksError: string | null; starterPacksLoading: boolean; @@ -59,6 +69,7 @@ function createInitialState(): DiagnosticsState { labels: [], labelsError: null, labelsLoading: true, + labelsSourceProfiles: {}, lists: [], listsError: null, listsLoading: true, @@ -68,6 +79,27 @@ function createInitialState(): DiagnosticsState { }; } +function createIdleState(): DiagnosticsState { + return { + blockedBy: [], + blockedByError: null, + blockedByLoading: false, + blocking: [], + blockingError: null, + blockingLoading: false, + labels: [], + labelsError: null, + labelsLoading: false, + labelsSourceProfiles: {}, + lists: [], + listsError: null, + listsLoading: false, + starterPacks: [], + starterPacksError: null, + starterPacksLoading: false, + }; +} + function purposeLabel(purpose: string | null | undefined) { switch ((purpose || "").toLowerCase()) { case "curate": @@ -105,18 +137,96 @@ function initials(name: string) { return name.trim().slice(0, 1).toUpperCase() || "?"; } +function formatHandle(handle: string | null | undefined) { + if (!handle) { + return "Unknown"; + } + + return handle.startsWith("did:") || handle.startsWith("@") ? handle : `@${handle}`; +} + +function getDiagnosticEntryHandle(item: DiagnosticBlockItem | DiagnosticDidProfile) { + if (item.profile?.handle) { + return item.profile.handle; + } + + if ("did" in item) { + return item.did; + } + + return item.subjectDid ?? item.profile?.did ?? "Unknown"; +} + +function getLabelDefinition(value: string | null | undefined) { + switch ((value || "").toLowerCase()) { + case "!hide": { + return "Hidden content label."; + } + case "!hide-media": { + return "Media visibility label."; + } + case "!hide-replies": { + return "Replies visibility label."; + } + case "!no-unauthenticated": { + return "Requires a signed-in view."; + } + case "!warn": { + return "Advisory moderation label."; + } + default: { + return "Service-applied moderation metadata."; + } + } +} + +function getLabelEffect(label: DiagnosticLabel) { + if (label.neg) { + return "This label negates a previous moderation decision."; + } + + switch ((label.val || "").toLowerCase()) { + case "!hide": + case "!hide-media": + case "!hide-replies": { + return "It can change how the record or account is shown in supporting clients."; + } + case "!no-unauthenticated": { + return "It can limit visibility for signed-out browsing."; + } + default: { + return "Its exact effect depends on the labeling service and client policy."; + } + } +} + +function getSourceProfileName(sourceProfiles: Record, src: string | null | undefined) { + if (!src) { + return "Unknown service"; + } + + const profile = asRecord(sourceProfiles[src]); + if (!profile) { + return src; + } + + return getStringProperty(profile, "displayName") ?? formatHandle(getStringProperty(profile, "handle")) ?? src; +} + export function DiagnosticsPanel(props: DiagnosticsPanelProps) { const session = useAppSession(); const [state, setState] = createStore(createInitialState()); const [activeTab, setActiveTab] = createSignal("lists"); const [blocksExpanded, setBlocksExpanded] = createSignal(false); - const activeDid = createMemo(() => props.did.trim() || session.activeDid || ""); + const activeDid = createMemo(() => props.did?.trim() || session.activeDid || ""); + const activeRecordUri = createMemo(() => props.recordUri?.trim() || ""); const isSelf = createMemo(() => activeDid() === session.activeDid); let requestId = 0; createEffect(() => { const did = activeDid(); if (!did) { + setState(createIdleState()); return; } @@ -132,16 +242,21 @@ export function DiagnosticsPanel(props: DiagnosticsPanelProps) { }); function handleKeyDown(event: KeyboardEvent) { - if (event.key >= "1" && event.key <= "5" && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (shouldIgnoreKey(event) || event.altKey || event.shiftKey) { + return; + } + + if (event.key >= "1" && event.key <= "5") { event.preventDefault(); const nextTab = DIAGNOSTICS_TABS[Number(event.key) - 1]?.value; if (nextTab) { setActiveTab(nextTab); } + return; } if (event.key === "Escape") { - props.onClose(); + props.onClose?.(); } } @@ -167,32 +282,41 @@ export function DiagnosticsPanel(props: DiagnosticsPanelProps) { try { const response = await getAccountLabels(did); if (currentRequest !== requestId) return; - setState({ labels: response.labels, labelsError: null, labelsLoading: false }); + setState({ + labels: response.labels, + labelsError: null, + labelsLoading: false, + labelsSourceProfiles: response.sourceProfiles, + }); } catch (error) { const message = normalizeError(error); if (currentRequest !== requestId) return; - setState({ labelsError: message, labelsLoading: false }); + setState({ labelsError: message, labelsLoading: false, labelsSourceProfiles: {} }); logger.warn("failed to load diagnostics labels", { keyValues: { did, error: message } }); } } async function loadBlocks(currentRequest: number, did: string) { - try { - const [blockedBy, blocking] = await Promise.all([getAccountBlockedBy(did, 25), getAccountBlocking(did)]); - if (currentRequest !== requestId) return; - setState({ - blockedBy: blockedBy.items, - blockedByError: null, - blockedByLoading: false, - blocking: blocking.items, - blockingError: null, - blockingLoading: false, - }); - } catch (error) { - const message = normalizeError(error); - if (currentRequest !== requestId) return; - setState({ blockedByError: message, blockedByLoading: false, blockingError: message, blockingLoading: false }); - logger.warn("failed to load diagnostics blocks", { keyValues: { did, error: message } }); + const [blockedBy, blocking] = await Promise.allSettled([getAccountBlockedBy(did, 25), getAccountBlocking(did)]); + + if (currentRequest !== requestId) { + return; + } + + if (blockedBy.status === "fulfilled") { + setState({ blockedBy: blockedBy.value.items, blockedByError: null, blockedByLoading: false }); + } else { + const message = normalizeError(blockedBy.reason); + setState({ blockedByError: message, blockedByLoading: false }); + logger.warn("failed to load diagnostics blocked-by data", { keyValues: { did, error: message } }); + } + + if (blocking.status === "fulfilled") { + setState({ blocking: blocking.value.items, blockingError: null, blockingLoading: false }); + } else { + const message = normalizeError(blocking.reason); + setState({ blockingError: message, blockingLoading: false }); + logger.warn("failed to load diagnostics blocking data", { keyValues: { did, error: message } }); } } @@ -211,18 +335,30 @@ export function DiagnosticsPanel(props: DiagnosticsPanelProps) { return (
- + void loadBlocks(requestId, activeDid())} + onRetryBlocking={() => void loadBlocks(requestId, activeDid())} + onRetryLabels={() => void loadLabels(requestId, activeDid())} + onRetryLists={() => void loadLists(requestId, activeDid())} + onRetryStarterPacks={() => void loadStarterPacks(requestId, activeDid())} onToggleBlocks={() => setBlocksExpanded((value) => !value)} + recordUri={activeRecordUri()} state={state} />
); } -function DiagnosticsHeader(props: { did: string; isSelf: boolean; onClose: () => void }) { +function DiagnosticsHeader(props: { did: string; embedded: boolean; isSelf: boolean; onClose?: () => void }) { return (
@@ -230,16 +366,18 @@ function DiagnosticsHeader(props: { did: string; isSelf: boolean; onClose: () =>

Context

Social Diagnostics

- {props.isSelf ? "Your boundaries and footprint" : "Public social context for this account"} + {props.isSelf ? "Your boundaries and public footprint" : "Public social context for this account"}

- + + +

@@ -281,22 +419,39 @@ function DiagnosticsTabs(props: { activeTab: DiagnosticsTab; onSelectTab: (tab: } function DiagnosticsViewport( - props: { activeTab: DiagnosticsTab; blocksExpanded: boolean; onToggleBlocks: () => void; state: DiagnosticsState }, + props: { + activeTab: DiagnosticsTab; + blocksExpanded: boolean; + isSelf: boolean; + onOpenExplorerTarget?: (target: string) => void; + onRetryBlockedBy: () => void; + onRetryBlocking: () => void; + onRetryLabels: () => void; + onRetryLists: () => void; + onRetryStarterPacks: () => void; + onToggleBlocks: () => void; + recordUri: string; + state: DiagnosticsState; + }, ) { return (

+ lists={props.state.lists} + loading={props.state.listsLoading} + onOpenExplorerTarget={props.onOpenExplorerTarget} + onRetry={props.onRetryLists} /> + labels={props.state.labels} + loading={props.state.labelsLoading} + onRetry={props.onRetryLabels} + sourceProfiles={props.state.labelsSourceProfiles} /> - +
+ + +
); } -function DiagnosticsListsTab(props: { lists: DiagnosticList[]; error: string | null; loading: boolean }) { +function DiagnosticsListsTab( + props: { + error: string | null; + lists: DiagnosticList[]; + loading: boolean; + onOpenExplorerTarget?: (target: string) => void; + onRetry: () => void; + }, +) { return (
+ description="Lists are ordinary social structure. Purpose, owner context, and membership stay visible without scoring or judgment." /> @@ -341,7 +514,9 @@ function DiagnosticsListsTab(props: { lists: DiagnosticList[]; error: string | n

{group.label}

- {(list) => } + + {(list) => } +
)} @@ -351,13 +526,21 @@ function DiagnosticsListsTab(props: { lists: DiagnosticList[]; error: string | n - {error => } + {(error) => }
); } -function DiagnosticsLabelsTab(props: { labels: DiagnosticLabel[]; error: string | null; loading: boolean }) { +function DiagnosticsLabelsTab( + props: { + error: string | null; + labels: DiagnosticLabel[]; + loading: boolean; + onRetry: () => void; + sourceProfiles: Record; + }, +) { return (
+ } each={props.labels}> - {(label, index) => } + {(label, index) => ( + + )} }> - {error => } + {(error) => }
); @@ -397,6 +585,9 @@ function DiagnosticsBlocksTab( blockingError: string | null; blockingLoading: boolean; expanded: boolean; + isSelf: boolean; + onRetryBlockedBy: () => void; + onRetryBlocking: () => void; onToggleExpanded: () => void; }, ) { @@ -406,14 +597,14 @@ function DiagnosticsBlocksTab( return (
+ description="Blocking is a normal social boundary. Counts stay in the summary row; specific accounts appear only after a deliberate action." + title={props.isSelf ? "Your Boundaries" : "Blocks"} />
- - + +
- {"Blocks are a normal part of social media. This data is public on the AT Protocol."} + Blocks are a normal part of social media. This data is public on the AT Protocol.
+ + + ); } function DiagnosticsEmptyState(props: { copy: string }) { @@ -551,77 +743,103 @@ function StatCard(props: { label: string; value: number }) { ); } -function LabelChip(props: { label: DiagnosticLabel; index: number }) { - const copy = () => [props.label.val ?? "label", props.label.src ?? "unknown service"].join(" ยท "); +function LabelChip(props: { index: number; label: DiagnosticLabel; sourceName: string }) { + const title = createMemo(() => + [ + `Label: ${props.label.val ?? "Unknown"}`, + `Definition: ${getLabelDefinition(props.label.val)}`, + `Source: ${props.sourceName}`, + `Effect: ${getLabelEffect(props.label)}`, + ].join("\n") + ); return ( - + title={title()} + transition={{ delay: Math.min(props.index * 0.02, 0.12), duration: 0.14 }}> + {props.label.val ?? "label"} - {props.label.src ?? "unknown service"} + {props.sourceName} ); } -function ListCard(props: { list: DiagnosticList }) { - const title = () => props.list.title ?? props.list.name ?? "Untitled list"; +function ListCard(props: { list: DiagnosticList; onOpenExplorerTarget?: (target: string) => void }) { const count = () => props.list.memberCount ?? props.list.listItemCount ?? 0; + const title = () => props.list.title ?? props.list.name ?? "Untitled list"; return (
-
+
-

{title()}

-

- {props.list.creator?.handle ? `@${props.list.creator.handle}` : "Unknown owner"} -

+
+

{title()}

+ + {purposeLabel(props.list.purpose)} + +
+

Owner: {formatHandle(props.list.creator?.handle)}

{props.list.description ?? "No description provided."}

-
- - {purposeLabel(props.list.purpose)} - + +
{count()} members + + {uri => ( + + )} +
); } -function StarterPackCard(props: { pack: DiagnosticStarterPack }) { - const title = () => props.pack.title ?? props.pack.name ?? props.pack.record?.name ?? "Starter pack"; +function StarterPackCard(props: { onOpenExplorerTarget?: (target: string) => void; pack: DiagnosticStarterPack }) { const count = () => props.pack.listItemCount ?? props.pack.record?.listItemsSample?.length ?? 0; + const title = () => props.pack.title ?? props.pack.name ?? props.pack.record?.name ?? "Starter pack"; return (
-
+

{title()}

- {props.pack.creator?.handle ? `@${props.pack.creator.handle}` : "Unknown creator"} + Creator: {formatHandle(props.pack.creator?.handle ?? null)}

{props.pack.description ?? props.pack.record?.description ?? "No description provided."}

- {count()} members -
-
- ); -} -function BacklinkPreviewCard(props: { copy: string; title: string }) { - return ( -
-

{props.title}

-

{props.copy}

+
+ {count()} members + + {uri => ( + + )} + +
+
); } @@ -637,10 +855,14 @@ function BlockProfileList(

{props.title}

- {(item) => { + {(item, index) => { const name = () => item.displayName ?? item.handle; return ( -
+
{initials(name())}}> {(src) => } @@ -648,14 +870,14 @@ function BlockProfileList(

{name()}

-

{item.handle}

+

{formatHandle(item.handle)}

{(description) => (

{description()}

)}
-
+ ); }}
@@ -667,7 +889,15 @@ function BlockProfileList( function DiagnosticsListSkeleton() { return (
- {() =>
} + + {() => ( +
+
+
+
+
+ )} +
); } @@ -675,7 +905,7 @@ function DiagnosticsListSkeleton() { function DiagnosticsLabelSkeleton() { return (
- {() =>
} + {() =>
}
); } @@ -683,7 +913,15 @@ function DiagnosticsLabelSkeleton() { function DiagnosticsStarterPackSkeleton() { return (
- {() =>
} + + {() => ( +
+
+
+
+
+ )} +
); } @@ -691,7 +929,7 @@ function DiagnosticsStarterPackSkeleton() { function DiagnosticsBlockSkeleton() { return (
- {() =>
} + {() =>
}
); } diff --git a/src/components/diagnostics/RecordBacklinksPanel.tsx b/src/components/diagnostics/RecordBacklinksPanel.tsx new file mode 100644 index 0000000..88b567b --- /dev/null +++ b/src/components/diagnostics/RecordBacklinksPanel.tsx @@ -0,0 +1,263 @@ +import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; +import { normalizeError } from "$/lib/utils/text"; +import * as logger from "@tauri-apps/plugin-log"; +import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; +import { createStore } from "solid-js/store"; +import { Motion, Presence } from "solid-motionone"; +import { ArrowIcon, Icon } from "../shared/Icon"; + +type GroupKey = "likes" | "reposts" | "replies" | "quotes"; + +type RecordBacklinksPanelProps = { uri?: string | null }; + +type BacklinksState = { error: string | null; groups: Record; loading: boolean }; + +const EMPTY_GROUP: DiagnosticBacklinkGroup = { cursor: null, records: [], total: 0 }; + +const GROUP_ORDER: Array<{ copy: string; icon: "heart" | "repost" | "reply" | "quote"; key: GroupKey; label: string }> = + [ + { copy: "Records that liked this subject.", icon: "heart", key: "likes", label: "Likes" }, + { copy: "Records that reposted this subject.", icon: "repost", key: "reposts", label: "Reposts" }, + { copy: "Direct replies to this subject.", icon: "reply", key: "replies", label: "Replies" }, + { copy: "Records that embedded this URI.", icon: "quote", key: "quotes", label: "Quote posts" }, + ]; + +function createInitialState(): BacklinksState { + return { + error: null, + groups: { likes: EMPTY_GROUP, quotes: EMPTY_GROUP, replies: EMPTY_GROUP, reposts: EMPTY_GROUP }, + loading: true, + }; +} + +function createIdleState(): BacklinksState { + return { + error: null, + groups: { likes: EMPTY_GROUP, quotes: EMPTY_GROUP, replies: EMPTY_GROUP, reposts: EMPTY_GROUP }, + loading: false, + }; +} + +function initials(name: string) { + return name.trim().slice(0, 1).toUpperCase() || "?"; +} + +function formatHandle(handle: string | null | undefined, did: string | null | undefined) { + if (handle) { + return handle.startsWith("@") ? handle : `@${handle}`; + } + + return did ?? "Unknown"; +} + +export function RecordBacklinksPanel(props: RecordBacklinksPanelProps) { + const [state, setState] = createStore(createInitialState()); + const [expandedByKey, setExpandedByKey] = createStore>({ + likes: false, + quotes: false, + replies: false, + reposts: false, + }); + const activeUri = createMemo(() => props.uri?.trim() || ""); + let requestId = 0; + + createEffect(() => { + const uri = activeUri(); + if (!uri) { + setState(createIdleState()); + return; + } + + const currentRequest = ++requestId; + setState(createInitialState()); + setExpandedByKey({ likes: false, quotes: false, replies: false, reposts: false }); + + void loadBacklinks(currentRequest, uri); + }); + + async function loadBacklinks(currentRequest: number, uri: string) { + try { + const response = await getRecordBacklinks(uri); + if (currentRequest !== requestId) { + return; + } + + setState({ + error: null, + groups: { + likes: response.likes ?? EMPTY_GROUP, + quotes: response.quotes ?? EMPTY_GROUP, + replies: response.replies ?? EMPTY_GROUP, + reposts: response.reposts ?? EMPTY_GROUP, + }, + loading: false, + }); + } catch (error) { + const message = normalizeError(error); + if (currentRequest !== requestId) { + return; + } + + setState({ error: message, loading: false }); + logger.warn("failed to load record backlinks", { keyValues: { error: message, uri } }); + } + } + + function toggleGroup(key: GroupKey) { + setExpandedByKey(key, (value) => !value); + } + + return ( + + + {(group) => ( + toggleGroup(group.key)} + total={state.groups[group.key].total ?? state.groups[group.key].records.length} /> + )} + +
+ }> + +
+ Backlinks are record-specific context. Open a post or record to inspect the public references pointing at it. +
+
+ + + + + {(error) => ( +
+

{error()}

+ +
+ )} +
+ + ); +} + +function BacklinkGroupCard( + props: { + copy: string; + expanded: boolean; + icon: "heart" | "repost" | "reply" | "quote"; + items: DiagnosticBacklinkItem[]; + label: string; + onToggle: () => void; + total: number | null | undefined; + }, +) { + const total = () => props.total ?? props.items.length; + + return ( +
+ + + + + + + No visible records are available in this section right now. +
+ }> + {(item, index) => } + + + + +
+ ); +} + +function BacklinkRecordCard(props: { index: number; item: DiagnosticBacklinkItem }) { + const actorLabel = createMemo(() => + props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown" + ); + const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did)); + + return ( + +
+ {initials(actorLabel())}}> + {(src) => } + +
+ +
+
+

{actorLabel()}

+ + {props.item.collection ?? "record"} + +
+

{handleLabel()}

+

{props.item.uri}

+
+
+ ); +} + +function BacklinksSkeleton() { + return ( +
+ + {() => ( +
+
+
+
+ )} + +
+ ); +} diff --git a/src/components/explorer/ExplorerPanel.test.tsx b/src/components/explorer/ExplorerPanel.test.tsx index d2ba5b0..315921e 100644 --- a/src/components/explorer/ExplorerPanel.test.tsx +++ b/src/components/explorer/ExplorerPanel.test.tsx @@ -6,6 +6,8 @@ const describeRepoMock = vi.hoisted(() => vi.fn()); const describeServerMock = vi.hoisted(() => vi.fn()); const exportRepoCarMock = vi.hoisted(() => vi.fn()); const getRecordMock = vi.hoisted(() => vi.fn()); +const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); +const getProfileMock = vi.hoisted(() => vi.fn()); const listRecordsMock = vi.hoisted(() => vi.fn()); const queryLabelsMock = vi.hoisted(() => vi.fn()); const resolveInputMock = vi.hoisted(() => vi.fn()); @@ -24,6 +26,8 @@ vi.mock( }), ); +vi.mock("$/lib/api/profile", () => ({ getProfile: getProfileMock })); +vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); function renderPanel() { @@ -36,12 +40,26 @@ describe("ExplorerPanel", () => { describeServerMock.mockReset(); exportRepoCarMock.mockReset(); getRecordMock.mockReset(); + getRecordBacklinksMock.mockReset(); + getProfileMock.mockReset(); listRecordsMock.mockReset(); queryLabelsMock.mockReset(); resolveInputMock.mockReset(); listenMock.mockReset(); exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); + getProfileMock.mockResolvedValue({ + did: "did:plc:alice", + handle: "alice.test", + followersCount: 28, + followsCount: 14, + }); + getRecordBacklinksMock.mockResolvedValue({ + likes: { cursor: null, records: [], total: 3 }, + quotes: { cursor: null, records: [], total: 1 }, + replies: { cursor: null, records: [], total: 2 }, + reposts: { cursor: null, records: [], total: 4 }, + }); listenMock.mockResolvedValue(() => {}); queryLabelsMock.mockResolvedValue({ labels: [] }); }); @@ -70,6 +88,9 @@ describe("ExplorerPanel", () => { expect(resolveInputMock).toHaveBeenCalledWith("@alice.test"); expect(await screen.findByRole("button", { name: /app\.bsky\.feed\.like/u })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })).toBeInTheDocument(); + expect(await screen.findByText("Followers")).toBeInTheDocument(); + expect(screen.getByText("28")).toBeInTheDocument(); + expect(screen.getByText("14")).toBeInTheDocument(); expect(screen.queryByText("0 records")).not.toBeInTheDocument(); expect(screen.queryByText("Count unavailable")).not.toBeInTheDocument(); }); @@ -168,4 +189,39 @@ describe("ExplorerPanel", () => { expect(await screen.findByText("Hosted Repositories")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /did:plc:hosted/u })).toBeInTheDocument(); }); + + it("shows record backlinks as a supplementary explorer panel", async () => { + resolveInputMock.mockResolvedValue({ + input: "at://did:plc:alice/app.bsky.feed.post/123", + inputKind: "atUri", + targetKind: "record", + normalizedInput: "at://did:plc:alice/app.bsky.feed.post/123", + uri: "at://did:plc:alice/app.bsky.feed.post/123", + did: "did:plc:alice", + handle: "alice.test", + pdsUrl: "https://pds.example.com", + collection: "app.bsky.feed.post", + rkey: "123", + }); + getRecordMock.mockResolvedValue({ + cid: "cid-123", + value: { $type: "app.bsky.feed.post", text: "Explorer record" }, + }); + getRecordBacklinksMock.mockResolvedValue({ + likes: { cursor: null, records: [], total: 3 }, + quotes: { cursor: null, records: [], total: 1 }, + replies: { cursor: null, records: [], total: 2 }, + reposts: { cursor: null, records: [], total: 4 }, + }); + + renderPanel(); + + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); + fireEvent.input(input, { target: { value: "at://did:plc:alice/app.bsky.feed.post/123" } }); + fireEvent.submit(input.closest("form")!); + + expect(await screen.findByText("Backlinks")).toBeInTheDocument(); + expect(await screen.findByText("3 records")).toBeInTheDocument(); + expect(screen.getByText("4 records")).toBeInTheDocument(); + }); }); diff --git a/src/components/explorer/ExplorerPanel.tsx b/src/components/explorer/ExplorerPanel.tsx index 1391b62..da7894d 100644 --- a/src/components/explorer/ExplorerPanel.tsx +++ b/src/components/explorer/ExplorerPanel.tsx @@ -7,8 +7,10 @@ import { queryLabels, resolveInput, } from "$/lib/api/explorer"; +import { getProfile } from "$/lib/api/profile"; import type { ExplorerNavigation, ExplorerTargetKind } from "$/lib/api/types/explorer"; import { NAVIGATION_EVENT } from "$/lib/constants/events"; +import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; import { listen } from "@tauri-apps/api/event"; import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { produce } from "solid-js/store"; @@ -127,7 +129,10 @@ export function ExplorerPanel() { } case "repo": { if (resolved.did) { - const repoData = await describeRepo(resolved.did); + const [repoData, profile] = await Promise.all([ + describeRepo(resolved.did), + getProfile(resolved.did).catch(() => null), + ]); const collections = extractCollections(repoData); finalViewState = { ...viewState, @@ -137,6 +142,9 @@ export function ExplorerPanel() { did: resolved.did, handle: resolved.handle || resolved.did, pdsUrl: resolved.pdsUrl, + socialSummary: profile + ? { followerCount: profile.followersCount ?? null, followingCount: profile.followsCount ?? null } + : null, }, }; } @@ -346,6 +354,7 @@ export function ExplorerPanel() { onMount(() => { let unlisten: (() => void) | undefined; + const pendingTarget = consumeQueuedExplorerTarget(); void listen(NAVIGATION_EVENT, (event) => { const target = event.payload.target; @@ -356,6 +365,10 @@ export function ExplorerPanel() { document.addEventListener("keydown", handleKeyDown); + if (pendingTarget) { + void handleResolveInput(pendingTarget); + } + onCleanup(() => { unlisten?.(); document.removeEventListener("keydown", handleKeyDown); @@ -423,12 +436,13 @@ export function ExplorerPanel() { handleCollectionClick(view.repoData!.did, collection)} - onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} /> + pdsUrl={view.repoData!.pdsUrl} + onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} + socialSummary={view.repoData!.socialSummary} /> diff --git a/src/components/explorer/types.ts b/src/components/explorer/types.ts index 075e58c..4137cce 100644 --- a/src/components/explorer/types.ts +++ b/src/components/explorer/types.ts @@ -8,7 +8,13 @@ type PDSRepoData = { did: string; head: string; rev: string; active: boolean; st type RepoViewCollection = { nsid: string }; -type RepoViewData = { collections: Array; handle: string; did: string; pdsUrl: string | null }; +type RepoViewData = { + collections: Array; + did: string; + handle: string; + pdsUrl: string | null; + socialSummary?: { followerCount: number | null; followingCount: number | null } | null; +}; type CollectionViewData = { records: Array>; diff --git a/src/components/explorer/views/RecordView.test.tsx b/src/components/explorer/views/RecordView.test.tsx index 4d36fc5..13189d7 100644 --- a/src/components/explorer/views/RecordView.test.tsx +++ b/src/components/explorer/views/RecordView.test.tsx @@ -1,9 +1,20 @@ import { render, screen } from "@solidjs/testing-library"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { RecordView } from "./RecordView"; +const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); + +vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); + describe("RecordView", () => { it("renders falsey JSON values and moderation labels", () => { + getRecordBacklinksMock.mockResolvedValue({ + likes: { cursor: null, records: [], total: 0 }, + quotes: { cursor: null, records: [], total: 0 }, + replies: { cursor: null, records: [], total: 0 }, + reposts: { cursor: null, records: [], total: 0 }, + }); + render(() => ( { expect(screen.getByText("\"\"")).toBeInTheDocument(); expect(screen.getByText("false")).toBeInTheDocument(); expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.getByText("Backlinks")).toBeInTheDocument(); expect(screen.getByText("Moderation Labels")).toBeInTheDocument(); expect(screen.getByText("!warn")).toBeInTheDocument(); }); diff --git a/src/components/explorer/views/RecordView.tsx b/src/components/explorer/views/RecordView.tsx index 3f291af..7a3bbf4 100644 --- a/src/components/explorer/views/RecordView.tsx +++ b/src/components/explorer/views/RecordView.tsx @@ -1,3 +1,4 @@ +import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; import { type JsonValue, JsonValueAs } from "$/components/explorer/types"; import { ArrowIcon, Icon } from "$/components/shared/Icon"; import { getStringProperty, isRecordLike, isString } from "$/lib/type-guards"; @@ -204,6 +205,10 @@ export function RecordView(props: RecordViewProps) { + + + + 0}>
diff --git a/src/components/explorer/views/RepoView.tsx b/src/components/explorer/views/RepoView.tsx index 9e468e9..32b76d3 100644 --- a/src/components/explorer/views/RepoView.tsx +++ b/src/components/explorer/views/RepoView.tsx @@ -1,21 +1,26 @@ import { ArrowIcon, Icon } from "$/components/shared/Icon"; -import { For } from "solid-js"; +import { For, Show } from "solid-js"; type RepoViewProps = { + collections: Array<{ nsid: string }>; did: string; handle: string; - pdsUrl: string | null; - collections: Array<{ nsid: string }>; onCollectionClick: (collection: string) => void; onPdsClick: () => void; + pdsUrl: string | null; + socialSummary?: { followerCount: number | null; followingCount: number | null } | null; }; +function formatCount(value: number | null | undefined) { + return value === null || value === undefined ? "Unavailable" : value.toLocaleString(); +} + export function RepoView(props: RepoViewProps) { return (
-
-
+
+
@@ -24,22 +29,43 @@ export function RepoView(props: RepoViewProps) {
-
-
-

DID

+
+
+

DID

{props.did}

+ + + {(summary) => ( +
+
+

Followers

+

{formatCount(summary().followerCount)}

+

+ Public relationship context for this repository. +

+
+
+

Following

+

{formatCount(summary().followingCount)}

+

+ Summary only. Block counts stay inside diagnostics. +

+
+
+ )} +
-
-
+
+

Collections

@@ -48,7 +74,7 @@ export function RepoView(props: RepoViewProps) { {(collection) => (