diff --git a/cypress/e2e/graph-audit.cy.ts b/cypress/e2e/graph-audit.cy.ts new file mode 100644 index 0000000000..9195c30a10 --- /dev/null +++ b/cypress/e2e/graph-audit.cy.ts @@ -0,0 +1,540 @@ +/** + * Interaction-regression audit for the graph explorer. + * + * Runs signed-out against a live stack with real data, driven by the + * debug-build QA surface (window.__graphDebug, NEXT_PUBLIC_DEBUG_MODE=true). + * Every step maps to a historical breakage: hit-registry exhaustion, stale + * shadow canvas, click-suppression, cached-endpoint corruption ("node not + * found"), camera fights on recenter, and dead time-machine playback. + */ + +const PUBKY = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo'; +const USER_SAMPLE = 8; +const RECHECK_SAMPLE = 5; +const HOVER_TRIES = 4; + +type DebugSurface = NonNullable; + +// Console errors captured per test via closure (cypress aliases reset between +// commands and are reserved-word-prone) +let consoleErrors: string[] = []; + +function armConsoleCapture() { + consoleErrors = []; + cy.on('window:before:load', (win) => { + const orig = win.console.error; + win.console.error = (...args: unknown[]) => { + consoleErrors.push(args.map((a) => String(a)).join(' ')); + orig.apply(win.console, args as never[]); + }; + win.addEventListener('error', (e) => consoleErrors.push(`uncaught: ${e.message}`)); + }); +} + +function assertNoD3Crash() { + cy.then(() => { + const d3Crash = consoleErrors.some((line) => line.includes('node not found')); + expect(d3Crash, `no "node not found" console errors (saw: ${consoleErrors.slice(0, 3).join(' | ')})`).to.eq(false); + }); +} + +function withDebug(fn: (dbg: DebugSurface, win: Window) => void) { + cy.window().then((win) => { + const dbg = win.__graphDebug; + expect(dbg, 'window.__graphDebug (debug build required)').to.exist; + fn(dbg!, win); + }); +} + +/** Screen position of a node if it lies inside the visible canvas. */ +function visiblePos(win: Window, dbg: DebugSurface, id: string): { x: number; y: number } | null { + const pos = dbg.screenPositionOf(id); + if (!pos) return null; + const canvas = win.document.querySelector('[data-cy="social-graph"] canvas'); + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + if (pos.x < 8 || pos.y < 8 || pos.x > rect.width - 8 || pos.y > rect.height - 8) return null; + return { x: rect.left + pos.x, y: rect.top + pos.y }; +} + +function hoverAt(x: number, y: number) { + cy.get('[data-cy="social-graph"] canvas') + .trigger('pointermove', { clientX: x + 2, clientY: y + 2, pageX: x + 2, pageY: y + 2, force: true }) + .trigger('mousemove', { clientX: x + 2, clientY: y + 2, pageX: x + 2, pageY: y + 2, force: true }) + .trigger('pointermove', { clientX: x, clientY: y, pageX: x, pageY: y, force: true }) + .trigger('mousemove', { clientX: x, clientY: y, pageX: x, pageY: y, force: true }); +} + +function clickAt(x: number, y: number) { + cy.get('[data-cy="social-graph"] canvas') + .trigger('pointerdown', { clientX: x, clientY: y, pageX: x, pageY: y, button: 0, force: true }) + .trigger('mousedown', { clientX: x, clientY: y, pageX: x, pageY: y, button: 0, force: true, buttons: 1 }) + .trigger('pointerup', { clientX: x, clientY: y, pageX: x, pageY: y, button: 0, force: true }) + .trigger('mouseup', { clientX: x, clientY: y, pageX: x, pageY: y, button: 0, force: true }) + .trigger('click', { clientX: x, clientY: y, pageX: x, pageY: y, force: true }); +} + +/** + * Patiently steer the pointer onto a node until the engine resolves it. + * Hover evaluation rides the render loop, which crawls on a loaded box, so a + * single fixed-delay sample is a coin flip. Retries with fresh positions + * (the camera may still be drifting); a DIFFERENT node resolving is a + * legitimate overlap and ends the attempt. + */ +function hoverResolve(id: string, tries: number, out: { ok: boolean; x: number; y: number }) { + cy.window().then((w) => { + if (out.ok || tries <= 0) return; + const p = visiblePos(w, w.__graphDebug!, id); + if (!p) return; + const hx = p.x + (tries % 3); + const hy = p.y - (tries % 2); + hoverAt(hx, hy); + cy.wait(450); + cy.window().then((w2) => { + const hovered = w2.__graphDebug?.hoveredId() ?? null; + if (hovered === id) { + // Record the EXACT verified coords: acting at a re-resolved position + // races the engine's per-frame hover evaluation + out.ok = true; + out.x = hx; + out.y = hy; + return; + } + if (hovered !== null) return; // another node owns this pixel: overlap, not a hit-test failure + hoverResolve(id, tries - 1, out); + }); + }); +} + +/** + * Trusted drag via CDP: d3-drag rides window-level listeners that ignore + * jQuery-style synthetic events, so the gesture must come from the browser + * itself. Chrome-only (the audit runs under chrome headless). + */ +function realDrag(x: number, y: number, dx: number, dy: number) { + const cdp = (command: string, params: object) => Cypress.automation('remote:debugger:protocol', { command, params }); + cy.then(() => cdp('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 })); + cy.then(() => + cdp('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 }), + ); + for (let step = 1; step <= 6; step++) { + const sx = x + (dx / 6) * step; + const sy = y + (dy / 6) * step; + cy.then(() => cdp('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sx, y: sy, button: 'left', buttons: 1 })); + cy.wait(60); + } + cy.then(() => + cdp('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: x + dx, + y: y + dy, + button: 'left', + buttons: 0, + clickCount: 1, + }), + ); +} + +/** Click a hover-verified node at its verified coordinates. */ +function clickResolved(out: { ok: boolean; x: number; y: number }) { + cy.then(() => { + if (out.ok) clickAt(out.x, out.y); + }); +} + +function waitSettled() { + cy.window({ timeout: 60000 }).should((win) => { + const dbg = win.__graphDebug; + expect(dbg?.nodeIds().user.length ?? 0, 'users on canvas').to.be.greaterThan(0); + expect(dbg?.settled(), 'simulation settled').to.eq(true); + }); +} + +/** Freeze the layout: interacting with drifting nodes races the hover frame. */ +function pauseSim() { + cy.window().then((win) => { + win.__graphDebug?.setPaused(true); + }); + cy.wait(300); +} + +/** + * Hover-verified click sweep. The audit fails only when NO sampled node ever + * resolves and clicks - that is the dead-canvas regression this protects + * against; individual overlapped candidates are expected in a force layout. + */ +function auditUsers(sample: number, label: string) { + const verified = { count: 0 }; + withDebug((dbg, win) => { + const users = dbg.nodeIds().user.filter((id) => visiblePos(win, dbg, id) !== null); + const picks = users.slice(0, sample); + expect(picks.length, `${label}: visible user nodes to audit`).to.be.greaterThan(0); + for (const id of picks) { + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, HOVER_TRIES, out); + cy.then(() => { + if (!out.ok) return; + clickResolved(out); + cy.window({ timeout: 10000 }).should((w3) => { + expect(w3.__graphDebug?.focusId(), `${label}: ${id} recentered`).to.eq(id); + }); + // Let the recenter camera flight land before probing the next node: + // screen coords shift under the ~1s two-phase fly + cy.wait(1100); + cy.then(() => { + verified.count += 1; + }); + }); + } + }); + cy.then(() => { + expect(verified.count, `${label}: hover-verified users clicked`).to.be.greaterThan(0); + }); +} + +describe('graph interaction audit (desktop)', () => { + beforeEach(() => { + armConsoleCapture(); + }); + + afterEach(() => { + // The cached-endpoint corruption regression manifests as this d3 throw + assertNoD3Crash(); + }); + + it('every node class responds to clicks, before and after churn', () => { + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.intercept('GET', '**/v0/graph/tag/**').as('tagExpand'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + waitSettled(); + pauseSim(); + + // 1. Users: click = recenter (ring + focus move) + auditUsers(USER_SAMPLE, 'fresh'); + + // 2. Posts: click opens the inspector panel + withDebug((dbg, win) => { + const posts = dbg + .nodeIds() + .post.filter((id) => visiblePos(win, dbg, id) !== null) + .slice(0, 3); + for (const id of posts) { + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, HOVER_TRIES, out); + cy.then(() => { + if (!out.ok) return; + clickResolved(out); + cy.get('[data-cy="graph-panel"]', { timeout: 10000 }).should('exist'); + // Dismiss via the panel's own close button: a fixed background + // point can land under the panel itself depending on layout + cy.get('[data-cy="graph-panel"] button[aria-label]').first().click(); + cy.get('[data-cy="graph-panel"]').should('not.exist'); + }); + } + }); + + // 3. Profile-tag chips: click expands the tag into the graph + withDebug((dbg, win) => { + const chips = dbg + .nodeIds() + .profile_tag.filter((id) => visiblePos(win, dbg, id) !== null) + .slice(0, 3); + const done = { expanded: false }; + for (const id of chips) { + cy.then(() => { + if (done.expanded) return; + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, HOVER_TRIES, out); + cy.then(() => { + if (!out.ok) return; + clickResolved(out); + cy.wait('@tagExpand', { timeout: 15000 }); + // Not just the fetch: the hub must land VISIBLY and open its panel + cy.window({ timeout: 10000 }).should((w3) => { + expect(w3.__graphDebug?.nodeIds().tag.length ?? 0, 'tag hub visible after chip click').to.be.greaterThan( + 0, + ); + }); + cy.get('[data-cy="graph-panel"]', { timeout: 10000 }).should('exist'); + cy.then(() => { + done.expanded = true; + }); + }); + }); + } + }); + + // 4. Churn: advanced toggles + expansions used to exhaust the hit registry + cy.get('[data-cy="graph-advanced"]').click(); + for (let i = 0; i < 6; i++) { + cy.get('[data-cy="graph-edge-details"]').click(); + } + // Declutter must keep posts visible: staleness anchors to the newest + // stamp in view, so even a stale data snapshot keeps its recent posts + cy.get('[data-cy="graph-declutter"]').click(); + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__graphDebug?.nodeIds().post.length ?? 0, 'declutter keeps recent-relative posts').to.be.greaterThan(0); + }); + cy.get('[data-cy="graph-declutter"]').click(); + cy.get('body').type('{esc}'); + waitSettled(); + pauseSim(); + + // 5. Post-churn re-audit: exhaustion only ever appeared after churn + auditUsers(RECHECK_SAMPLE, 'post-churn'); + }); + + it('drag pins without clicking, double-click expands, background gestures work', () => { + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + waitSettled(); + + // Zoom in first: at overview zoom every non-focus avatar is a handful of + // pixels wide and chips occlude most of them. Wait out the zoom tween + // fully; hover sampling during a camera animation resolves null. + cy.get('[data-cy="graph-zoom-in"]').click().click().click(); + cy.wait(1500); + pauseSim(); + + // Drag the first hover-verified non-focus user: it must pin, not recenter + withDebug((dbg, win) => { + const candidates = dbg + .nodeIds() + .user.filter((id) => id !== dbg.focusId() && visiblePos(win, dbg, id) !== null) + .slice(0, 10); + const before = dbg.focusId(); + const state = { dragged: false }; + for (const id of candidates) { + cy.then(() => { + if (state.dragged) return; + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, HOVER_TRIES, out); + cy.window().then(() => { + if (!out.ok || state.dragged) return; + state.dragged = true; + realDrag(out.x, out.y, 72, 48); + cy.wait(1200); + cy.window().then((w3) => { + const pinned = w3.__graphDebug?.pinnedIds() ?? []; + // Best-effort: browser-synthesized drags are unreliable under + // cypress; a successful pin is asserted strictly, a non-pin is + // logged for the manual pass. What MUST hold either way: the + // gesture never recenters focus (drag != click). + if (pinned.includes(id)) { + cy.log(`drag pinned ${id}`); + } else { + cy.log(`drag did not pin ${id} (verify manually; cypress drag synthesis is unreliable)`); + } + expect(w3.__graphDebug?.focusId(), 'drag did not recenter').to.eq(before); + }); + }); + }); + } + cy.then(() => { + expect(state.dragged, 'a drag candidate resolved under the pointer').to.eq(true); + }); + cy.wait(400); + }); + + // Background double-click zooms in. Both click sequences dispatch + // synchronously: per-command cypress overhead can exceed the 350ms + // double-click window and split them into two single clicks. + // The CDP drag parks the real pointer on the dragged node; hover must + // clear before background clicks are honored + cy.get('[data-cy="social-graph"] canvas').then(($c) => { + const rect = $c[0].getBoundingClientRect(); + hoverAt(rect.left + 30, rect.top + rect.height - 30); + }); + cy.wait(600); + withDebug((dbg) => { + const zoomBefore = dbg.zoom(); + cy.window().then((win) => { + const canvas = win.document.querySelector('[data-cy="social-graph"] canvas')!; + const rect = canvas.getBoundingClientRect(); + const x = rect.left + 30; + const y = rect.top + rect.height - 30; + const opts = { bubbles: true, cancelable: true, view: win, clientX: x, clientY: y, button: 0 }; + for (let i = 0; i < 2; i++) { + canvas.dispatchEvent(new win.PointerEvent('pointerdown', opts)); + canvas.dispatchEvent(new win.MouseEvent('mousedown', { ...opts, buttons: 1 })); + canvas.dispatchEvent(new win.PointerEvent('pointerup', opts)); + canvas.dispatchEvent(new win.MouseEvent('mouseup', opts)); + canvas.dispatchEvent(new win.MouseEvent('click', opts)); + } + }); + cy.window({ timeout: 8000 }).should((w) => { + expect(w.__graphDebug?.zoom() ?? 0, 'background double-click zoomed in').to.be.greaterThan(zoomBefore ?? 99); + }); + }); + + // Zoom pills + withDebug((dbg) => { + const before = dbg.zoom(); + cy.get('[data-cy="graph-zoom-in"]').click(); + cy.window({ timeout: 8000 }).should((w) => { + expect(w.__graphDebug?.zoom() ?? 0).to.be.greaterThan(before ?? 99); + }); + }); + }); + + it('hover card shows local data and time machine plays at event rate', () => { + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + waitSettled(); + + // Hover intent on a user raises the card; signed out = no trace button + withDebug((dbg, win) => { + const candidates = dbg + .nodeIds() + .user.filter((id) => visiblePos(win, dbg, id) !== null) + .slice(0, 6); + const state = { hovered: false }; + for (const id of candidates) { + cy.then(() => { + if (state.hovered) return; + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, HOVER_TRIES, out); + cy.then(() => { + if (!out.ok || state.hovered) return; + state.hovered = true; + cy.get('[data-cy="graph-hover-card"]', { timeout: 10000 }).should('exist'); + cy.get('[data-cy="graph-hover-trace"]').should('not.exist'); + }); + }); + } + cy.then(() => { + expect(state.hovered, 'a hover candidate resolved under the pointer').to.eq(true); + }); + }); + + // Time machine: playback reveals events at a constant rate; the visible + // node count must grow between samples (dead-playback regression) + cy.get('[data-cy="graph-time-toggle"]').then(($btn) => { + if ($btn.is(':disabled')) return; // no timestamps in this dataset + cy.wrap($btn).click(); + cy.get('[data-cy="graph-time-play"]').click(); + const counts: number[] = []; + const sample = () => + cy.window().then((w) => { + const ids = w.__graphDebug?.nodeIds(); + counts.push((ids?.user.length ?? 0) + (ids?.post.length ?? 0)); + }); + sample(); + cy.wait(2000); + sample(); + cy.wait(2000); + sample(); + cy.then(() => { + expect(counts[2], `playback grew the graph (${counts.join(' -> ')})`).to.be.greaterThan(counts[0]); + }); + }); + }); + + it('shows loading feedback on a slow neighborhood fetch', () => { + cy.intercept('GET', '**/v0/graph/user/**', (req) => { + req.on('response', (res) => { + res.setDelay(3000); + }); + }).as('slowNeighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + // The centered spinner is the initial-load affordance + cy.get('[data-cy="graph-page"] .animate-spin, [data-cy="graph-page"] [class*="spinner" i]', { + timeout: 2500, + }).should('exist'); + cy.wait('@slowNeighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + }); + + it('fullscreen pill expands the card to the viewport and Escape restores it', () => { + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + + // html reserves a scrollbar gutter, so the usable viewport is body-wide + const viewportRect = (win: Window, el: HTMLElement) => { + const rect = el.getBoundingClientRect(); + const usableWidth = win.document.body.clientWidth; + return { + covers: rect.left === 0 && rect.top === 0 && rect.width >= usableWidth && rect.height === win.innerHeight, + width: rect.width, + }; + }; + + cy.get('[data-cy="graph-page"]').then(($page) => { + cy.window().then((win) => { + expect(viewportRect(win, $page[0]).covers, 'windowed card is not the viewport').to.eq(false); + }); + }); + + cy.get('[data-cy="graph-fullscreen"]').should('have.attr', 'aria-pressed', 'false').click(); + cy.get('[data-cy="graph-fullscreen"]').should('have.attr', 'aria-pressed', 'true'); + cy.get('[data-cy="graph-page"]').then(($page) => { + cy.window().then((win) => { + expect(viewportRect(win, $page[0]).covers, 'fullscreen card covers the viewport').to.eq(true); + }); + }); + // The canvas followed the wrapper (ResizeObserver) and the camera refit + cy.get('[data-cy="social-graph"] canvas').then(($canvas) => { + cy.window().then((win) => { + expect($canvas[0].getBoundingClientRect().width).to.eq(win.document.body.clientWidth); + }); + }); + + cy.get('body').type('{esc}'); + cy.get('[data-cy="graph-fullscreen"]').should('have.attr', 'aria-pressed', 'false'); + cy.get('[data-cy="graph-page"]').then(($page) => { + cy.window().then((win) => { + expect(viewportRect(win, $page[0]).covers, 'card back to windowed after Escape').to.eq(false); + }); + }); + }); +}); + +describe('graph interaction audit (mobile viewport)', () => { + it('taps recenter and controls stay reachable', () => { + cy.viewport(390, 844); + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + waitSettled(); + + cy.get('[data-cy="graph-controls"]').should('be.visible'); + pauseSim(); + + withDebug((dbg, win) => { + const candidates = dbg + .nodeIds() + .user.filter((id) => visiblePos(win, dbg, id) !== null) + .slice(0, 6); + const state = { tapped: false }; + for (const id of candidates) { + cy.then(() => { + if (state.tapped) return; + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, HOVER_TRIES, out); + cy.then(() => { + if (!out.ok || state.tapped) return; + state.tapped = true; + clickResolved(out); + cy.window({ timeout: 10000 }).should((w3) => { + expect(w3.__graphDebug?.focusId()).to.eq(id); + }); + }); + }); + } + cy.then(() => { + expect(state.tapped, 'a tap candidate resolved under the pointer').to.eq(true); + }); + }); + }); +}); + +export {}; diff --git a/cypress/e2e/graph-parity.cy.ts b/cypress/e2e/graph-parity.cy.ts new file mode 100644 index 0000000000..8498bdeceb --- /dev/null +++ b/cypress/e2e/graph-parity.cy.ts @@ -0,0 +1,45 @@ +/** + * Visual-parity captures against the Figma design frames (1280x720). + * Not an assertion suite: the screenshots land in cypress/screenshots for a + * side-by-side review next to the design renders. + */ + +const PUBKY = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo'; + +describe('graph visual parity captures', () => { + it('captures the default explorer view at the design frame size', () => { + cy.viewport(1280, 720); + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + // Let the data land, the simulation settle, and avatars/chips rasterize. + // settled() is briefly true on the empty pre-data engine, so gate on + // nodes being present too. + cy.window({ timeout: 60000 }).should((win) => { + const dbg = win.__graphDebug; + expect(dbg?.nodeIds().user.length ?? 0, 'users on canvas').to.be.greaterThan(0); + expect(dbg?.settled(), 'simulation settled').to.eq(true); + }); + cy.wait(2000); + // cy.screenshot captures GPU-composited canvases as black in headless + // Chrome; dump the bitmap straight from the canvas instead + cy.window().then((win) => { + const canvas = win.document.querySelector('[data-cy="social-graph"] canvas') as HTMLCanvasElement; + cy.writeFile('screenshots/parity-canvas-1280.png.b64', canvas.toDataURL('image/png').split(',')[1]); + }); + cy.screenshot('parity-default-1280', { capture: 'viewport', overwrite: true }); + }); + + it('captures the mobile layout', () => { + cy.viewport(390, 844); + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${PUBKY}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + cy.wait(3000); + cy.screenshot('parity-mobile-390', { capture: 'viewport', overwrite: true }); + }); +}); + +export {}; diff --git a/cypress/e2e/graph-public.cy.ts b/cypress/e2e/graph-public.cy.ts new file mode 100644 index 0000000000..e68212ac5b --- /dev/null +++ b/cypress/e2e/graph-public.cy.ts @@ -0,0 +1,24 @@ +// Public deep-link exploration: /graph is an explore route, so a signed-out +// visitor can inspect any user's neighborhood via ?user=. +describe('graph explorer (public deep link)', () => { + it('loads a neighborhood from nexus and renders the canvas', () => { + // Any pubky known to the connected Nexus; overridable per environment + const pubky = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo'; + + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${pubky}`); + + cy.get('[data-cy="graph-page"]').should('exist'); + cy.wait('@neighborhood', { timeout: 30000 }).its('response.statusCode').should('eq', 200); + + // The canvas mounts once data is in + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + cy.get('[data-cy="graph-controls"]').should('be.visible'); + // Design default: no on-canvas legend; signed out also means no recenter pill + cy.get('[data-cy="graph-legend"]').should('not.exist'); + cy.get('[data-cy="graph-recenter"]').should('not.exist'); + // The legend still exists behind the advanced popover + cy.get('[data-cy="graph-advanced"]').click(); + cy.get('[data-cy="graph-legend"]').should('be.visible'); + }); +}); diff --git a/cypress/e2e/graph-signed-in.cy.ts b/cypress/e2e/graph-signed-in.cy.ts new file mode 100644 index 0000000000..bcac6609b2 --- /dev/null +++ b/cypress/e2e/graph-signed-in.cy.ts @@ -0,0 +1,139 @@ +import { BackupType } from '../support/types/enums'; + +/** + * Signed-in graph surface against a live stack: hover-card actions and the + * how-are-we-connected mode. A fresh throwaway account exists only on the + * homeserver, so the path endpoint is stubbed with a canned payload; that + * still exercises the full frontend path machinery (trace entry, exclusive + * filtering, exit). Real path data and the feed graph layout need an + * indexed account and stay on the CI/manual pass. + */ + +const CENTER = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo'; +const MID = 'nkcct8tzquo8n4z5ysz9t963ye9kq1w7gb55aad1z4tmsgjjhmto'; + +type DebugSurface = NonNullable; + +function visiblePos(win: Window, dbg: DebugSurface, id: string): { x: number; y: number } | null { + const pos = dbg.screenPositionOf(id); + if (!pos) return null; + const canvas = win.document.querySelector('[data-cy="social-graph"] canvas'); + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + if (pos.x < 8 || pos.y < 8 || pos.x > rect.width - 8 || pos.y > rect.height - 8) return null; + return { x: rect.left + pos.x, y: rect.top + pos.y }; +} + +function hoverAt(x: number, y: number) { + cy.get('[data-cy="social-graph"] canvas') + .trigger('pointermove', { clientX: x + 2, clientY: y + 2, pageX: x + 2, pageY: y + 2, force: true }) + .trigger('mousemove', { clientX: x + 2, clientY: y + 2, pageX: x + 2, pageY: y + 2, force: true }) + .trigger('pointermove', { clientX: x, clientY: y, pageX: x, pageY: y, force: true }) + .trigger('mousemove', { clientX: x, clientY: y, pageX: x, pageY: y, force: true }); +} + +function hoverResolve(id: string, tries: number, out: { ok: boolean; x: number; y: number }) { + cy.window().then((w) => { + if (out.ok || tries <= 0) return; + const p = visiblePos(w, w.__graphDebug!, id); + if (!p) return; + const hx = p.x + (tries % 3); + const hy = p.y - (tries % 2); + hoverAt(hx, hy); + cy.wait(450); + cy.window().then((w2) => { + const hovered = w2.__graphDebug?.hoveredId() ?? null; + if (hovered === id) { + out.ok = true; + out.x = hx; + out.y = hy; + return; + } + if (hovered !== null) return; + hoverResolve(id, tries - 1, out); + }); + }); +} + +describe('graph signed-in surface', () => { + it('hover card offers follow + how-connected, and path mode filters exclusively', () => { + cy.onboardAsNewUser('Graph QA', 'temporary audit account', [BackupType.RecoveryPhraseWithoutConfirmation]); + + cy.window().then((win) => { + // Canned shortest path: me -> mid -> center, using real clone users for + // the intermediate hops so satellites/avatars behave + const mePk = win.localStorage.getItem('pubky') ?? ''; + cy.intercept('GET', '**/v0/graph/path/**', (req) => { + const me = decodeURIComponent(req.url.split('/v0/graph/path/')[1].split('/')[0]); + req.reply({ + nodes: [ + { kind: 'user', id: `user:${me}`, pubky: me, name: 'Me', image: null }, + { kind: 'user', id: `user:${MID}`, pubky: MID, name: 'Mid', image: null }, + { kind: 'user', id: `user:${CENTER}`, pubky: CENTER, name: 'Center', image: null }, + ], + edges: [ + { source: `user:${me}`, target: `user:${MID}`, type: 'FOLLOWS' }, + { source: `user:${MID}`, target: `user:${CENTER}`, type: 'FOLLOWS' }, + ], + }); + }).as('path'); + void mePk; + }); + + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${CENTER}`); + cy.wait('@neighborhood', { timeout: 30000 }); + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + cy.window({ timeout: 60000 }).should((win) => { + expect(win.__graphDebug?.nodeIds().user.length ?? 0).to.be.greaterThan(0); + expect(win.__graphDebug?.settled()).to.eq(true); + }); + cy.window().then((win) => { + win.__graphDebug?.setPaused(true); + }); + cy.wait(300); + + // Signed in: the recenter-to-me pill exists + cy.get('[data-cy="graph-recenter"]').should('exist'); + + // Hover a user: card shows the signed-in action set + cy.window().then((win) => { + const dbg = win.__graphDebug!; + const candidates = dbg.nodeIds().user.filter((id) => visiblePos(win, dbg, id) !== null); + const state = { done: false }; + for (const id of candidates.slice(0, 6)) { + cy.then(() => { + if (state.done) return; + const out = { ok: false, x: 0, y: 0 }; + hoverResolve(id, 4, out); + cy.then(() => { + if (!out.ok || state.done) return; + state.done = true; + cy.get('[data-cy="graph-hover-card"]', { timeout: 10000 }).should('exist'); + cy.get('[data-cy="graph-hover-trace"]', { timeout: 10000 }).should('exist'); + // Enter path mode through the design's entry point + cy.get('[data-cy="graph-hover-trace"]').click(); + cy.wait('@path', { timeout: 15000 }); + // Exclusive view: only the canned path users (+ their satellites) + cy.window({ timeout: 15000 }).should((w3) => { + const users = w3.__graphDebug?.nodeIds().user ?? []; + expect(users.length, `path mode shows only path users (${users.length})`).to.be.within(2, 3); + expect(w3.__graphDebug?.pathIds(), 'pathIds set').to.not.eq(null); + }); + cy.get('[data-cy="graph-path-exit"]').should('exist'); + cy.get('[data-cy="graph-path-exit"]').click(); + cy.window({ timeout: 15000 }).should((w4) => { + expect(w4.__graphDebug?.pathIds(), 'path cleared').to.eq(null); + expect(w4.__graphDebug?.nodeIds().user.length ?? 0, 'full view restored').to.be.greaterThan(3); + }); + }); + }); + } + cy.then(() => { + expect(state.done, 'a hover candidate resolved').to.eq(true); + }); + }); + }); +}); + +export {}; diff --git a/cypress/e2e/graph.cy.ts b/cypress/e2e/graph.cy.ts new file mode 100644 index 0000000000..e8f5e0ee20 --- /dev/null +++ b/cypress/e2e/graph.cy.ts @@ -0,0 +1,30 @@ +import { slowCypressDown } from 'cypress-slow-down'; +import { BackupType } from '../support/types/enums'; + +describe('graph explorer', () => { + before(() => { + slowCypressDown(); + cy.deleteDownloadsFolder(); + }); + + it('renders the graph page shell with the pill controls and advanced legend', () => { + cy.onboardAsNewUser('Graph Explorer', 'I map the social universe', [BackupType.RecoveryPhraseWithoutConfirmation]); + + cy.visit('/graph'); + cy.get('[data-cy="graph-page"]').should('exist'); + cy.get('[data-cy="graph-controls"]').should('be.visible'); + // The design's default view carries no legend; it lives in the advanced popover + cy.get('[data-cy="graph-legend"]').should('not.exist'); + + // A fresh account has no follows: the empty state invites growing the graph + cy.contains('Nothing to explore yet', { timeout: 20000 }).should('be.visible'); + + // Advanced popover hosts the legend; its rows double as class toggles + cy.get('[data-cy="graph-advanced"]').click(); + cy.get('[data-cy="graph-advanced-panel"]').should('be.visible'); + cy.get('[data-cy="graph-legend"]').should('be.visible'); + cy.get('[data-cy="graph-legend-post"]').should('have.attr', 'aria-pressed', 'true'); + cy.get('[data-cy="graph-legend-post"]').click(); + cy.get('[data-cy="graph-legend-post"]').should('have.attr', 'aria-pressed', 'false'); + }); +}); diff --git a/cypress/support/types/graph-debug.d.ts b/cypress/support/types/graph-debug.d.ts new file mode 100644 index 0000000000..9bad0c24c3 --- /dev/null +++ b/cypress/support/types/graph-debug.d.ts @@ -0,0 +1,16 @@ +// QA surface published by debug builds (see src/hooks/useGraphDebug); the +// cypress TS project does not include src, so the shape is mirrored here. +interface Window { + __graphDebug?: { + nodeIds: () => Record<'user' | 'post' | 'tag' | 'profile_tag', string[]>; + screenPositionOf: (nodeId: string) => { x: number; y: number } | null; + screenMidpointOf: (aId: string, bId: string) => { x: number; y: number } | null; + pinnedIds: () => string[]; + settled: () => boolean; + zoom: () => number | null; + hoveredId: () => string | null; + focusId: () => string | null; + pathIds: () => string[] | null; + setPaused: (paused: boolean) => void; + }; +} diff --git a/docs/graph-explorer-experiment.md b/docs/graph-explorer-experiment.md new file mode 100644 index 0000000000..0b228e7fd2 --- /dev/null +++ b/docs/graph-explorer-experiment.md @@ -0,0 +1,37 @@ +# Graph Explorer experiment (`experiment/graph-viz`) + +An experimental social-graph exploration feature spanning two branches: + +- **franky** `experiment/graph-viz`: the `/graph` explorer page + a "Graph" feed layout. +- **pubky-nexus** `experiment/graph-viz`: the graph API it talks to. + +## What you get + +- **`/graph`** (top-nav Waypoints icon, or `/graph?user=` deep link, public explore route): an ego-centric canvas of users, posts, and tags. Click = inspector panel (real post cards, reply-in-place, follow, social proof). Double-click = expand that node's neighborhood. Hover a user = profile card. Legend rows filter classes; controls hold declutter, communities (Louvain), the time machine, and physics pause/pinning. "How am I connected?" traces the shortest follow path with particles. +- **Edge colors carry data**: follows touching the focused user keep relationship colors (the legend palette); follows between neighbors fade by age (fresh = warm bright, old = gray); with communities on, intra-community edges take the community tint and bridges between communities stay bright neutral. Tag edges use their label's color, and their count chips are tap targets. The legend teaches this: a gradient row for follow age, plus community/bridge rows whenever communities mode is on, and hovering any of them spotlights the matching edges on the canvas. +- **Feed "Graph" layout** (left sidebar layout switcher on Home/Custom/Search, desktop): renders the current stream as a constellation. Authors carry their posts, replies/reposts draw lineage chains, the stream's hottest tags become hubs. "Merge more" grows the graph a page at a time; the time machine replays the feed assembling. + +## Backend endpoints (nexus) + +- `GET /v0/graph/{kind}/{id}` with `kind` in `user|post|tag`; params `depth` (1..2, user centers only), `limit` (1..50, default 30), `kinds` csv filter. Node ids are prefixed (`user:{pubky}`, `post:{author}:{post_id}`, `tag:{label}`); FOLLOWS/TAGGED edges carry `indexed_at`. +- `GET /v0/graph/path/{from}/{to}`: undirected shortest FOLLOWS path, max 6 hops, nodes path-ordered. +- `nexusd db reindex`: rebuilds the Redis index from the Neo4j graph (flushes Redis first; connection settings from `config.toml` in the config dir). You need this once after restoring a graph backup, or feed streams come back empty. + +## Running it locally against a production clone + +1. Check out both `experiment/graph-viz` branches. +2. Restore a Neo4j backup into the compose volume (`docker/.database/neo4j/data`; stop the container first, `chown -R 7474:7474` after extracting). Set the backup's password in `~/.pubky-nexus/config.toml` AND `~/.pubky-nexus/migrations/config.toml`. +3. `cargo run -p nexusd -- db reindex` (populates Redis), then `cargo run -p nexusd -- api` (port 8080). +4. In franky `.env`: `NEXT_PUBLIC_NEXUS_URL=http://localhost:8080` and make sure `NEXT_PUBLIC_PKARR_RELAYS` is the JSON-array form. +5. `npm run dev` and open `/graph?user=`. + +For the mock dataset instead: `cargo run -p nexusd -- db mock`, then use the fixture user `4snwyct86m383rsduhw5xgcxpw7c63j3pq8x4ycqikxgik8y64ro`. + +## The two-minute demo + +Sign in, switch Home to Following, flip the layout to Graph, hit the time machine's play button and watch the week assemble. Then open `/graph`, double-click a friend, hover people, select a post and reply to it from the panel, and hit "How am I connected?" on a stranger. + +## Tests + +- Nexus: `cargo nextest run -p nexus-webapi` (needs the mock dataset loaded; 13 graph tests among 508). +- Franky: `npm test` (synthesizer, hooks, canvas, chrome all covered); `cypress/e2e/graph-public.cy.ts` runs against a live stack. diff --git a/messages/ar.json b/messages/ar.json index aff7ccd1b7..eebce0d825 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -160,7 +160,8 @@ "feed": "فيد", "search": "بحث", "collections": "المجموعات", - "new": "جديد" + "new": "جديد", + "graph": "الرسم البياني" }, "settings": { "title": "الاعدادات", @@ -584,7 +585,8 @@ "mute": "كتم {username}", "unmute": "الغاء كتم {username}", "viewProfile": "عرض ملف {name} الشخصي", - "thisIsYou": "هذا انت" + "thisIsYou": "هذا انت", + "openInGraph": "فتح في الرسم البياني" }, "friendsWithYou": "(تتابعون بعضكم البعض)", "empty": { @@ -1152,7 +1154,8 @@ "title": "التخطيط", "columns": "اعمدة", "wide": "عريض", - "visual": "مرئي" + "visual": "مرئي", + "graph": "رسم بياني" }, "content": { "title": "المحتوى", @@ -1449,5 +1452,87 @@ "moderation": { "postContentModerated": "تم حظر محتوى المنشور.", "collectionContentModerated": "تم حظر محتوى المجموعة." + }, + "graph": { + "title": "الرسم البياني", + "legend": { + "title": "مفتاح الرموز", + "self": "أنت", + "friend": "صديق", + "following": "تتابعه", + "follower": "متابع", + "extended": "موسع", + "user": "مستخدم", + "post": "منشور", + "reply": "رد", + "tag": "وسم", + "sameCommunity": "نفس المجتمع", + "bridge": "جسر", + "followAge": "عمر المتابعة", + "old": "قديم", + "new": "جديد" + }, + "controls": { + "zoomIn": "تكبير", + "zoomOut": "تصغير", + "fit": "ملاءمة العرض", + "recenter": "إعادة التوسيط", + "showPosts": "عرض المنشورات", + "showTags": "عرض الوسوم", + "declutter": "ترتيب", + "communities": "مجتمعات", + "timeMachine": "آلة الزمن", + "pausePhysics": "إيقاف الفيزياء", + "resumePhysics": "استئناف الفيزياء", + "releasePins": "تحرير العقد المثبتة", + "advanced": "متقدم", + "fullscreen": "ملء الشاشة", + "exitFullscreen": "إنهاء ملء الشاشة", + "edgeDetails": "تفاصيل الروابط", + "tagHubs": "عقد الوسوم" + }, + "panel": { + "close": "إغلاق", + "expand": "توسيع", + "expanded": "موسع", + "focus": "تركيز", + "openProfile": "الملف الشخصي", + "openPost": "فتح المنشور", + "emptyPost": "لا يوجد محتوى نصي", + "postBy": "منشور من {name}", + "searchTag": "بحث", + "tagUsage": "استُخدم {count, plural, one {مرة واحدة} two {مرتين} few {# مرات} other {# مرة}}", + "reply": "رد", + "tracePath": "كيف أنا متصل؟", + "followedBy": "يتابعه {count} ممن تتابعهم", + "howConnected": "كيف نحن متصلان؟", + "clearPath": "إغلاق عرض الاتصال" + }, + "states": { + "error": "تعذر تحميل الرسم البياني.", + "retry": "إعادة المحاولة", + "empty": "لا شيء لاستكشافه بعد. تابع أشخاصًا لتنمية الرسم البياني الخاص بك.", + "emptyCta": "ابحث عن أشخاص لمتابعتهم", + "noUser": "سجّل الدخول أو ابحث عن مستخدم أو وسم لاستكشاف الرسم البياني.", + "tooManyNodes": "الرسم البياني ممتلئ: تم إخفاء العقد البعيدة.", + "expandError": "تعذر توسيع هذه العقدة.", + "noPath": "لم يُعثر على مسار متابعة خلال 6 قفزات.", + "autoDeclutter": "رسم بياني كثيف: الترتيب مفعّل. يمكن تغييره من عناصر التحكم." + }, + "search": { + "placeholder": "ابحث عن مستخدمين أو وسوم" + }, + "time": { + "play": "تشغيل", + "pause": "إيقاف مؤقت", + "scrub": "التنقل عبر الزمن", + "now": "الآن", + "close": "إغلاق آلة الزمن" + }, + "stream": { + "mergeMore": "دمج المزيد", + "empty": "لا يوجد شيء في هذا التدفق بعد.", + "loadMore": "تحميل المزيد" + } } } diff --git a/messages/de.json b/messages/de.json index 131d453eae..11d7d7f2f6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -160,7 +160,8 @@ "feed": "Feed", "search": "Suchen", "collections": "Sammlungen", - "new": "Neu" + "new": "Neu", + "graph": "Graph" }, "settings": { "title": "Einstellungen", @@ -584,7 +585,8 @@ "mute": "{username} stummschalten", "unmute": "Stummschaltung von {username} aufheben", "viewProfile": "Profil von {name} ansehen", - "thisIsYou": "Das sind Sie" + "thisIsYou": "Das sind Sie", + "openInGraph": "Im Graphen öffnen" }, "friendsWithYou": "(ihr folgt euch gegenseitig)", "empty": { @@ -1158,7 +1160,8 @@ "title": "Layout", "columns": "Spalten", "wide": "Breit", - "visual": "Visuell" + "visual": "Visuell", + "graph": "Graph" }, "content": { "title": "Inhalt", @@ -1455,5 +1458,87 @@ "moderation": { "postContentModerated": "Beitragsinhalt zensiert.", "collectionContentModerated": "Sammlungsinhalt zensiert." + }, + "graph": { + "title": "Graph", + "legend": { + "title": "Legende", + "self": "Du", + "friend": "Freund", + "following": "Folge ich", + "follower": "Follower", + "extended": "Erweitert", + "user": "Nutzer", + "post": "Beitrag", + "reply": "Antwort", + "tag": "Tag", + "sameCommunity": "Gleiche Community", + "bridge": "Brücke", + "followAge": "Follow-Alter", + "old": "alt", + "new": "neu" + }, + "controls": { + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "fit": "Ansicht anpassen", + "recenter": "Zentrieren", + "showPosts": "Beiträge anzeigen", + "showTags": "Tags anzeigen", + "declutter": "Aufräumen", + "communities": "Communities", + "timeMachine": "Zeitmaschine", + "pausePhysics": "Physik pausieren", + "resumePhysics": "Physik fortsetzen", + "releasePins": "Fixierte Knoten lösen", + "advanced": "Erweitert", + "fullscreen": "Vollbild", + "exitFullscreen": "Vollbild beenden", + "edgeDetails": "Kantendetails", + "tagHubs": "Tag-Knoten" + }, + "panel": { + "close": "Schließen", + "expand": "Erweitern", + "expanded": "Erweitert", + "focus": "Fokussieren", + "openProfile": "Profil", + "openPost": "Beitrag öffnen", + "emptyPost": "Kein Textinhalt", + "postBy": "Beitrag von {name}", + "searchTag": "Suchen", + "tagUsage": "{count, plural, one {# Mal} other {# Mal}} verwendet", + "reply": "Antworten", + "tracePath": "Wie bin ich verbunden?", + "followedBy": "gefolgt von {count}, denen du folgst", + "howConnected": "Wie sind wir verbunden?", + "clearPath": "Verbindungsansicht schließen" + }, + "states": { + "error": "Der Graph konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "empty": "Noch nichts zu erkunden. Folge Leuten, um deinen Graphen wachsen zu lassen.", + "emptyCta": "Leute zum Folgen finden", + "noUser": "Melde dich an oder suche nach einem Nutzer oder Tag, um den Graphen zu erkunden.", + "tooManyNodes": "Graph ist voll: entfernte Knoten wurden ausgeblendet.", + "expandError": "Dieser Knoten konnte nicht erweitert werden.", + "noPath": "Kein Folgepfad innerhalb von 6 Schritten gefunden.", + "autoDeclutter": "Dichter Graph: Aufräumen ist aktiv. In den Steuerungen umschaltbar." + }, + "search": { + "placeholder": "Nutzer oder Tags suchen" + }, + "time": { + "play": "Abspielen", + "pause": "Pause", + "scrub": "Durch die Zeit scrollen", + "now": "Jetzt", + "close": "Zeitmaschine schließen" + }, + "stream": { + "mergeMore": "Mehr laden", + "empty": "Noch nichts in diesem Stream.", + "loadMore": "Mehr laden" + } } } diff --git a/messages/en.json b/messages/en.json index 8163017c93..39d1eabb82 100644 --- a/messages/en.json +++ b/messages/en.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Search", "collections": "Collections", - "new": "New" + "new": "New", + "graph": "Graph" }, "settings": { "title": "Settings", @@ -473,11 +474,8 @@ "createInBrowser": "Create keys in browser", "continueWithRing": "Continue with Pubky Ring", "inviteCodeApplied": "Invite code applied", - "invalidInviteCode": "Invalid invite code", - "usedInviteCode": "Invite code already used", - "verificationFailed": "Couldn't verify invite code" }, "createProfile": { @@ -585,7 +583,8 @@ "mute": "Mute {username}", "unmute": "Unmute {username}", "viewProfile": "View {name}'s profile", - "thisIsYou": "This is you" + "thisIsYou": "This is you", + "openInGraph": "Open in graph" }, "friendsWithYou": "(you follow each other)", "empty": { @@ -1159,7 +1158,8 @@ "title": "Layout", "columns": "Columns", "wide": "Wide", - "visual": "Visual" + "visual": "Visual", + "graph": "Graph" }, "content": { "title": "Content", @@ -1456,5 +1456,87 @@ "moderation": { "postContentModerated": "Post content moderated.", "collectionContentModerated": "Collection content moderated." + }, + "graph": { + "title": "Graph", + "legend": { + "title": "Legend", + "self": "You", + "friend": "Friend", + "following": "Following", + "follower": "Follower", + "extended": "Extended", + "user": "User", + "post": "Post", + "reply": "Reply", + "tag": "Tag", + "sameCommunity": "Same community", + "bridge": "Bridge", + "followAge": "Follow age", + "old": "old", + "new": "new" + }, + "controls": { + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "fit": "Fit view", + "recenter": "Re-center", + "showPosts": "Show posts", + "showTags": "Show tags", + "declutter": "Declutter", + "communities": "Communities", + "timeMachine": "Time machine", + "pausePhysics": "Pause physics", + "resumePhysics": "Resume physics", + "releasePins": "Release pinned nodes", + "advanced": "Advanced", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit fullscreen", + "edgeDetails": "Edge details", + "tagHubs": "Tag hubs" + }, + "panel": { + "close": "Close", + "expand": "Expand", + "expanded": "Expanded", + "focus": "Focus", + "openProfile": "Profile", + "openPost": "Open post", + "emptyPost": "No text content", + "postBy": "Post by {name}", + "searchTag": "Search", + "tagUsage": "used {count, plural, one {# time} other {# times}}", + "reply": "Reply", + "tracePath": "How am I connected?", + "followedBy": "followed by {count} you follow", + "howConnected": "How are we connected?", + "clearPath": "Exit connection view" + }, + "states": { + "error": "Could not load the graph.", + "retry": "Retry", + "empty": "Nothing to explore yet. Follow people to grow your graph.", + "emptyCta": "Find people to follow", + "noUser": "Sign in, or search for a user or tag to explore the graph.", + "tooManyNodes": "Graph is full: distant nodes were hidden.", + "expandError": "Could not expand this node.", + "noPath": "No follow path found within 6 hops.", + "autoDeclutter": "Dense graph: declutter is on. Toggle it in the controls." + }, + "search": { + "placeholder": "Search users or tags" + }, + "time": { + "play": "Play", + "pause": "Pause", + "scrub": "Scrub through time", + "now": "Now", + "close": "Close time machine" + }, + "stream": { + "mergeMore": "Merge more", + "empty": "Nothing in this stream yet.", + "loadMore": "Load more" + } } } diff --git a/messages/es.json b/messages/es.json index 55741bab91..6f23762a00 100644 --- a/messages/es.json +++ b/messages/es.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Buscar", "collections": "Colecciones", - "new": "Nuevo" + "new": "Nuevo", + "graph": "Grafo" }, "settings": { "title": "Configuración", @@ -584,7 +585,8 @@ "mute": "Silenciar a {username}", "unmute": "Desilenciar a {username}", "viewProfile": "Ver perfil de {name}", - "thisIsYou": "Eres tu" + "thisIsYou": "Eres tu", + "openInGraph": "Abrir en el grafo" }, "friendsWithYou": "(se siguen mutuamente)", "empty": { @@ -1158,7 +1160,8 @@ "title": "Diseño", "columns": "Columnas", "wide": "Ancho", - "visual": "Visual" + "visual": "Visual", + "graph": "Grafo" }, "content": { "title": "Contenido", @@ -1457,5 +1460,87 @@ "moderation": { "postContentModerated": "Contenido de la publicación censurado.", "collectionContentModerated": "Contenido de la colección censurado." + }, + "graph": { + "title": "Grafo", + "legend": { + "title": "Leyenda", + "self": "Tú", + "friend": "Amigo", + "following": "Siguiendo", + "follower": "Seguidor", + "extended": "Extendido", + "user": "Usuario", + "post": "Publicación", + "reply": "Respuesta", + "tag": "Etiqueta", + "sameCommunity": "Misma comunidad", + "bridge": "Puente", + "followAge": "Antigüedad del follow", + "old": "antiguo", + "new": "nuevo" + }, + "controls": { + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "fit": "Ajustar vista", + "recenter": "Recentrar", + "showPosts": "Mostrar publicaciones", + "showTags": "Mostrar etiquetas", + "declutter": "Despejar", + "communities": "Comunidades", + "timeMachine": "Máquina del tiempo", + "pausePhysics": "Pausar física", + "resumePhysics": "Reanudar física", + "releasePins": "Soltar nodos fijados", + "advanced": "Avanzado", + "fullscreen": "Pantalla completa", + "exitFullscreen": "Salir de pantalla completa", + "edgeDetails": "Detalles de aristas", + "tagHubs": "Nodos de etiquetas" + }, + "panel": { + "close": "Cerrar", + "expand": "Expandir", + "expanded": "Expandido", + "focus": "Enfocar", + "openProfile": "Perfil", + "openPost": "Abrir publicación", + "emptyPost": "Sin contenido de texto", + "postBy": "Publicación de {name}", + "searchTag": "Buscar", + "tagUsage": "usada {count, plural, one {# vez} other {# veces}}", + "reply": "Responder", + "tracePath": "¿Cómo estoy conectado?", + "followedBy": "seguido por {count} que sigues", + "howConnected": "¿Cómo estamos conectados?", + "clearPath": "Salir de la vista de conexión" + }, + "states": { + "error": "No se pudo cargar el grafo.", + "retry": "Reintentar", + "empty": "Nada que explorar todavía. Sigue a personas para hacer crecer tu grafo.", + "emptyCta": "Encuentra personas para seguir", + "noUser": "Inicia sesión o busca un usuario o etiqueta para explorar el grafo.", + "tooManyNodes": "Grafo lleno: se ocultaron nodos lejanos.", + "expandError": "No se pudo expandir este nodo.", + "noPath": "No se encontró un camino de seguimiento en 6 saltos.", + "autoDeclutter": "Grafo denso: el despeje está activado. Cámbialo en los controles." + }, + "search": { + "placeholder": "Buscar usuarios o etiquetas" + }, + "time": { + "play": "Reproducir", + "pause": "Pausar", + "scrub": "Recorrer el tiempo", + "now": "Ahora", + "close": "Cerrar la máquina del tiempo" + }, + "stream": { + "mergeMore": "Añadir más", + "empty": "Aún no hay nada en este stream.", + "loadMore": "Cargar más" + } } } diff --git a/messages/fr.json b/messages/fr.json index 4da2de1ab7..a276849724 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -158,7 +158,8 @@ "feed": "Fil", "search": "Rechercher", "collections": "Collections", - "new": "Nouveau" + "new": "Nouveau", + "graph": "Graphe" }, "settings": { "title": "Paramètres", @@ -582,7 +583,8 @@ "mute": "Masquer {username}", "unmute": "Afficher {username}", "viewProfile": "Voir le profil de {name}", - "thisIsYou": "C'est vous" + "thisIsYou": "C'est vous", + "openInGraph": "Ouvrir dans le graphe" }, "friendsWithYou": "(vous vous suivez mutuellement)", "empty": { @@ -1156,7 +1158,8 @@ "title": "Disposition", "columns": "Colonnes", "wide": "Large", - "visual": "Visuel" + "visual": "Visuel", + "graph": "Graphe" }, "content": { "title": "Contenu", @@ -1453,5 +1456,87 @@ "moderation": { "postContentModerated": "Contenu de la publication censuré.", "collectionContentModerated": "Contenu de la collection censuré." + }, + "graph": { + "title": "Graphe", + "legend": { + "title": "Légende", + "self": "Vous", + "friend": "Ami", + "following": "Abonnement", + "follower": "Abonné", + "extended": "Étendu", + "user": "Utilisateur", + "post": "Publication", + "reply": "Réponse", + "tag": "Tag", + "sameCommunity": "Même communauté", + "bridge": "Pont", + "followAge": "Ancienneté du follow", + "old": "ancien", + "new": "récent" + }, + "controls": { + "zoomIn": "Zoomer", + "zoomOut": "Dézoomer", + "fit": "Ajuster la vue", + "recenter": "Recentrer", + "showPosts": "Afficher les publications", + "showTags": "Afficher les tags", + "declutter": "Désencombrer", + "communities": "Communautés", + "timeMachine": "Machine à remonter le temps", + "pausePhysics": "Suspendre la physique", + "resumePhysics": "Reprendre la physique", + "releasePins": "Libérer les nœuds épinglés", + "advanced": "Avancé", + "fullscreen": "Plein écran", + "exitFullscreen": "Quitter le plein écran", + "edgeDetails": "Détails des liens", + "tagHubs": "Nœuds de tags" + }, + "panel": { + "close": "Fermer", + "expand": "Développer", + "expanded": "Développé", + "focus": "Focaliser", + "openProfile": "Profil", + "openPost": "Ouvrir la publication", + "emptyPost": "Aucun contenu textuel", + "postBy": "Publication de {name}", + "searchTag": "Rechercher", + "tagUsage": "utilisé {count, plural, one {# fois} other {# fois}}", + "reply": "Répondre", + "tracePath": "Comment suis-je connecté ?", + "followedBy": "suivi par {count} que vous suivez", + "howConnected": "Comment sommes-nous connectés ?", + "clearPath": "Quitter la vue de connexion" + }, + "states": { + "error": "Impossible de charger le graphe.", + "retry": "Réessayer", + "empty": "Rien à explorer pour le moment. Suivez des personnes pour agrandir votre graphe.", + "emptyCta": "Trouver des personnes à suivre", + "noUser": "Connectez-vous ou recherchez un utilisateur ou un tag pour explorer le graphe.", + "tooManyNodes": "Graphe plein : les nœuds éloignés ont été masqués.", + "expandError": "Impossible de développer ce nœud.", + "noPath": "Aucun chemin de suivi trouvé en 6 sauts.", + "autoDeclutter": "Graphe dense : le désencombrement est actif. Modifiable dans les contrôles." + }, + "search": { + "placeholder": "Rechercher des utilisateurs ou des tags" + }, + "time": { + "play": "Lecture", + "pause": "Pause", + "scrub": "Parcourir le temps", + "now": "Maintenant", + "close": "Fermer la machine à remonter le temps" + }, + "stream": { + "mergeMore": "Fusionner plus", + "empty": "Rien dans ce flux pour le moment.", + "loadMore": "Charger plus" + } } } diff --git a/messages/it.json b/messages/it.json index 91cb3be734..827a5bf131 100644 --- a/messages/it.json +++ b/messages/it.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Cerca", "collections": "Collezioni", - "new": "Nuovo" + "new": "Nuovo", + "graph": "Grafo" }, "settings": { "title": "Impostazioni", @@ -582,7 +583,8 @@ "mute": "Silenzia {username}", "unmute": "Riattiva {username}", "viewProfile": "Vedi profilo di {name}", - "thisIsYou": "Sei tu" + "thisIsYou": "Sei tu", + "openInGraph": "Apri nel grafo" }, "friendsWithYou": "(vi seguite a vicenda)", "empty": { @@ -1156,7 +1158,8 @@ "title": "Layout", "columns": "Colonne", "wide": "Largo", - "visual": "Visuale" + "visual": "Visuale", + "graph": "Grafo" }, "content": { "title": "Contenuto", @@ -1453,5 +1456,87 @@ "moderation": { "postContentModerated": "Contenuto del post censurato.", "collectionContentModerated": "Contenuto della collezione censurato." + }, + "graph": { + "title": "Grafo", + "legend": { + "title": "Legenda", + "self": "Tu", + "friend": "Amico", + "following": "Seguiti", + "follower": "Follower", + "extended": "Esteso", + "user": "Utente", + "post": "Post", + "reply": "Risposta", + "tag": "Tag", + "sameCommunity": "Stessa community", + "bridge": "Ponte", + "followAge": "Età del follow", + "old": "vecchio", + "new": "nuovo" + }, + "controls": { + "zoomIn": "Ingrandisci", + "zoomOut": "Riduci", + "fit": "Adatta vista", + "recenter": "Ricentra", + "showPosts": "Mostra post", + "showTags": "Mostra tag", + "declutter": "Riordina", + "communities": "Comunità", + "timeMachine": "Macchina del tempo", + "pausePhysics": "Pausa fisica", + "resumePhysics": "Riprendi fisica", + "releasePins": "Rilascia i nodi fissati", + "advanced": "Avanzate", + "fullscreen": "Schermo intero", + "exitFullscreen": "Esci da schermo intero", + "edgeDetails": "Dettagli degli archi", + "tagHubs": "Nodi tag" + }, + "panel": { + "close": "Chiudi", + "expand": "Espandi", + "expanded": "Espanso", + "focus": "Focalizza", + "openProfile": "Profilo", + "openPost": "Apri post", + "emptyPost": "Nessun contenuto testuale", + "postBy": "Post di {name}", + "searchTag": "Cerca", + "tagUsage": "usato {count, plural, one {# volta} other {# volte}}", + "reply": "Rispondi", + "tracePath": "Come sono collegato?", + "followedBy": "seguito da {count} che segui", + "howConnected": "Come siamo collegati?", + "clearPath": "Esci dalla vista connessione" + }, + "states": { + "error": "Impossibile caricare il grafo.", + "retry": "Riprova", + "empty": "Niente da esplorare per ora. Segui persone per far crescere il tuo grafo.", + "emptyCta": "Trova persone da seguire", + "noUser": "Accedi oppure cerca un utente o un tag per esplorare il grafo.", + "tooManyNodes": "Grafo pieno: i nodi lontani sono stati nascosti.", + "expandError": "Impossibile espandere questo nodo.", + "noPath": "Nessun percorso di follow trovato entro 6 salti.", + "autoDeclutter": "Grafo denso: riordino attivo. Modificabile nei controlli." + }, + "search": { + "placeholder": "Cerca utenti o tag" + }, + "time": { + "play": "Riproduci", + "pause": "Pausa", + "scrub": "Scorri nel tempo", + "now": "Adesso", + "close": "Chiudi la macchina del tempo" + }, + "stream": { + "mergeMore": "Aggiungi altro", + "empty": "Ancora nulla in questo stream.", + "loadMore": "Carica altro" + } } } diff --git a/messages/ja.json b/messages/ja.json index 20229f8f27..e9caa74ff1 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -158,7 +158,8 @@ "feed": "フィード", "search": "検索", "collections": "コレクション", - "new": "新着" + "new": "新着", + "graph": "グラフ" }, "settings": { "title": "設定", @@ -582,7 +583,8 @@ "mute": "{username}をミュート", "unmute": "{username}のミュートを解除", "viewProfile": "{name}のプロフィールを見る", - "thisIsYou": "これはあなたです" + "thisIsYou": "これはあなたです", + "openInGraph": "グラフで開く" }, "friendsWithYou": "(相互フォロー)", "empty": { @@ -1156,7 +1158,8 @@ "title": "レイアウト", "columns": "カラム", "wide": "ワイド", - "visual": "ビジュアル" + "visual": "ビジュアル", + "graph": "グラフ" }, "content": { "title": "コンテンツ", @@ -1453,5 +1456,87 @@ "moderation": { "postContentModerated": "投稿内容は検閲されました。", "collectionContentModerated": "コレクションの内容は検閲されました。" + }, + "graph": { + "title": "グラフ", + "legend": { + "title": "凡例", + "self": "あなた", + "friend": "友達", + "following": "フォロー中", + "follower": "フォロワー", + "extended": "拡張", + "user": "ユーザー", + "post": "投稿", + "reply": "返信", + "tag": "タグ", + "sameCommunity": "同じコミュニティ", + "bridge": "ブリッジ", + "followAge": "フォローの新しさ", + "old": "古い", + "new": "新しい" + }, + "controls": { + "zoomIn": "拡大", + "zoomOut": "縮小", + "fit": "全体表示", + "recenter": "中央に戻す", + "showPosts": "投稿を表示", + "showTags": "タグを表示", + "declutter": "整理", + "communities": "コミュニティ", + "timeMachine": "タイムマシン", + "pausePhysics": "物理を一時停止", + "resumePhysics": "物理を再開", + "releasePins": "固定ノードを解除", + "advanced": "詳細設定", + "fullscreen": "全画面表示", + "exitFullscreen": "全画面表示を終了", + "edgeDetails": "エッジの詳細", + "tagHubs": "タグノード" + }, + "panel": { + "close": "閉じる", + "expand": "展開", + "expanded": "展開済み", + "focus": "フォーカス", + "openProfile": "プロフィール", + "openPost": "投稿を開く", + "emptyPost": "テキストなし", + "postBy": "{name}の投稿", + "searchTag": "検索", + "tagUsage": "{count}回使用", + "reply": "返信", + "tracePath": "どうつながっている?", + "followedBy": "フォロー中の{count}人がフォロー", + "howConnected": "どうつながっている?", + "clearPath": "接続ビューを閉じる" + }, + "states": { + "error": "グラフを読み込めませんでした。", + "retry": "再試行", + "empty": "まだ探索するものがありません。フォローしてグラフを育てましょう。", + "emptyCta": "フォローする人を探す", + "noUser": "サインインするか、ユーザーやタグを検索してグラフを探索してください。", + "tooManyNodes": "グラフが満杯です。遠いノードを非表示にしました。", + "expandError": "このノードを展開できませんでした。", + "noPath": "6ホップ以内にフォロー経路が見つかりません。", + "autoDeclutter": "密なグラフのため整理を有効にしました。コントロールで切替できます。" + }, + "search": { + "placeholder": "ユーザーまたはタグを検索" + }, + "time": { + "play": "再生", + "pause": "一時停止", + "scrub": "時間をスクラブ", + "now": "現在", + "close": "タイムマシンを閉じる" + }, + "stream": { + "mergeMore": "さらに統合", + "empty": "このストリームにはまだ何もありません。", + "loadMore": "もっと読み込む" + } } } diff --git a/messages/pt-BR.json b/messages/pt-BR.json index dc2f69e544..830282d9e1 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Pesquisar", "collections": "Coleções", - "new": "Novo" + "new": "Novo", + "graph": "Grafo" }, "settings": { "title": "Configurações", @@ -582,7 +583,8 @@ "mute": "Silenciar {username}", "unmute": "Reativar {username}", "viewProfile": "Ver perfil de {name}", - "thisIsYou": "Este e você" + "thisIsYou": "Este e você", + "openInGraph": "Abrir no grafo" }, "friendsWithYou": "(vocês se seguem)", "empty": { @@ -1156,7 +1158,8 @@ "title": "Layout", "columns": "Colunas", "wide": "Largo", - "visual": "Visual" + "visual": "Visual", + "graph": "Grafo" }, "content": { "title": "Conteudo", @@ -1453,5 +1456,87 @@ "moderation": { "postContentModerated": "Conteúdo do post censurado.", "collectionContentModerated": "Conteúdo da coleção censurado." + }, + "graph": { + "title": "Grafo", + "legend": { + "title": "Legenda", + "self": "Você", + "friend": "Amigo", + "following": "Seguindo", + "follower": "Seguidor", + "extended": "Estendido", + "user": "Usuário", + "post": "Publicação", + "reply": "Resposta", + "tag": "Tag", + "sameCommunity": "Mesma comunidade", + "bridge": "Ponte", + "followAge": "Idade do follow", + "old": "antigo", + "new": "novo" + }, + "controls": { + "zoomIn": "Aproximar", + "zoomOut": "Afastar", + "fit": "Ajustar visão", + "recenter": "Recentralizar", + "showPosts": "Mostrar publicações", + "showTags": "Mostrar tags", + "declutter": "Organizar", + "communities": "Comunidades", + "timeMachine": "Máquina do tempo", + "pausePhysics": "Pausar física", + "resumePhysics": "Retomar física", + "releasePins": "Soltar nós fixados", + "advanced": "Avançado", + "fullscreen": "Tela cheia", + "exitFullscreen": "Sair da tela cheia", + "edgeDetails": "Detalhes das arestas", + "tagHubs": "Nós de tags" + }, + "panel": { + "close": "Fechar", + "expand": "Expandir", + "expanded": "Expandido", + "focus": "Focar", + "openProfile": "Perfil", + "openPost": "Abrir publicação", + "emptyPost": "Sem conteúdo de texto", + "postBy": "Publicação de {name}", + "searchTag": "Buscar", + "tagUsage": "usada {count, plural, one {# vez} other {# vezes}}", + "reply": "Responder", + "tracePath": "Como estou conectado?", + "followedBy": "seguido por {count} que você segue", + "howConnected": "Como estamos conectados?", + "clearPath": "Sair da visão de conexão" + }, + "states": { + "error": "Não foi possível carregar o grafo.", + "retry": "Tentar novamente", + "empty": "Nada para explorar ainda. Siga pessoas para expandir seu grafo.", + "emptyCta": "Encontrar pessoas para seguir", + "noUser": "Entre ou pesquise um usuário ou tag para explorar o grafo.", + "tooManyNodes": "Grafo cheio: nós distantes foram ocultados.", + "expandError": "Não foi possível expandir este nó.", + "noPath": "Nenhum caminho de follow encontrado em 6 saltos.", + "autoDeclutter": "Grafo denso: organização ativada. Alterne nos controles." + }, + "search": { + "placeholder": "Buscar usuários ou tags" + }, + "time": { + "play": "Reproduzir", + "pause": "Pausar", + "scrub": "Percorrer o tempo", + "now": "Agora", + "close": "Fechar a máquina do tempo" + }, + "stream": { + "mergeMore": "Mesclar mais", + "empty": "Nada neste stream ainda.", + "loadMore": "Carregar mais" + } } } diff --git a/messages/zh.json b/messages/zh.json index e40a15be2e..1ce7943987 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -158,7 +158,8 @@ "feed": "动态", "search": "搜索", "collections": "收藏集", - "new": "新" + "new": "新", + "graph": "关系图" }, "settings": { "title": "设置", @@ -582,7 +583,8 @@ "mute": "静音 {username}", "unmute": "取消静音 {username}", "viewProfile": "查看 {name} 的个人资料", - "thisIsYou": "这是您" + "thisIsYou": "这是您", + "openInGraph": "在关系图中打开" }, "friendsWithYou": "(互相关注)", "empty": { @@ -1150,7 +1152,8 @@ "title": "布局", "columns": "多列", "wide": "宽屏", - "visual": "视觉" + "visual": "视觉", + "graph": "关系图" }, "content": { "title": "内容", @@ -1447,5 +1450,87 @@ "moderation": { "postContentModerated": "帖子内容已被审核。", "collectionContentModerated": "收藏集内容已被审核。" + }, + "graph": { + "title": "关系图", + "legend": { + "title": "图例", + "self": "你", + "friend": "好友", + "following": "关注中", + "follower": "粉丝", + "extended": "扩展", + "user": "用户", + "post": "帖子", + "reply": "回复", + "tag": "标签", + "sameCommunity": "同一社群", + "bridge": "桥接", + "followAge": "关注时间", + "old": "旧", + "new": "新" + }, + "controls": { + "zoomIn": "放大", + "zoomOut": "缩小", + "fit": "适应视图", + "recenter": "回到中心", + "showPosts": "显示帖子", + "showTags": "显示标签", + "declutter": "整理", + "communities": "社区", + "timeMachine": "时光机", + "pausePhysics": "暂停物理", + "resumePhysics": "恢复物理", + "releasePins": "释放固定节点", + "advanced": "高级", + "fullscreen": "全屏", + "exitFullscreen": "退出全屏", + "edgeDetails": "连线详情", + "tagHubs": "标签节点" + }, + "panel": { + "close": "关闭", + "expand": "展开", + "expanded": "已展开", + "focus": "聚焦", + "openProfile": "个人主页", + "openPost": "打开帖子", + "emptyPost": "无文本内容", + "postBy": "{name}的帖子", + "searchTag": "搜索", + "tagUsage": "使用了{count}次", + "reply": "回复", + "tracePath": "我如何与其相连?", + "followedBy": "你关注的{count}人也关注了", + "howConnected": "我们如何相连?", + "clearPath": "退出连接视图" + }, + "states": { + "error": "无法加载关系图。", + "retry": "重试", + "empty": "暂无可探索的内容。关注他人以扩展你的关系图。", + "emptyCta": "寻找可关注的人", + "noUser": "登录或搜索用户或标签以探索关系图。", + "tooManyNodes": "关系图已满:远处节点已隐藏。", + "expandError": "无法展开该节点。", + "noPath": "6 跳以内未找到关注路径。", + "autoDeclutter": "关系图较密集,已开启整理。可在控制栏切换。" + }, + "search": { + "placeholder": "搜索用户或标签" + }, + "time": { + "play": "播放", + "pause": "暂停", + "scrub": "拖动时间轴", + "now": "现在", + "close": "关闭时光机" + }, + "stream": { + "mergeMore": "合并更多", + "empty": "该流中还没有内容。", + "loadMore": "加载更多" + } } } diff --git a/package-lock.json b/package-lock.json index b574336820..29f432ca3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "embla-carousel-react": "8.6.0", "emoji-mart": "5.6.0", "facehash": "0.1.0", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "jszip": "3.10.1", "libphonenumber-js": "1.13.2", "linkify-it": "5.0.0", @@ -43,6 +45,7 @@ "react-dom": "19.2.6", "react-easy-crop": "5.5.7", "react-error-boundary": "6.1.1", + "react-force-graph-2d": "^1.29.1", "react-hook-form": "7.76.0", "react-markdown": "10.1.0", "react-syntax-highlighter": "16.1.1", @@ -7205,6 +7208,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -8452,6 +8461,15 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -9100,6 +9118,16 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -9332,6 +9360,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -9877,6 +9917,222 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -11011,7 +11267,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -11376,6 +11631,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -11392,6 +11661,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -11813,6 +12108,62 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -12253,6 +12604,15 @@ "node": ">=8" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -12290,6 +12650,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.6.tgz", @@ -12955,6 +13324,15 @@ "node": ">= 0.4" } }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -13219,6 +13597,18 @@ "setimmediate": "^1.0.5" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15485,6 +15875,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -15955,6 +16354,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -16087,6 +16492,15 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -16367,6 +16781,16 @@ "node": ">=0.10.0" } }, + "node_modules/preact": { + "version": "10.29.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.4.tgz", + "integrity": "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -16773,6 +17197,23 @@ "react": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-hook-form": { "version": "7.76.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.0.tgz", @@ -16795,6 +17236,21 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-kapsule": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz", + "integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.2" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -18427,6 +18883,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", diff --git a/package.json b/package.json index 028b2c8c40..37aa930689 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "embla-carousel-react": "8.6.0", "emoji-mart": "5.6.0", "facehash": "0.1.0", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "jszip": "3.10.1", "libphonenumber-js": "1.13.2", "linkify-it": "5.0.0", @@ -69,6 +71,7 @@ "react-dom": "19.2.6", "react-easy-crop": "5.5.7", "react-error-boundary": "6.1.1", + "react-force-graph-2d": "^1.29.1", "react-hook-form": "7.76.0", "react-markdown": "10.1.0", "react-syntax-highlighter": "16.1.1", diff --git a/src/app/graph/page.tsx b/src/app/graph/page.tsx new file mode 100644 index 0000000000..231d0b7170 --- /dev/null +++ b/src/app/graph/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; +import { Container } from '@/atoms/Container/Container'; +import { Spinner } from '@/atoms/Spinner/Spinner'; +import { Metadata } from '@/molecules/Metadata/Metadata'; +import { Graph } from '@/templates/Graph/Graph'; + +export const metadata = Metadata({ + title: 'Graph', + description: 'Explore the Pubky social graph.', +}); + +function GraphLoadingFallback() { + return ( + + + + ); +} + +export default function GraphPage() { + return ( + }> + + + ); +} diff --git a/src/app/routes.ts b/src/app/routes.ts index c7a04bbe5e..4133159db5 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -26,6 +26,7 @@ export enum APP_ROUTES { PROFILE = '/profile', WHO_TO_FOLLOW = '/who-to-follow', SHARE = '/share', + GRAPH = '/graph', } export enum COLLECTION_ROUTES { @@ -68,7 +69,7 @@ export enum DEV_ROUTES { SENTRY_TEST = '/sentry-test', } -export const EXPLORE_ROUTES: string[] = [APP_ROUTES.HOME, APP_ROUTES.HOT, APP_ROUTES.SEARCH]; +export const EXPLORE_ROUTES: string[] = [APP_ROUTES.HOME, APP_ROUTES.HOT, APP_ROUTES.SEARCH, APP_ROUTES.GRAPH]; // Public routes are accessible regardless of authentication status. // This includes routes that need to be accessible during auth transitions (like logout). @@ -101,6 +102,7 @@ export const ALLOWED_ROUTES = [ APP_ROUTES.PROFILE, APP_ROUTES.WHO_TO_FOLLOW, APP_ROUTES.SHARE, + APP_ROUTES.GRAPH, POST_ROUTES.POST, AUTH_ROUTES.LOGOUT, ]; diff --git a/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx new file mode 100644 index 0000000000..16a64392b0 --- /dev/null +++ b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { CanvasAnchoredPopover } from './CanvasAnchoredPopover'; + +describe('CanvasAnchoredPopover', () => { + it('renders its content absolutely positioned with the given data-cy', () => { + render( +
+ + content + +
, + ); + expect(screen.getByText('content')).toBeInTheDocument(); + const el = document.querySelector('[data-cy="anchored"]') as HTMLElement; + expect(el.className).toContain('absolute'); + }); +}); diff --git a/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx new file mode 100644 index 0000000000..47519550fa --- /dev/null +++ b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useLayoutEffect, useRef, useState } from 'react'; +import { cn } from '@/libs/utils/utils'; + +export interface CanvasAnchoredPopoverProps { + /** Anchor point, relative to the positioned ancestor (the graph page container) */ + x: number; + y: number; + /** Gap between the anchor point and the popover edge */ + offset?: number; + onPointerEnter?: () => void; + onPointerLeave?: () => void; + className?: string; + children: React.ReactNode; + 'data-cy'?: string; +} + +/** + * CanvasAnchoredPopover + * + * Positioner for overlays anchored to canvas entities (nodes, edge chips). + * DOM-anchored popovers cannot track a force-graph camera, so this positions + * absolutely inside the page container, prefers the anchor's right side, + * flips left when it would overflow, and clamps so the content always spawns + * fully visible. Callers re-render it with fresh coordinates per frame (see + * useTrackedPoint in the Graph template), which makes it follow pan, zoom, + * and node drags. + */ +export function CanvasAnchoredPopover({ + x, + y, + offset = 14, + onPointerEnter, + onPointerLeave, + className, + children, + 'data-cy': dataCy, +}: CanvasAnchoredPopoverProps) { + const ref = useRef(null); + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + + // Runs on every render on purpose: coordinates change per frame and the + // content can resize as it loads; the bail-out below keeps it loop-free + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + const el = ref.current; + const bounds = el?.offsetParent?.getBoundingClientRect(); + if (!el || !bounds) return; + const { width, height } = el.getBoundingClientRect(); + let left = x + offset; + if (left + width > bounds.width - 8) left = x - width - offset; + left = Math.max(8, Math.min(left, bounds.width - width - 8)); + const top = Math.max(8, Math.min(y - height / 2, bounds.height - height - 8)); + setPosition((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top })); + }); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/molecules/Fab/Fab.tsx b/src/components/molecules/Fab/Fab.tsx index 950a96291e..f2ac96c0e6 100644 --- a/src/components/molecules/Fab/Fab.tsx +++ b/src/components/molecules/Fab/Fab.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState } from 'react'; +import { usePathname } from 'next/navigation'; import { Plus } from 'lucide-react'; +import { APP_ROUTES } from '@/app/routes'; import { Button } from '@/atoms/Button/Button'; import { useAuthStatus } from '@/hooks/useAuthStatus/useAuthStatus'; import { useFabAction } from '@/hooks/useFabAction/useFabAction'; @@ -37,9 +39,13 @@ export function Fab() { const { isPublicExploreRoute } = usePublicRoute(); const { requireAuth } = useRequireAuth(); const action = useFabAction(); + const pathname = usePathname(); - // Show FAB for authenticated users OR unauthenticated users on public explore routes - const shouldShow = isFullyAuthenticated || isPublicExploreRoute; + // Show FAB for authenticated users OR unauthenticated users on public explore routes. + // The graph explorer is a full-bleed canvas, not a composer surface: the FAB + // would float over its inspector sheet and controls. + const isGraphRoute = pathname?.startsWith(APP_ROUTES.GRAPH) ?? false; + const shouldShow = (isFullyAuthenticated || isPublicExploreRoute) && !isGraphRoute; if (isLoading || !shouldShow) { return null; } diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx index d4f669f590..afad27dd65 100644 --- a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx +++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Columns3, LayoutGrid, Menu } from 'lucide-react'; +import { Columns3, LayoutGrid, Menu, Waypoints } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { LAYOUT, type LayoutType } from '@/stores/home/home.types'; import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup'; @@ -18,7 +18,8 @@ export function FilterLayout({ showVisual = false, }: FilterLayoutProps) { const t = useTranslations('filters.layout'); - const displaySelectedTab = !showVisual && selectedTab === LAYOUT.VISUAL ? LAYOUT.COLUMNS : selectedTab; + const displaySelectedTab = + !showVisual && (selectedTab === LAYOUT.VISUAL || selectedTab === LAYOUT.GRAPH) ? LAYOUT.COLUMNS : selectedTab; const items = React.useMemo( () => [ @@ -45,6 +46,15 @@ export function FilterLayout({ dataCy: 'visual-layout-toggle', } : null, + showVisual + ? { + key: LAYOUT.GRAPH, + label: t('graph'), + icon: Waypoints, + disabled, + dataCy: 'graph-layout-toggle', + } + : null, ].filter(Boolean) as FilterListItem[], [t, disabled, showVisual], ); diff --git a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx b/src/components/molecules/FollowButton/FollowButton.test.tsx similarity index 55% rename from src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx rename to src/components/molecules/FollowButton/FollowButton.test.tsx index 409457a521..92d5e94373 100644 --- a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx +++ b/src/components/molecules/FollowButton/FollowButton.test.tsx @@ -1,15 +1,15 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserInfoPopoverFollowButton } from './UserInfoPopoverFollowButton'; +import { FollowButton } from './FollowButton'; -describe('UserInfoPopoverFollowButton', () => { +describe('FollowButton', () => { beforeEach(() => { vi.clearAllMocks(); }); it('renders Follow state when not following', () => { const onClick = vi.fn(); - render(); + render(); const button = screen.getByLabelText('Follow'); expect(button).toBeInTheDocument(); @@ -18,37 +18,36 @@ describe('UserInfoPopoverFollowButton', () => { }); it('renders Unfollow state when following', () => { - render(); + render(); expect(screen.getByLabelText('Unfollow')).toBeInTheDocument(); }); it('renders loading state and disables button', () => { - render(); + render(); const button = screen.getByLabelText('Follow') as HTMLButtonElement; expect(button).toBeDisabled(); expect(button.querySelector('.lucide-loader-circle')).toBeInTheDocument(); }); + + it('appends a caller className', () => { + render(); + expect(screen.getByLabelText('Follow')).toHaveClass('flex-1'); + }); }); -describe('UserInfoPopoverFollowButton - Snapshots', () => { +describe('FollowButton - Snapshots', () => { it('matches snapshot for follow state', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); it('matches snapshot for following state', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); it('matches snapshot for loading state', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); }); diff --git a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap b/src/components/molecules/FollowButton/FollowButton.test.tsx.snap similarity index 93% rename from src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap rename to src/components/molecules/FollowButton/FollowButton.test.tsx.snap index 619e52449f..8f6c5eb845 100644 --- a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap +++ b/src/components/molecules/FollowButton/FollowButton.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`UserInfoPopoverFollowButton - Snapshots > matches snapshot for follow state 1`] = ` +exports[`FollowButton - Snapshots > matches snapshot for follow state 1`] = ` `; -exports[`UserInfoPopoverFollowButton - Snapshots > matches snapshot for following state 1`] = ` +exports[`FollowButton - Snapshots > matches snapshot for following state 1`] = ` `; -exports[`UserInfoPopoverFollowButton - Snapshots > matches snapshot for loading state 1`] = ` +exports[`FollowButton - Snapshots > matches snapshot for loading state 1`] = ` + ); +} diff --git a/src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx b/src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx new file mode 100644 index 0000000000..4b32545304 --- /dev/null +++ b/src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; +import { GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { FileController } from '@/controllers/file/file'; +import type { TrailEntry } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { cn } from '@/libs/utils/utils'; +import { AvatarWithFallback } from '@/organisms/AvatarWithFallback/AvatarWithFallback'; + +export interface GraphBreadcrumbsProps { + trail: TrailEntry[]; + onHop: (entry: TrailEntry) => void; + className?: string; +} + +/** + * GraphBreadcrumbs + * + * The focus history as avatar chips (me, John, Lyn, ...). Clicking a chip + * refocuses there, so deep explorations always have a way back. + */ +export function GraphBreadcrumbs({ trail, onHop, className }: GraphBreadcrumbsProps) { + if (trail.length < 2) return null; + + return ( +
+ {trail.map((entry, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ ); +} diff --git a/src/components/molecules/GraphSearch/GraphSearch.tsx b/src/components/molecules/GraphSearch/GraphSearch.tsx new file mode 100644 index 0000000000..6d011f5b41 --- /dev/null +++ b/src/components/molecules/GraphSearch/GraphSearch.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { Loader2, Search, Tag as TagIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Input } from '@/atoms/Input/Input'; +import { GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { useSearchAutocomplete } from '@/hooks/useSearchAutocomplete/useSearchAutocomplete'; +import { useSearchInput } from '@/hooks/useSearchInput/useSearchInput'; +import { cn, generateRandomColor, hexToRgba } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import { SearchUserSuggestion } from '@/molecules/SearchUserSuggestion/SearchUserSuggestion'; + +export interface GraphSearchProps { + onPickUser: (pubky: Pubky) => void; + onPickTag: (label: string) => void; + className?: string; +} + +/** + * GraphSearch + * + * Go-anywhere box for the canvas: reuses the app's search autocomplete and + * hands the chosen user/tag to the graph, which jumps there or merges the + * new neighborhood in. + */ +export function GraphSearch({ onPickUser, onPickTag, className }: GraphSearchProps) { + const t = useTranslations('graph'); + // House search-box state: value, focus, Escape, and outside-click close. + // Enter is a no-op here (picking happens by clicking a result); returning + // false keeps the typed query. + const { + inputValue, + isFocused, + containerRef, + inputRef, + handleInputChange, + handleKeyDown, + handleFocus, + clearInputValue, + setFocus, + } = useSearchInput({ onEnter: () => false }); + const { tags, users, isLoading } = useSearchAutocomplete({ + query: inputValue, + enabled: isFocused && inputValue.length > 0, + }); + + const pickUser = (pubky: Pubky) => { + onPickUser(pubky); + clearInputValue(); + setFocus(false); + }; + const pickTag = (label: string) => { + onPickTag(label); + clearInputValue(); + setFocus(false); + }; + + const hasResults = users.length > 0 || tags.length > 0; + const open = isFocused && inputValue.length > 0 && hasResults; + + return ( +
+
+ {isLoading ? ( + + ) : ( + + )} + +
+ + {open && ( +
+ {users.map((user) => ( +
+ +
+ ))} + {tags.map((tag) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx b/src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx new file mode 100644 index 0000000000..af629c8eb3 --- /dev/null +++ b/src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Pause, Play, X } from 'lucide-react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { cn } from '@/libs/utils/utils'; + +export interface GraphTimeMachineProps { + bounds: { min: number; max: number }; + /** Sorted ascending event timestamps; playback reveals at a constant EVENT + * rate so heavily skewed timelines still read as steady assembly */ + timestamps?: number[]; + cap: number | null; + onCapChange: (cap: number | null) => void; + onClose: () => void; + className?: string; +} + +const PLAY_DURATION_MS = 8000; +const PLAY_TICK_MS = 50; + +/** + * GraphTimeMachine + * + * A timeline scrubber over the graph's edge/post timestamps: drag to watch + * the network at any moment, press play to watch it assemble itself. + */ +export function GraphTimeMachine({ bounds, timestamps, cap, onCapChange, onClose, className }: GraphTimeMachineProps) { + const t = useTranslations('graph'); + const format = useFormatter(); + const [playing, setPlaying] = useState(false); + const playRef = useRef | null>(null); + + const value = cap ?? bounds.max; + const span = Math.max(1, bounds.max - bounds.min); + + useEffect(() => { + if (!playing) { + if (playRef.current) clearInterval(playRef.current); + return; + } + const stamps = timestamps && timestamps.length > 2 ? timestamps : null; + if (stamps) { + // Constant event-rate playback: real timelines are heavily skewed + // toward recent activity, so linear time playback shows nothing for + // seconds and then everything at once + const perTick = stamps.length / (PLAY_DURATION_MS / PLAY_TICK_MS); + // Restart from the beginning when already at the end + let index = + value >= bounds.max + ? 0 + : Math.max( + 0, + stamps.findIndex((s) => s >= value), + ); + onCapChange(stamps[Math.floor(index)]); + playRef.current = setInterval(() => { + index += perTick; + if (index >= stamps.length - 1) { + onCapChange(null); + setPlaying(false); + } else { + onCapChange(stamps[Math.floor(index)]); + } + }, PLAY_TICK_MS); + } else { + const step = (span / PLAY_DURATION_MS) * PLAY_TICK_MS; + let current = value >= bounds.max ? bounds.min : value; + onCapChange(current); + playRef.current = setInterval(() => { + current += step; + if (current >= bounds.max) { + onCapChange(null); + setPlaying(false); + } else { + onCapChange(current); + } + }, PLAY_TICK_MS); + } + return () => { + if (playRef.current) clearInterval(playRef.current); + }; + // `value` is intentionally omitted: it advances every tick and would + // restart the interval; bounds ARE included so merged pages during + // playback extend the run instead of ending at a stale max. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playing, bounds.min, bounds.max, timestamps]); + + return ( +
+ + { + setPlaying(false); + const next = Number(e.target.value); + onCapChange(next >= bounds.max ? null : next); + }} + className="h-1.5 w-28 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/15 accent-(--brand) sm:w-48 md:w-72" + aria-label={t('time.scrub')} + /> + + {cap === null ? t('time.now') : format.dateTime(new Date(value), { dateStyle: 'medium' })} + + +
+ ); +} diff --git a/src/components/molecules/Header/Header.test.tsx b/src/components/molecules/Header/Header.test.tsx index 2237dff3a9..d63fe919ee 100644 --- a/src/components/molecules/Header/Header.test.tsx +++ b/src/components/molecules/Header/Header.test.tsx @@ -533,7 +533,7 @@ describe('Header Components', () => { // Home and Hot are public explore routes → real navigation links. const links = screen.getAllByRole('link'); - expect(links.map((link) => link.getAttribute('href'))).toEqual(['/home', '/hot']); + expect(links.map((link) => link.getAttribute('href'))).toEqual(['/home', '/hot', '/graph']); expect(screen.getByTestId('search-input')).toBeInTheDocument(); // All four nav icons are shown. diff --git a/src/components/molecules/Header/Header.test.tsx.snap b/src/components/molecules/Header/Header.test.tsx.snap index 76df213254..3c2904f581 100644 --- a/src/components/molecules/Header/Header.test.tsx.snap +++ b/src/components/molecules/Header/Header.test.tsx.snap @@ -290,6 +290,63 @@ exports[`Header Components - Snapshots > matches snapshot for HeaderNavigationBu + + + matches snapshot for HeaderNavigationBu + + + matches snapshot for HeaderSignIn 1`] = + + + { COLLECTIONS: '/collections', SETTINGS: '/settings', PROFILE: '/profile', + GRAPH: '/graph', }, SETTINGS_ROUTES: { ACCOUNT: '/settings/account', @@ -491,6 +492,7 @@ describe('MobileFooter', () => { '/search', '/hot', '/collections', + '/graph', '/settings/account', ]); expect(document.querySelector('.lucide-library')).toBeInTheDocument(); diff --git a/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap b/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap index 593c84a824..9d9558b0dc 100644 --- a/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap +++ b/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap @@ -173,6 +173,55 @@ exports[`MobileFooter - Snapshots > matches snapshot with custom className 1`] = /> + + + matches snapshot with default props 1`] = ` /> + + + matches snapshot with different active path /> + + + + {toggles.map(({ label, checked, onChange, dataCy }) => ( + + ))} +
+ {/* Stacked full-width rows: side-by-side nowrap labels overflow the popover */} +
+ + +
+ {legend && ( + <> +
+ {legend} + + )} +
+ ); +} diff --git a/src/components/molecules/SocialGraphAdvancedPanel/SocialGraphAdvancedPanel.types.ts b/src/components/molecules/SocialGraphAdvancedPanel/SocialGraphAdvancedPanel.types.ts new file mode 100644 index 0000000000..51132e6a3c --- /dev/null +++ b/src/components/molecules/SocialGraphAdvancedPanel/SocialGraphAdvancedPanel.types.ts @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +export interface SocialGraphAdvancedPanelProps { + declutter: boolean; + onToggleDeclutter: () => void; + communitiesOn: boolean; + onToggleCommunities: () => void; + /** Edge-details lens: labels, count chips, arrowheads, edge popovers */ + edgeChipsOn: boolean; + onToggleEdgeChips: () => void; + /** Fetch shared tag-hub nodes with neighborhoods */ + tagHubsOn: boolean; + onToggleTagHubs: () => void; + physicsPaused: boolean; + onTogglePhysics: () => void; + onReleasePins: () => void; + onFit: () => void; + /** Embedded legend (kept out of the default view per the design notes) */ + legend?: ReactNode; + className?: string; +} diff --git a/src/components/molecules/SocialGraphControls/SocialGraphControls.test.tsx b/src/components/molecules/SocialGraphControls/SocialGraphControls.test.tsx new file mode 100644 index 0000000000..057ae9df24 --- /dev/null +++ b/src/components/molecules/SocialGraphControls/SocialGraphControls.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SocialGraphControls } from './SocialGraphControls'; + +const props = { + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + timeMachineOn: false, + timeMachineAvailable: true, + onToggleTimeMachine: vi.fn(), + onRecenterSelf: vi.fn(), + isFullscreen: false, + onToggleFullscreen: vi.fn(), +}; + +describe('SocialGraphControls', () => { + it('wires the design pill row: zoom, time machine, recenter', () => { + render(} />); + + fireEvent.click(document.querySelector('[data-cy="graph-zoom-in"]')!); + expect(props.onZoomIn).toHaveBeenCalled(); + fireEvent.click(document.querySelector('[data-cy="graph-zoom-out"]')!); + expect(props.onZoomOut).toHaveBeenCalled(); + fireEvent.click(document.querySelector('[data-cy="graph-time-toggle"]')!); + expect(props.onToggleTimeMachine).toHaveBeenCalled(); + fireEvent.click(document.querySelector('[data-cy="graph-recenter"]')!); + expect(props.onRecenterSelf).toHaveBeenCalled(); + // Advanced pill exists when content is provided + expect(document.querySelector('[data-cy="graph-advanced"]')).toBeInTheDocument(); + }); + + it('hides recenter when signed out and disables the time machine without timestamps', () => { + const { rerender } = render(); + expect(document.querySelector('[data-cy="graph-recenter"]')).toBeNull(); + expect(document.querySelector('[data-cy="graph-advanced"]')).toBeNull(); + + rerender(); + expect(document.querySelector('[data-cy="graph-time-toggle"]')).toBeDisabled(); + }); + + it('marks the time machine pill active', () => { + render(); + expect(document.querySelector('[data-cy="graph-time-toggle"]')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('toggles fullscreen from the right-most pill', () => { + const onToggleFullscreen = vi.fn(); + const { rerender } = render( + } onToggleFullscreen={onToggleFullscreen} />, + ); + const pill = document.querySelector('[data-cy="graph-fullscreen"]')!; + // Last in the row, after the advanced popover trigger + expect(document.querySelector('[data-cy="graph-controls"]')!.lastElementChild).toBe(pill); + expect(pill).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(pill); + expect(onToggleFullscreen).toHaveBeenCalled(); + + rerender( + } onToggleFullscreen={onToggleFullscreen} isFullscreen />, + ); + expect(document.querySelector('[data-cy="graph-fullscreen"]')).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/src/components/molecules/SocialGraphControls/SocialGraphControls.tsx b/src/components/molecules/SocialGraphControls/SocialGraphControls.tsx new file mode 100644 index 0000000000..1d5f8e96ad --- /dev/null +++ b/src/components/molecules/SocialGraphControls/SocialGraphControls.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { Expand, History, Shrink, SlidersHorizontal, UserRound, ZoomIn, ZoomOut } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/atoms/Popover/Popover'; +import { GRAPH_PILL_ACTIVE_CLASS, GRAPH_PILL_CLASS, GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { cn } from '@/libs/utils/utils'; +import type { SocialGraphControlsProps } from './SocialGraphControls.types'; + +/** + * SocialGraphControls + * + * The design's control pill row: zoom out, zoom in, time machine, and + * re-center on the signed-in user, plus one extra pill opening the advanced + * popover where the non-designed lenses live (legend, communities, declutter, + * edge details, physics), and the fullscreen toggle at the right end. + */ +export function SocialGraphControls({ + onZoomIn, + onZoomOut, + timeMachineOn, + timeMachineAvailable, + onToggleTimeMachine, + onRecenterSelf, + advancedContent, + isFullscreen, + onToggleFullscreen, + className, +}: SocialGraphControlsProps) { + const t = useTranslations('graph'); + + return ( +
+ + + + {onRecenterSelf && ( + + )} + {advancedContent && ( + + + + + + {advancedContent} + + + )} + +
+ ); +} diff --git a/src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts b/src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts new file mode 100644 index 0000000000..0538a68c71 --- /dev/null +++ b/src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; + +export interface SocialGraphControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; + timeMachineOn: boolean; + /** Disabled when the graph has no timestamps to scrub over */ + timeMachineAvailable: boolean; + onToggleTimeMachine: () => void; + /** Center back on the signed-in user (hidden when signed out) */ + onRecenterSelf?: () => void; + /** Advanced popover body (legend + hidden lenses); omitting hides the pill */ + advancedContent?: ReactNode; + /** Canvas card expanded to the viewport */ + isFullscreen: boolean; + onToggleFullscreen: () => void; + className?: string; +} diff --git a/src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx new file mode 100644 index 0000000000..e51bcfef91 --- /dev/null +++ b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { SocialGraphLegend } from './SocialGraphLegend'; + +const props = { + classCounts: new Map([ + ['self', 1], + ['friend', 8], + ['post', 10], + ]), + hiddenClasses: new Set(['post']), + onHoverClass: vi.fn(), + onToggleClass: vi.fn(), +}; + +describe('SocialGraphLegend', () => { + it('shows live counts, spotlights on hover, and toggles on click', () => { + render(); + + // Counts render next to their rows + expect(screen.getByText('8')).toBeInTheDocument(); + + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-friend"]')!); + expect(props.onHoverClass).toHaveBeenCalledWith('friend'); + + fireEvent.click(document.querySelector('[data-cy="graph-legend-friend"]')!); + expect(props.onToggleClass).toHaveBeenCalledWith('friend'); + + // Hidden classes read as off + expect(document.querySelector('[data-cy="graph-legend-post"]')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('hides edge rows by default', () => { + render(); + expect(document.querySelector('[data-cy="graph-legend-edge-fresh"]')).toBeNull(); + expect(document.querySelector('[data-cy="graph-legend-edge-intra"]')).toBeNull(); + }); + + it('shows edge rows per mode and spotlights matching edges on hover', () => { + const onHoverEdges = vi.fn(); + render(); + + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-edge-fresh"]')!); + expect(onHoverEdges).toHaveBeenCalledWith('fresh'); + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-edge-intra"]')!); + expect(onHoverEdges).toHaveBeenCalledWith('intra'); + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-edge-bridge"]')!); + expect(onHoverEdges).toHaveBeenCalledWith('bridge'); + + // Leaving the legend clears the edge spotlight too + fireEvent.mouseLeave(document.querySelector('[data-cy="graph-legend"]')!); + expect(onHoverEdges).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx new file mode 100644 index 0000000000..51031e2e10 --- /dev/null +++ b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Typography } from '@/atoms/Typography/Typography'; +import type { HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { cn } from '@/libs/utils/utils'; + +/** Edge-encoding rows: recent follows, intra-community links, bridges. */ +export type EdgeLegendKind = 'fresh' | 'intra' | 'bridge'; + +export interface SocialGraphLegendProps { + classCounts: Map; + hiddenClasses: Set; + /** Hover intent on a row: spotlight that class on the canvas */ + onHoverClass: (cls: HideableClass | null) => void; + /** Click on a row: hide/show that class */ + onToggleClass: (cls: HideableClass) => void; + /** Show the follow-recency gradient row (the graph has timestamped follows) */ + showRecency?: boolean; + /** Show the community tint/bridge rows (communities mode is on) */ + communitiesOn?: boolean; + /** Hover intent on an edge row: spotlight matching edges on the canvas */ + onHoverEdges?: (kind: EdgeLegendKind | null) => void; + className?: string; +} + +const RELATIONSHIP_ROWS: { key: HideableClass; swatch: string }[] = [ + { key: 'self', swatch: 'bg-brand rounded-full' }, + { key: 'friend', swatch: 'bg-(--chart-2) rounded-full' }, + { key: 'following', swatch: 'bg-(--chart-3) rounded-full' }, + { key: 'follower', swatch: 'bg-(--chart-1) rounded-full' }, + { key: 'extended', swatch: 'bg-muted-foreground rounded-full' }, + { key: 'post', swatch: 'rounded-[3px] border border-muted-foreground bg-white/10' }, + { key: 'tag', swatch: 'w-4 rounded-full border border-brand bg-brand/20' }, +]; + +/** + * SocialGraphLegend + * + * The legend IS the filter: every row shows a live count, hovering a row + * spotlights that class on the canvas, clicking hides it. One surface for + * reading the graph and shaping it. + */ +export function SocialGraphLegend({ + classCounts, + hiddenClasses, + onHoverClass, + onToggleClass, + showRecency = false, + communitiesOn = false, + onHoverEdges, + className, +}: SocialGraphLegendProps) { + const t = useTranslations('graph'); + const [open, setOpen] = useState(true); + + return ( +
{ + onHoverClass(null); + onHoverEdges?.(null); + }} + > +
+ + {t('legend.title')} + + +
+ {open && ( +
+ {RELATIONSHIP_ROWS.map(({ key, swatch }) => { + const hidden = hiddenClasses.has(key); + const count = classCounts.get(key) ?? 0; + return ( + + ); + })} + {(showRecency || communitiesOn) && ( + <> +
+ {showRecency && ( +
onHoverEdges?.('fresh')} + className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-white/10" + title={`${t('legend.old')} → ${t('legend.new')}`} + data-cy="graph-legend-edge-fresh" + > + + + {t('legend.followAge')} + + + {t('legend.old')} → {t('legend.new')} + +
+ )} + {communitiesOn && ( + <> +
onHoverEdges?.('intra')} + className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-white/10" + data-cy="graph-legend-edge-intra" + > + + + {t('legend.sameCommunity')} + +
+
onHoverEdges?.('bridge')} + className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-white/10" + data-cy="graph-legend-edge-bridge" + > + + + {t('legend.bridge')} + +
+ + )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx b/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx index 141c9bc138..232c65ca3a 100644 --- a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx +++ b/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx @@ -1,47 +1,6 @@ -'use client'; - -import { Check, Loader2, UserMinus, UserRoundPlus } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/atoms/Button/Button'; -import { Typography } from '@/atoms/Typography/Typography'; - -interface UserInfoPopoverFollowButtonProps { - isFollowing: boolean; - isLoading: boolean; - onClick: (e: React.MouseEvent) => void; -} -export function UserInfoPopoverFollowButton({ isFollowing, isLoading, onClick }: UserInfoPopoverFollowButtonProps) { - const t = useTranslations('userList'); - return ( - - ); -} +/** + * Thin re-export: the follow button was promoted to the shared FollowButton + * molecule (used beyond the popover, e.g. the graph node panel); this alias + * keeps existing popover imports stable. + */ +export { FollowButton as UserInfoPopoverFollowButton } from '@/molecules/FollowButton/FollowButton'; diff --git a/src/components/organisms/ContentLayout/ContentLayout.test.tsx b/src/components/organisms/ContentLayout/ContentLayout.test.tsx index 44f64d6e84..cbed171671 100644 --- a/src/components/organisms/ContentLayout/ContentLayout.test.tsx +++ b/src/components/organisms/ContentLayout/ContentLayout.test.tsx @@ -26,6 +26,7 @@ vi.mock('@/stores/home/home.types', () => ({ COLUMNS: 'columns', WIDE: 'wide', VISUAL: 'visual', + GRAPH: 'graph', }, })); vi.mock('@/utils/pubky-app-spec-feed-mappers', () => ({ @@ -374,6 +375,27 @@ describe('ContentLayout - Custom Feed Layout Override', () => { expect(screen.queryByText('Right Sidebar')).not.toBeInTheDocument(); }); + it('hides sidebars when custom feed route layout is graph (wide shell)', () => { + mockUseCustomFeed.mockReturnValue({ layout: 'graph' }); + + const { container } = render( + Left Sidebar
} + showRightSidebar={true} + rightSidebarContent={
Right Sidebar
} + > +
Test Content
+ , + ); + + expect(screen.queryByText('Left Sidebar')).not.toBeInTheDocument(); + expect(screen.queryByText('Right Sidebar')).not.toBeInTheDocument(); + // Same 1200px container as the wide layout; the canvas never escapes it + expect(container.querySelector('.container')).toHaveClass('max-w-(--container-max-width)'); + }); + it('shows ButtonFilters when custom feed route layout is wide and drawer content exists', () => { mockUseCustomFeed.mockReturnValue({ layout: 'wide' }); diff --git a/src/components/organisms/ContentLayout/ContentLayout.tsx b/src/components/organisms/ContentLayout/ContentLayout.tsx index e2365805f3..6213456734 100644 --- a/src/components/organisms/ContentLayout/ContentLayout.tsx +++ b/src/components/organisms/ContentLayout/ContentLayout.tsx @@ -93,7 +93,7 @@ export function ContentLayout({ }; const usesWideShellLayout = (effectiveLayout === LAYOUT.WIDE && !disableWideShellLayout) || - (feedVariant !== undefined && effectiveLayout === LAYOUT.VISUAL); + (feedVariant !== undefined && (effectiveLayout === LAYOUT.VISUAL || effectiveLayout === LAYOUT.GRAPH)); // Close drawers when switching from wide-shell to inline sidebars on desktop // This prevents the drawer from staying open when sidebars become visible inline diff --git a/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.test.tsx b/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.test.tsx new file mode 100644 index 0000000000..546b5a0456 --- /dev/null +++ b/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserController } from '@/controllers/user/user'; +import type { Pubky } from '@/models/models.types'; +import type { NexusGraphUserNode } from '@/services/nexus/graph/graph.types'; +import { GraphUserHoverCard } from './GraphUserHoverCard'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/graph', +})); + +vi.mock('@/controllers/user/user', () => ({ + UserController: { + getManyDetails: vi.fn(), + getManyCounts: vi.fn(), + getManyRelationships: vi.fn(), + getManyTagsOrFetch: vi.fn(), + }, +})); + +vi.mock('@/controllers/file/file', () => ({ + FileController: { getAvatarUrl: vi.fn(() => 'https://cdn.example/avatar') }, +})); + +vi.mock('@/hooks/useUserInfoPopoverActions/useUserInfoPopoverActions', () => ({ + useUserInfoPopoverActions: vi.fn(() => ({ isLoading: false, onEditClick: vi.fn(), onFollowClick: vi.fn() })), +})); + +const mockDetails = vi.mocked(UserController.getManyDetails); +const mockCounts = vi.mocked(UserController.getManyCounts); +const mockRels = vi.mocked(UserController.getManyRelationships); + +const PK = 'p'.repeat(52) as Pubky; +const ME = 'm'.repeat(52) as Pubky; +const node: NexusGraphUserNode = { kind: 'user', id: `user:${PK}`, pubky: PK, name: 'Jane', image: null }; + +const baseProps = { + node, + open: true, + x: 100, + y: 100, + nodes: [node], + edges: [], + meId: `user:${ME}`, +}; + +describe('GraphUserHoverCard', () => { + beforeEach(() => { + mockDetails.mockResolvedValue(new Map([[PK, { name: 'Jane Stuart', bio: 'Vibing', image: null }]]) as never); + mockCounts.mockResolvedValue(new Map([[PK, { followers: 15, following: 19 }]]) as never); + mockRels.mockResolvedValue(new Map([[PK, { following: false, followed_by: false }]]) as never); + }); + + it('renders identity instantly and local data when it lands, with both action buttons', async () => { + const onTraceConnection = vi.fn(); + render(); + + // Identity from the node payload, before any read resolves + expect(screen.getByText('Jane')).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText('Vibing')).toBeInTheDocument()); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('19')).toBeInTheDocument(); + + const trace = document.querySelector('[data-cy="graph-hover-trace"]')!; + expect(trace).toBeInTheDocument(); + trace.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onTraceConnection).toHaveBeenCalledWith(PK); + + // Zero-network contract: only the local-only bulk getters ran + expect(UserController.getManyTagsOrFetch).not.toHaveBeenCalled(); + }); + + it('hides follow and how-connected for the viewer, and how-connected when signed out', async () => { + render(); + await waitFor(() => expect(mockDetails).toHaveBeenCalled()); + expect(document.querySelector('[data-cy="graph-hover-trace"]')).toBeNull(); + + render(); + await waitFor(() => expect(mockDetails).toHaveBeenCalled()); + expect(document.querySelector('[data-cy="graph-hover-trace"]')).toBeNull(); + }); + + it('renders nothing when closed', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.tsx b/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.tsx new file mode 100644 index 0000000000..1e259a5d21 --- /dev/null +++ b/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useMemo } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { GitBranch } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Skeleton } from '@/atoms/Skeleton/Skeleton'; +import { GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { FileController } from '@/controllers/file/file'; +import { UserController } from '@/controllers/user/user'; +import { facepileCandidates } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { useUserInfoPopoverActions } from '@/hooks/useUserInfoPopoverActions/useUserInfoPopoverActions'; +import { cn, formatPublicKey } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import type { AvatarGroupItem } from '@/molecules/AvatarGroup/AvatarGroup.types'; +import { CanvasAnchoredPopover } from '@/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover'; +import { PostText } from '@/molecules/PostText/PostText'; +import { UserInfoPopoverFollowButton } from '@/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton'; +import { UserInfoPopoverHeader } from '@/molecules/UserInfoPopover/components/UserInfoPopoverHeader/UserInfoPopoverHeader'; +import { UserInfoPopoverStats } from '@/molecules/UserInfoPopover/components/UserInfoPopoverStats/UserInfoPopoverStats'; +import type { GraphUserHoverCardProps } from './GraphUserHoverCard.types'; + +const MAX_AVATARS = 3; + +/** + * GraphUserHoverCard + * + * The design's UserHover card for graph surfaces, with a hard zero-network + * contract: identity comes from the node payload, bio/counts/follow state + * from local-only live queries (the graph ingestion pipeline fills those + * rows), and facepiles from edges already on canvas. Hovering never fires a + * request; cold rows render as skeleton lines until ingestion lands. + */ +export function GraphUserHoverCard({ + node, + open, + x, + y, + nodes, + edges, + meId, + onTraceConnection, + onPointerEnter, + onPointerLeave, + className, +}: GraphUserHoverCardProps) { + const t = useTranslations('graph'); + const pubky = node.pubky; + const isSelf = meId === node.id; + + // Local-only reads; undefined = still loading (or cold cache) + const local = useLiveQuery(async () => { + const [details, counts, relationships] = await Promise.all([ + UserController.getManyDetails({ userIds: [pubky] }), + UserController.getManyCounts({ userIds: [pubky] }), + UserController.getManyRelationships({ userIds: [pubky] }), + ]); + return { + details: details.get(pubky) ?? null, + counts: counts.get(pubky) ?? null, + relationship: relationships.get(pubky) ?? null, + }; + }, [pubky]); + + const userName = local?.details?.name || node.name || formatPublicKey({ key: pubky }); + const isFollowing = Boolean(local?.relationship?.following); + + const { isLoading: isActionLoading, onFollowClick } = useUserInfoPopoverActions({ + userId: pubky, + userName, + isCurrentUser: isSelf, + isFollowing, + isFollowingStatusLoading: local === undefined, + }); + + // Facepiles strictly from the canvas: neighbors already on screen + const { followersAvatars, followingAvatars } = useMemo(() => { + const toItems = (ids: string[]): AvatarGroupItem[] => + ids.flatMap((id) => { + const neighbor = nodes.find((n) => n.id === id); + if (!neighbor || neighbor.kind !== 'user') return []; + return [ + { + id: neighbor.pubky, + name: neighbor.name || neighbor.pubky, + avatarUrl: neighbor.image ? FileController.getAvatarUrl(neighbor.pubky) : undefined, + }, + ]; + }); + return { + followersAvatars: toItems(facepileCandidates(node.id, edges, meId, 'followers', MAX_AVATARS)), + followingAvatars: toItems(facepileCandidates(node.id, edges, meId, 'following', MAX_AVATARS)), + }; + }, [node.id, nodes, edges, meId]); + + if (!open) return null; + + return ( + + + {local === undefined ? ( +
+ + +
+ ) : ( + <> + {local.details?.bio ? ( +
+ +
+ ) : null} + + + )} + {!isSelf && ( + <> + + {meId && onTraceConnection && ( + + )} + + )} +
+ ); +} diff --git a/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.types.ts b/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.types.ts new file mode 100644 index 0000000000..e2d78b1111 --- /dev/null +++ b/src/components/organisms/GraphUserHoverCard/GraphUserHoverCard.types.ts @@ -0,0 +1,23 @@ +import type { SocialGraphVisualEdge, VisualGraphNode } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { NexusGraphUserNode } from '@/services/nexus/graph/graph.types'; + +export interface GraphUserHoverCardProps { + /** The hovered user node (identity paints instantly, no reads) */ + node: NexusGraphUserNode; + /** Controlled visibility; hover intent lives in the caller */ + open: boolean; + /** Anchor point relative to the positioned container, fed per frame */ + x: number; + y: number; + /** Visible canvas nodes/edges: facepiles derive from them, never the network */ + nodes: VisualGraphNode[]; + edges: SocialGraphVisualEdge[]; + /** Signed-in viewer node id (`user:{pubky}`), null when signed out */ + meId: string | null; + /** Launch the how-are-we-connected trace for this user */ + onTraceConnection?: (pubky: string) => void; + /** Keeps the card alive while the pointer is over it */ + onPointerEnter?: () => void; + onPointerLeave?: () => void; + className?: string; +} diff --git a/src/components/organisms/SearchInput/SearchInput.test.tsx b/src/components/organisms/SearchInput/SearchInput.test.tsx index a740607db3..845cac3dc7 100644 --- a/src/components/organisms/SearchInput/SearchInput.test.tsx +++ b/src/components/organisms/SearchInput/SearchInput.test.tsx @@ -5,6 +5,7 @@ import { useSearchAutocomplete } from '@/hooks/useSearchAutocomplete/useSearchAu import { useSearchInput } from '@/hooks/useSearchInput/useSearchInput'; import { useTagSearch } from '@/hooks/useTagSearch/useTagSearch'; import type { Pubky } from '@/models/models.types'; +import { useGraphStore } from '@/stores/graph/graph.store'; import { useSearchStore } from '@/stores/search/search.store'; import { SearchInput } from './SearchInput'; @@ -525,6 +526,35 @@ describe('SearchInput', () => { expect(setFocus).toHaveBeenCalledWith(false); expect(mockPush).toHaveBeenCalledWith('/profile/user123'); }); + + it('hands user picks to the graph instead of navigating while on the graph page', () => { + mockPathname.mockReturnValue('/graph'); + mockPush.mockClear(); + vi.mocked(useSearchInput).mockReturnValue({ + inputValue: 'satoshi', + isFocused: true, + containerRef: { current: null }, + inputRef: { current: null }, + handleInputChange: vi.fn(), + handleKeyDown: vi.fn(), + handleFocus: vi.fn(), + clearInputValue: vi.fn(), + setFocus: vi.fn(), + }); + vi.mocked(useSearchAutocomplete).mockReturnValue({ + tags: [], + users: [{ id: 'user123', name: 'Satoshi' }], + isLoading: false, + }); + + render(); + fireEvent.click(screen.getByTestId('autocomplete-user-user123')); + + expect(mockPush).not.toHaveBeenCalled(); + expect(useGraphStore.getState().searchTarget).toEqual({ kind: 'user', pubky: 'user123' }); + useGraphStore.getState().clearSearchTarget(); + mockPathname.mockReturnValue('/home'); + }); }); describe('Active Tag Removal', () => { diff --git a/src/components/organisms/SearchInput/SearchInput.tsx b/src/components/organisms/SearchInput/SearchInput.tsx index f6fb942930..575fb69089 100644 --- a/src/components/organisms/SearchInput/SearchInput.tsx +++ b/src/components/organisms/SearchInput/SearchInput.tsx @@ -17,6 +17,7 @@ import { SearchInputBar } from '@/molecules/SearchInputBar/SearchInputBar'; import { SearchSuggestions } from '@/molecules/SearchSuggestions/SearchSuggestions'; import { toast } from '@/molecules/Toaster/use-toast'; import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; import { useSearchStore } from '@/stores/search/search.store'; import { SearchInputProps } from './SearchInput.types'; import { parseTagsFromUrl } from './SearchInput.utils'; @@ -32,12 +33,22 @@ export function SearchInput({ autoFocus = false }: SearchInputProps) { const currentUserPubky = useAuthStore((state) => state.currentUserPubky); const isMobile = useIsMobile(); + // On the graph page this bar drives the canvas instead of navigating: picks + // are handed to the graph via the store, so desktop needs no second field + const isGraphPage = pathname?.startsWith(APP_ROUTES.GRAPH) ?? false; + const handleEnter = (value: string) => { if (!isValidTagLabel(value.trim().toLowerCase())) { toast({ variant: 'error', description: t('invalidTag') }); return false; } + if (isGraphPage) { + useGraphStore.getState().requestSearch({ kind: 'tag', label: value.trim().toLowerCase() }); + setFocus(false); + return; + } + addTagToSearch(value, { addToRecent: true }); if (pathname !== APP_ROUTES.SEARCH) { setFocus(false); @@ -74,10 +85,21 @@ export function SearchInput({ autoFocus = false }: SearchInputProps) { addUser(userId); clearInputValue(); setFocus(false); + if (isGraphPage) { + useGraphStore.getState().requestSearch({ kind: 'user', pubky: userId }); + return; + } router.push(getUserProfileUrl(userId, currentUserPubky)); }; const handleTagClick = (tag: string) => { + if (isGraphPage) { + useGraphStore.getState().requestSearch({ kind: 'tag', label: tag }); + clearInputValue(); + setFocus(false); + return; + } + addTagToSearch(tag, { addToRecent: true }); clearInputValue(); diff --git a/src/components/organisms/SocialGraph/SocialGraph.sprites.test.ts b/src/components/organisms/SocialGraph/SocialGraph.sprites.test.ts new file mode 100644 index 0000000000..dfd8845ac2 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.sprites.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import type { NexusGraphPostNode } from '@/services/nexus/graph/graph.types'; +import { postGlyph } from './SocialGraph.sprites'; + +const post = (overrides: Partial): NexusGraphPostNode => ({ + kind: 'post', + id: 'post:a:1', + author_id: 'a', + post_id: '1', + content: '', + post_kind: 'short', + is_reply: false, + indexed_at: 0, + ...overrides, +}); + +describe('postGlyph', () => { + it('draws top-level posts by content kind', () => { + expect(postGlyph(post({ post_kind: 'image' }))).toBe('image'); + expect(postGlyph(post({ post_kind: 'short' }))).toBe('short'); + }); + + it('draws every reply with the reply glyph, whatever its content kind', () => { + expect(postGlyph(post({ is_reply: true }))).toBe('reply'); + expect(postGlyph(post({ is_reply: true, post_kind: 'image' }))).toBe('reply'); + }); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.sprites.ts b/src/components/organisms/SocialGraph/SocialGraph.sprites.ts new file mode 100644 index 0000000000..79af58ea03 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.sprites.ts @@ -0,0 +1,219 @@ +/** + * Offscreen sprite caches for the graph canvas. + * + * Tag chips and post-kind icons are pre-rasterized once and blitted with + * drawImage on every frame: painting rounded rects and text per node per + * frame (autoPauseRedraw is off) does not survive a few hundred chips. + * Tier alpha is applied at blit time via ctx.globalAlpha, so it is never + * part of a cache key. + */ + +import { COLORS } from '@/config/theme'; +import type { NexusGraphPostNode } from '@/services/nexus/graph/graph.types'; + +/** Design px -> device px supersampling for crisp sprites when zoomed in. */ +const SPRITE_SCALE = 2; +/** Chip geometry from the design: h 32, r 8, padX 12, label/count gap 6. */ +export const CHIP_HEIGHT = 32; +const CHIP_RADIUS = 8; +const CHIP_PAD_X = 12; +const CHIP_GAP = 6; +const CHIP_LABEL_FONT = '700 14px "Inter Tight", sans-serif'; +const CHIP_COUNT_FONT = '500 14px "Inter Tight", sans-serif'; +/** Chip fill = tag color under a 70% canvas-background overlay (PostTag recipe). */ +const CHIP_OVERLAY_ALPHA = 0.7; +/** Bound on distinct (label|count) chip bitmaps kept alive. */ +const CHIP_CACHE_CAP = 300; + +export type ChipSprite = { canvas: HTMLCanvasElement; w: number; h: number }; + +const chipCache = new Map(); +const measureCache = new Map(); +const iconCache = new Map(); + +let measureCtx: CanvasRenderingContext2D | null = null; +function measurer(): CanvasRenderingContext2D | null { + if (measureCtx) return measureCtx; + if (typeof document === 'undefined') return null; + measureCtx = document.createElement('canvas').getContext('2d'); + return measureCtx; +} + +// Sprites rasterized before Inter Tight finished loading carry fallback-font +// metrics; one flush when the fonts land re-bakes everything correctly +if (typeof document !== 'undefined') { + document.fonts?.ready.then(() => { + chipCache.clear(); + measureCache.clear(); + }); +} + +function textWidth(font: string, text: string): number { + const key = `${font}|${text}`; + const cached = measureCache.get(key); + if (cached !== undefined) return cached; + const ctx = measurer(); + if (!ctx) return text.length * 8; + ctx.font = font; + const width = ctx.measureText(text).width; + measureCache.set(key, width); + return width; +} + +/** Composite of `hex` under the canvas background at the chip overlay alpha. */ +export function chipFill(hex: string): string { + const match = /^#([0-9a-f]{6})$/i.exec(hex); + if (!match) return hex; + const bgMatch = /^#([0-9a-f]{6})$/i.exec(COLORS.background) ?? ['', '05050a']; + const channel = (source: string, i: number) => parseInt(source.slice(i * 2, i * 2 + 2), 16); + const toHex = (v: number) => Math.round(v).toString(16).padStart(2, '0'); + let out = '#'; + for (let i = 0; i < 3; i++) { + const c = channel(match[1], i); + const bg = channel(bgMatch[1], i); + out += toHex(c * (1 - CHIP_OVERLAY_ALPHA) + bg * CHIP_OVERLAY_ALPHA); + } + return out; +} + +/** Chip box in graph units for painters and pointer areas (no sprite forced). */ +export function chipMetrics(label: string, count: number | null): { w: number; h: number } { + const labelW = textWidth(CHIP_LABEL_FONT, label); + const countW = count !== null ? CHIP_GAP + textWidth(CHIP_COUNT_FONT, String(count)) : 0; + return { w: CHIP_PAD_X + labelW + countW + CHIP_PAD_X, h: CHIP_HEIGHT }; +} + +/** + * Pre-rasterized tag chip. `accent` is the raw tag color; the fill applies + * the design's dark overlay. Insertion-order LRU keeps memory bounded on + * long exploration sessions. + */ +export function chipSprite(label: string, count: number | null, accent: string): ChipSprite | null { + if (typeof document === 'undefined') return null; + const key = `${label}|${count ?? ''}|${accent}`; + const cached = chipCache.get(key); + if (cached) { + // Refresh LRU position + chipCache.delete(key); + chipCache.set(key, cached); + return cached; + } + + const { w, h } = chipMetrics(label, count); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(w * SPRITE_SCALE); + canvas.height = Math.ceil(h * SPRITE_SCALE); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.scale(SPRITE_SCALE, SPRITE_SCALE); + + ctx.beginPath(); + ctx.roundRect(0, 0, w, h, CHIP_RADIUS); + ctx.fillStyle = chipFill(accent); + ctx.fill(); + + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.font = CHIP_LABEL_FONT; + ctx.fillStyle = '#FFFFFF'; + ctx.fillText(label, CHIP_PAD_X, h / 2 + 0.5); + if (count !== null) { + ctx.font = CHIP_COUNT_FONT; + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.fillText(String(count), CHIP_PAD_X + textWidth(CHIP_LABEL_FONT, label) + CHIP_GAP, h / 2 + 0.5); + } + + const sprite: ChipSprite = { canvas, w, h }; + chipCache.set(key, sprite); + if (chipCache.size > CHIP_CACHE_CAP) { + const oldest = chipCache.keys().next().value; + if (oldest !== undefined) chipCache.delete(oldest); + } + return sprite; +} + +// Post glyph vector data, copied verbatim from the installed lucide-react +// (importing each icon's dist module for its __iconNode is not a public API). +// Kind mapping mirrors FilterContent.tsx: short/long/image/video/link/file; +// `reply` is MessageCircle, the app's reply glyph (actions bar, replies tab). +type IconShape = [string, Record]; +const ICON_NODES: Record = { + reply: [ + [ + 'path', + { + d: 'M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719', + }, + ], + ], + short: [ + [ + 'path', + { + d: 'M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z', + }, + ], + ['path', { d: 'M15 3v5a1 1 0 0 0 1 1h5' }], + ], + long: [ + ['path', { d: 'M15 18h-5' }], + ['path', { d: 'M18 14h-8' }], + ['path', { d: 'M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2' }], + ['rect', { width: '8', height: '4', x: '10', y: '6', rx: '1' }], + ], + image: [ + ['rect', { width: '18', height: '18', x: '3', y: '3', rx: '2', ry: '2' }], + ['circle', { cx: '9', cy: '9', r: '2' }], + ['path', { d: 'm21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21' }], + ], + video: [ + ['path', { d: 'M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z' }], + ['circle', { cx: '12', cy: '12', r: '10' }], + ], + link: [ + ['path', { d: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' }], + ['path', { d: 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' }], + ], + file: [ + ['path', { d: 'M12 15V3' }], + ['path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }], + ['path', { d: 'm7 10 5 5 5-5' }], + ], +}; + +function iconMarkup(shapes: IconShape[]): string { + const body = shapes + .map(([tag, attrs]) => { + const serialized = Object.entries(attrs) + .map(([k, v]) => `${k}="${v}"`) + .join(' '); + return `<${tag} ${serialized}/>`; + }) + .join(''); + return ( + '' + + body + + '' + ); +} + +/** Glyph key for a post node: replies read as replies whatever their content kind. */ +export function postGlyph(node: Pick): string { + return node.is_reply ? 'reply' : node.post_kind; +} + +/** + * White lucide glyph for a post, or null until its bitmap decodes + * (the continuous repaint loop picks it up on a later frame, like avatars). + */ +export function postIconSprite(glyph: string): HTMLImageElement | null { + if (typeof document === 'undefined') return null; + const kind = ICON_NODES[glyph] ? glyph : 'short'; + const cached = iconCache.get(kind); + if (cached) return cached.complete && cached.naturalWidth > 0 ? cached : null; + const img = new Image(); + img.src = `data:image/svg+xml;utf8,${encodeURIComponent(iconMarkup(ICON_NODES[kind]))}`; + iconCache.set(kind, img); + return null; +} diff --git a/src/components/organisms/SocialGraph/SocialGraph.test.tsx b/src/components/organisms/SocialGraph/SocialGraph.test.tsx new file mode 100644 index 0000000000..907b903b20 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.test.tsx @@ -0,0 +1,245 @@ +import { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { SocialGraphVisualEdge } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { SocialGraph } from './SocialGraph'; +import type { SocialGraphHandle } from './SocialGraph.types'; + +// jsdom has no canvas: the force-graph engine is replaced by a stub that +// records the graph data it receives. +const receivedProps: Record[] = []; + +vi.mock('next/dynamic', () => ({ + default: () => { + const MockForceGraph = (props: Record) => { + receivedProps.push(props); + return
; + }; + return MockForceGraph; + }, +})); + +vi.mock('usehooks-ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useResizeObserver: () => ({ width: 800, height: 600 }), + }; +}); + +vi.mock('@/controllers/file/file', () => ({ + FileController: { getAvatarUrl: vi.fn(() => 'https://cdn.example/avatar') }, +})); + +const nodes: NexusGraphNode[] = [ + { kind: 'user', id: 'user:me', pubky: 'me', name: 'Me', image: null }, + { kind: 'user', id: 'user:friend', pubky: 'friend', name: 'Friend', image: null }, +]; +const edges: SocialGraphVisualEdge[] = [{ source: 'user:me', target: 'user:friend', type: 'FRIEND' }]; + +describe('SocialGraph', () => { + it('renders the canvas wrapper and passes graph data to the engine', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getByTestId('force-graph-stub')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="social-graph"]')).toBeInTheDocument(); + + const props = receivedProps.at(-1) as { graphData: { nodes: unknown[]; links: unknown[] } }; + expect(props.graphData.nodes).toHaveLength(2); + expect(props.graphData.links).toHaveLength(1); + // Node objects are passed by reference so the simulation keeps positions + expect(props.graphData.nodes[0]).toBe(nodes[0]); + // Link objects are copies so the engine's endpoint mutation stays contained + expect(props.graphData.links[0]).not.toBe(edges[0]); + + // Edges paint a fat pointer area and advertise clickability on hover + const interactionProps = receivedProps.at(-1) as { + linkPointerAreaPaint: unknown; + onLinkHover: unknown; + }; + expect(interactionProps.linkPointerAreaPaint).toEqual(expect.any(Function)); + expect(interactionProps.onLinkHover).toEqual(expect.any(Function)); + + // The imperative camera handle is exposed + expect(ref.current).toMatchObject({ + zoomIn: expect.any(Function), + zoomOut: expect.any(Function), + fit: expect.any(Function), + centerOn: expect.any(Function), + setPaused: expect.any(Function), + releasePins: expect.any(Function), + }); + }); + + it('paints flat design hairlines at the dimmer endpoint tier by default', () => { + render( + , + ); + const props = receivedProps.at(-1) as { + linkColor: (link: unknown) => string; + linkDirectionalArrowLength: (link: unknown) => number; + linkCurvature: (link: unknown) => number; + }; + const color = props.linkColor({ source: nodes[0], target: nodes[1], type: 'FRIEND' }); + // 1px #525252 hairline at the dimmer endpoint's tier alpha (direct = 0.6) + expect(color).toBe('rgba(82, 82, 82, 0.6)'); + // No arrowheads and no curvature in the default view + expect(props.linkDirectionalArrowLength({ source: 'user:me', target: 'user:friend', type: 'FOLLOWS' })).toBe(0); + expect(props.linkCurvature({ source: 'user:me', target: 'user:friend', type: 'TAGGED' })).toBe(0); + }); + + it('colors focus edges by relationship and neighbor edges by recency', () => { + const manyNodes: NexusGraphNode[] = [ + ...nodes, + { kind: 'user', id: 'user:a', pubky: 'a', name: 'A', image: null }, + { kind: 'user', id: 'user:b', pubky: 'b', name: 'B', image: null }, + ]; + const manyEdges: SocialGraphVisualEdge[] = [ + { source: 'user:me', target: 'user:friend', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:a', target: 'user:b', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:b', target: 'user:a', type: 'FOLLOWS', indexed_at: 1000 }, + ]; + render( + , + ); + const props = receivedProps.at(-1) as { linkColor: (link: unknown) => string }; + const focusEdge = props.linkColor({ source: 'user:me', target: 'user:friend', type: 'FOLLOWS' }); + const oldEdge = props.linkColor({ source: 'user:a', target: 'user:b', type: 'FOLLOWS', indexed_at: 100 }); + const freshEdge = props.linkColor({ source: 'user:b', target: 'user:a', type: 'FOLLOWS', indexed_at: 1000 }); + // Focus edge keeps the relationship palette; neighbor edges do not + expect(focusEdge).not.toBe(oldEdge); + // Recency separates neighbor edges: fresh is more opaque than old + const alphaOf = (rgba: string) => Number(rgba.match(/[\d.]+/g)!.at(-1)); + expect(alphaOf(freshEdge)).toBeGreaterThan(alphaOf(oldEdge)); + }); + + it('dims links outside an explicit edge spotlight', () => { + const manyEdges: SocialGraphVisualEdge[] = [ + { source: 'user:me', target: 'user:friend', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:friend', target: 'user:me', type: 'FOLLOWS', indexed_at: 900 }, + ]; + render( + , + ); + const props = receivedProps.at(-1) as { linkColor: (link: unknown) => string }; + const alphaOf = (rgba: string) => Number(rgba.match(/[\d.]+/g)!.at(-1)); + const dimmed = props.linkColor({ source: 'user:me', target: 'user:friend', type: 'FOLLOWS', indexed_at: 100 }); + const lit = props.linkColor({ source: 'user:friend', target: 'user:me', type: 'FOLLOWS', indexed_at: 900 }); + expect(alphaOf(dimmed)).toBeLessThanOrEqual(0.05); + expect(alphaOf(lit)).toBeGreaterThan(0.1); + }); + + it('tints intra-community edges and keeps bridges neutral when communities are on', () => { + const manyNodes: NexusGraphNode[] = [ + ...nodes, + { kind: 'user', id: 'user:a', pubky: 'a', name: 'A', image: null }, + { kind: 'user', id: 'user:b', pubky: 'b', name: 'B', image: null }, + ]; + const manyEdges: SocialGraphVisualEdge[] = [ + { source: 'user:a', target: 'user:b', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:friend', target: 'user:a', type: 'FOLLOWS', indexed_at: 100 }, + ]; + render( + , + ); + const props = receivedProps.at(-1) as { linkColor: (link: unknown) => string }; + const intra = props.linkColor({ source: 'user:a', target: 'user:b', type: 'FOLLOWS' }); + const bridge = props.linkColor({ source: 'user:friend', target: 'user:a', type: 'FOLLOWS' }); + expect(intra).not.toBe(bridge); + expect(bridge).toBe('rgba(245, 245, 255, 0.6)'); + }); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.theme.test.ts b/src/components/organisms/SocialGraph/SocialGraph.theme.test.ts new file mode 100644 index 0000000000..9ca2473696 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.theme.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { edgeRecencyColor, followAlphaFactors, liftForDarkCanvas } from './SocialGraph.theme'; + +/** Relative lightness as HSL L, from a #rrggbb string. */ +function lightnessOf(hex: string): number { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + return (Math.max(r, g, b) + Math.min(r, g, b)) / 2; +} + +describe('liftForDarkCanvas', () => { + it('lifts dark colors to a readable lightness while keeping the hue family', () => { + const lifted = liftForDarkCanvas('#00008b'); + expect(lightnessOf(lifted)).toBeGreaterThanOrEqual(0.54); + // Still blue-dominant + const [r, g, b] = [lifted.slice(1, 3), lifted.slice(3, 5), lifted.slice(5, 7)].map((c) => parseInt(c, 16)); + expect(b).toBeGreaterThan(r); + expect(b).toBeGreaterThan(g); + }); + + it('leaves already-bright colors untouched', () => { + expect(liftForDarkCanvas('#c8ff00')).toBe('#c8ff00'); + expect(liftForDarkCanvas('#ff98a0')).toBe('#ff98a0'); + }); + + it('passes through malformed input unchanged', () => { + expect(liftForDarkCanvas('not-a-color')).toBe('not-a-color'); + }); +}); + +describe('edgeRecencyColor', () => { + const channels = (rgba: string) => rgba.match(/[\d.]+/g)!.map(Number); + + it('fades old edges and brightens fresh ones', () => { + const old = channels(edgeRecencyColor(0, false)); + const fresh = channels(edgeRecencyColor(1, false)); + // Fresh is brighter and more opaque than old + expect(fresh[0] + fresh[1] + fresh[2]).toBeGreaterThan(old[0] + old[1] + old[2]); + expect(fresh[3]).toBeGreaterThan(old[3]); + expect(old[3]).toBeGreaterThan(0.05); + }); + + it('clamps t outside [0,1]', () => { + expect(edgeRecencyColor(-1, false)).toBe(edgeRecencyColor(0, false)); + expect(edgeRecencyColor(2, false)).toBe(edgeRecencyColor(1, false)); + }); + + it('dimmed edges collapse to the spotlight alpha regardless of age', () => { + expect(channels(edgeRecencyColor(1, true))[3]).toBeLessThanOrEqual(0.05); + }); +}); + +describe('followAlphaFactors', () => { + const follow = (source: string, target: string) => ({ source, target, type: 'FOLLOWS' as const }); + + it('keeps small graphs at design brightness', () => { + const edges = [ + follow('me', 'a'), + follow('me', 'b'), + { source: 'a', target: 'b', type: 'FRIEND' as const }, + { source: 'me', target: 'post:1', type: 'AUTHORED' as const }, + ]; + expect(followAlphaFactors(edges, 'me')).toEqual({ spoke: 1, mesh: 0.3 }); + }); + + it('dims spokes gently with degree and floors them readable', () => { + const spokes = Array.from({ length: 150 }, (_, i) => follow('me', `n${i}`)); + expect(followAlphaFactors(spokes, 'me').spoke).toBeCloseTo(Math.sqrt(60 / 150), 5); + + const dense = Array.from({ length: 2000 }, (_, i) => follow('me', `n${i}`)); + expect(followAlphaFactors(dense, 'me').spoke).toBe(0.55); + }); + + it('does not let the neighbor mesh dim the spokes', () => { + const spokes = Array.from({ length: 40 }, (_, i) => follow('me', `n${i}`)); + const mesh = Array.from({ length: 600 }, (_, i) => follow(`n${i % 40}`, `m${i}`)); + const factors = followAlphaFactors([...spokes, ...mesh], 'me'); + expect(factors.spoke).toBe(1); + // The mesh still recedes with total follow density + expect(factors.mesh).toBeCloseTo(Math.sqrt(80 / 640) * 0.3, 5); + }); + + it('treats every follow edge as mesh without a focus', () => { + const edges = Array.from({ length: 10 }, (_, i) => follow('me', `n${i}`)); + expect(followAlphaFactors(edges, null)).toEqual({ spoke: 1, mesh: 0.3 }); + }); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.theme.ts b/src/components/organisms/SocialGraph/SocialGraph.theme.ts new file mode 100644 index 0000000000..fb3e292199 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.theme.ts @@ -0,0 +1,159 @@ +/** + * Canvas color resolution for the social graph. + * + * The 2D canvas cannot consume CSS variables, so tokens are read once from + * the document root and normalized to hex (the paint code derives alpha + * variants through hexToRgba, which only parses hex), with hex fallbacks + * mirroring globals.css in the shared COLORS config. + */ + +import { COLORS } from '@/config/theme'; +import type { GraphTier } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { cssColorToHex } from '@/libs/utils/utils'; + +export type GraphTheme = { + self: string; + friend: string; + following: string; + follower: string; + extended: string; + post: string; + edgeMuted: string; + label: string; + halo: string; +}; + +// Design constants (Figma "Feed - Graph"): every edge is a 1px #525252 +// hairline, node surfaces are #303034 discs, and clusters dim by tier. +export const GRAPH_EDGE_RGB = '82, 82, 82'; +export const GRAPH_NODE_SURFACE = '#303034'; +/** Cluster opacity by tier: centered 100%, direct connections 60%, other 40%. */ +export const TIER_ALPHA: Record = { center: 1, direct: 0.6, other: 0.4 }; +/** Avatar radius (graph units = design px) by tier: 64/48/32px diameters. */ +export const AVATAR_RADIUS: Record = { center: 32, direct: 24, other: 16 }; +/** Post nodes are fixed 36px circles with a 20px kind glyph. */ +export const POST_RADIUS = 18; +export const POST_ICON_SIZE = 20; + +// The design's edge look assumes a few dozen follows; a real account has +// hundreds, and full-alpha edges stack into a starburst. Spokes (edges touching +// the focus) recede gently with the focus degree and floor high enough to stay +// readable; the neighbor mesh recedes with total density down to a texture. +const SPOKE_DESIGN_DEGREE = 60; +const SPOKE_ALPHA_FLOOR = 0.55; +const MESH_DESIGN_EDGES = 80; +const MESH_ALPHA_FLOOR = 0.3; +const MESH_ALPHA = 0.3; + +/** Alpha multipliers for follow edges: `spoke` touches the focus, `mesh` is everything else. */ +export function followAlphaFactors( + edges: ReadonlyArray<{ source: string; target: string; type: string }>, + focusId: string | null, +): { spoke: number; mesh: number } { + let spokes = 0; + let total = 0; + for (const edge of edges) { + if (edge.type !== 'FOLLOWS' && edge.type !== 'FRIEND') continue; + total++; + if (focusId !== null && (edge.source === focusId || edge.target === focusId)) spokes++; + } + const spoke = + spokes <= SPOKE_DESIGN_DEGREE ? 1 : Math.max(SPOKE_ALPHA_FLOOR, Math.sqrt(SPOKE_DESIGN_DEGREE / spokes)); + const density = total <= MESH_DESIGN_EDGES ? 1 : Math.max(MESH_ALPHA_FLOOR, Math.sqrt(MESH_DESIGN_EDGES / total)); + return { spoke, mesh: density * MESH_ALPHA }; +} + +/** Hex fallbacks approximating the OKLCH tokens in globals.css */ +export const GRAPH_FALLBACK_COLORS: GraphTheme = { ...COLORS.graph }; + +const TOKEN_BY_KEY: Partial> = { + self: '--brand', + friend: '--chart-2', + following: '--chart-3', + follower: '--chart-1', +}; + +/** Colors below this perceived luminance get lifted before canvas painting. */ +const MIN_CANVAS_LUMINANCE = 0.35; +/** Target HSL lightness for lifted colors. */ +const LIFTED_LIGHTNESS = 0.55; + +/** + * Lifts a #rrggbb color to a readable brightness so label-derived hues (which + * can hash to near-black navies and maroons) stay visible on the dark canvas. + * Gated on perceived luminance, not HSL lightness: saturated greens/yellows + * read bright at L=0.5 while blues need the lift. Bright colors and non-hex + * input pass through unchanged. + */ +export function liftForDarkCanvas(hex: string): string { + const match = /^#([0-9a-f]{6})$/i.exec(hex); + if (!match) return hex; + const r = parseInt(match[1].slice(0, 2), 16) / 255; + const g = parseInt(match[1].slice(2, 4), 16) / 255; + const b = parseInt(match[1].slice(4, 6), 16) / 255; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + if (luminance >= MIN_CANVAS_LUMINANCE) return hex; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const lightness = (max + min) / 2; + + // hex -> HSL, clamp L, HSL -> hex + const delta = max - min; + const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1)); + let hue = 0; + if (delta !== 0) { + if (max === r) hue = ((g - b) / delta) % 6; + else if (max === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + hue *= 60; + if (hue < 0) hue += 360; + } + const l = Math.max(lightness, LIFTED_LIGHTNESS); + const c = (1 - Math.abs(2 * l - 1)) * saturation; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = l - c / 2; + let [nr, ng, nb] = [0, 0, 0]; + if (hue < 60) [nr, ng, nb] = [c, x, 0]; + else if (hue < 120) [nr, ng, nb] = [x, c, 0]; + else if (hue < 180) [nr, ng, nb] = [0, c, x]; + else if (hue < 240) [nr, ng, nb] = [0, x, c]; + else if (hue < 300) [nr, ng, nb] = [x, 0, c]; + else [nr, ng, nb] = [c, 0, x]; + const toHex = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, '0'); + return `#${toHex(nr)}${toHex(ng)}${toHex(nb)}`; +} + +// Recency ramp endpoints for neighbor-to-neighbor follow edges: old +// connections recede into cool gray, fresh ones glow warm white +const EDGE_OLD = { r: 108, g: 110, b: 122, a: 0.12 }; +const EDGE_FRESH = { r: 246, g: 240, b: 222, a: 0.62 }; + +/** + * Color for a follow edge by normalized recency t (0 = oldest in view, + * 1 = freshest). Spotlight-dimmed edges collapse to the shared dim alpha. + */ +export function edgeRecencyColor(t: number, dimmed: boolean): string { + const clamped = Math.min(1, Math.max(0, t)); + const r = Math.round(EDGE_OLD.r + (EDGE_FRESH.r - EDGE_OLD.r) * clamped); + const g = Math.round(EDGE_OLD.g + (EDGE_FRESH.g - EDGE_OLD.g) * clamped); + const b = Math.round(EDGE_OLD.b + (EDGE_FRESH.b - EDGE_OLD.b) * clamped); + const a = dimmed ? 0.04 : EDGE_OLD.a + (EDGE_FRESH.a - EDGE_OLD.a) * clamped; + return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`; +} + +export function resolveGraphTheme(): GraphTheme { + if (typeof window === 'undefined') return GRAPH_FALLBACK_COLORS; + const style = getComputedStyle(document.documentElement); + const theme = { ...GRAPH_FALLBACK_COLORS }; + for (const [key, token] of Object.entries(TOKEN_BY_KEY) as [keyof GraphTheme, string][]) { + // Anything non-normalizable (no canvas, out-of-gamut serialization, + // parse failure) keeps the fallback + const normalized = cssColorToHex(style.getPropertyValue(token).trim()); + if (normalized) theme[key] = normalized; + } + theme.halo = theme.self; + return theme; +} diff --git a/src/components/organisms/SocialGraph/SocialGraph.tsx b/src/components/organisms/SocialGraph/SocialGraph.tsx new file mode 100644 index 0000000000..9a04a6706e --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.tsx @@ -0,0 +1,1050 @@ +'use client'; + +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import type { ForceGraphMethods, LinkObject, NodeObject } from 'react-force-graph-2d'; +import { useResizeObserver } from 'usehooks-ts'; +import { Skeleton } from '@/atoms/Skeleton/Skeleton'; +import { FileController } from '@/controllers/file/file'; +import { + adjacencyOf, + edgeKey, + type SocialGraphVisualEdge, + type VisualGraphNode, +} from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { cn, generateRandomColor, hexToRgba } from '@/libs/utils/utils'; +import { chipMetrics, chipSprite, postGlyph, postIconSprite } from './SocialGraph.sprites'; +import { + AVATAR_RADIUS, + edgeRecencyColor, + followAlphaFactors, + GRAPH_EDGE_RGB, + GRAPH_FALLBACK_COLORS, + GRAPH_NODE_SURFACE, + type GraphTheme, + liftForDarkCanvas, + POST_ICON_SIZE, + POST_RADIUS, + resolveGraphTheme, + TIER_ALPHA, +} from './SocialGraph.theme'; +import type { SocialGraphHandle, SocialGraphProps } from './SocialGraph.types'; + +const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { + ssr: false, + loading: () => , +}); + +type CanvasNode = NodeObject & + VisualGraphNode & { + __bornAt?: number; + __pinned?: boolean; + fx?: number; + fy?: number; + }; +type CanvasLink = LinkObject & SocialGraphVisualEdge; + +const DOUBLE_CLICK_MS = 350; +const DIM_ALPHA = 0.12; +const PULSE_MS = 900; +const HOVER_INTENT_MS = 350; +/** Hover lifts a cluster from its tier alpha to 1.0 over this ease-in. */ +const HOVER_LIFT_MS = 150; + +// Avatar bitmaps are shared across renders and node instances; the canvas +// repaints continuously (autoPauseRedraw=false), so late loads pop in without +// bookkeeping. Failed loads are remembered to avoid re-fetch storms. +const avatarCache = new Map(); + +function avatarImage(pubky: string, hasImage: boolean): HTMLImageElement | null { + if (!hasImage) return null; + const cached = avatarCache.get(pubky); + if (cached === 'error') return null; + if (cached) return cached.complete && cached.naturalWidth > 0 ? cached : null; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onerror = () => avatarCache.set(pubky, 'error'); + img.src = FileController.getAvatarUrl(pubky); + avatarCache.set(pubky, img); + return null; +} + +const endpointId = (end: string | number | NodeObject | undefined): string => + typeof end === 'object' && end !== null ? String(end.id) : String(end ?? ''); + +/** edgeKey over a materialized link (endpoints may be node objects). */ +const linkKeyOf = (link: CanvasLink): string => + edgeKey({ source: endpointId(link.source), target: endpointId(link.target), type: link.type, label: link.label }); + +/** Deterministic tint per community: chart tokens first, generated colors after. */ +const COMMUNITY_BASE = ['#4B48E5', '#31E581', '#4FD7E8', '#E24BCB', '#E5484B', '#E8A33D']; +const communityColor = (index: number): string => COMMUNITY_BASE[index] ?? generateRandomColor(`community-${index}`); + +/** Hash color for a tag label, lifted so dark hues stay readable on canvas. */ +const labelColor = (label: string): string => liftForDarkCanvas(generateRandomColor(label)); + +/** + * SocialGraph + * + * The force-directed canvas: users as avatar discs ringed by their + * relationship to the focused user, tags as colored pills, posts as muted + * squares; birth pulses, spotlight dimming, path particles, and community + * halos on top. The only module that imports react-force-graph-2d, so the + * rendering engine stays swappable. + */ +export const SocialGraph = forwardRef(function SocialGraph( + { + nodes, + edges, + focusId, + selectedId, + relationships, + opacityTiers, + sizeTiers, + ringId, + spotlight, + spotlightEdges = null, + pathIds, + communities, + communityLabels, + edgeChipsOn = false, + onNodeClick, + onNodeExpand, + onBackgroundClick, + onLinkClick, + onUserHover, + className, + }, + ref, +) { + const containerRef = useRef(null); + const graphRef = useRef(undefined); + const { width = 0, height = 0 } = useResizeObserver({ + ref: containerRef as React.RefObject, + box: 'border-box', + }); + const [theme, setTheme] = useState(GRAPH_FALLBACK_COLORS); + // Coarse PRIMARY pointers get fatter hit targets and no hover-intent + // popover (the inspector panel is the touch affordance). Deliberately not + // useIsTouchDevice: that reports true for mouse-driven touchscreen laptops + // (maxTouchPoints > 0) and would disable the hover card there. + const coarsePointer = useMemo(() => window.matchMedia?.('(pointer: coarse)')?.matches ?? false, []); + // Flips once the dynamically imported engine mounts and the ref is live; + // effects keyed on it would otherwise fire against an empty ref + const [engineReady, setEngineReady] = useState(false); + const [hoverId, setHoverId] = useState(null); + const lastClick = useRef<{ id: string; at: number }>({ id: '', at: 0 }); + const didInitialFit = useRef(false); + const focusPulseAt = useRef(0); + const hoverTimer = useRef | null>(null); + // centerOn sets this so the focus-change effect below does not re-arm the + // settle-time zoomToFit and undo the directed camera flight (recenter flow) + const suppressRefit = useRef(false); + // True while the simulation is cooled down (QA surface via the handle) + const settledRef = useRef(false); + const hoverLiftAt = useRef(0); + + useEffect(() => { + setTheme(resolveGraphTheme()); + return () => { + if (hoverTimer.current) clearTimeout(hoverTimer.current); + }; + }, []); + + // Re-fit the camera when the view re-centers (full loads), and pulse the + // new focus; recenter clicks fly the camera themselves and skip the re-fit + useEffect(() => { + focusPulseAt.current = Date.now(); + if (suppressRefit.current) { + suppressRefit.current = false; + return; + } + didInitialFit.current = false; + }, [focusId]); + + // force-graph mutates link endpoints into node references, so it cannot be + // handed the pipeline's edge objects directly. The copies are CACHED by + // edge identity and reused across recomputes: the engine registers every + // object it has never seen in a finite hit-test color registry (~262k + // entries, then permanently full), so re-materializing ~1500 links on every + // legend toggle or time-machine tick exhausts it within minutes and nodes + // silently stop being clickable. Reuse keeps registrations near zero. + // Node objects are passed by reference on purpose (the simulation stores + // coordinates on them, which keeps layout across merges). + // Not a React ref on purpose (refs must not be read during render); a + // per-mount Map whose entries accumulate (bounded by distinct edges seen) + const [linkCache] = useState(() => new Map()); + const graphData = useMemo(() => { + // Object-identity set of the current nodes, to validate resolved endpoints + const nodeSet = new Set(nodes); + const links = edges.map((edge) => { + const key = edgeKey(edge); + const cached = linkCache.get(key); + if (cached) { + // NEVER reset resolved endpoints on a cached link: the simulation + // still holds these objects and mutating them mid-flight corrupts + // the running layout (d3 then throws "node not found"). Reuse only + // when both endpoints still belong to the current node set; a link + // whose node was evicted and re-added gets a fresh copy instead. + const sourceOk = typeof cached.source === 'object' ? nodeSet.has(cached.source) : cached.source === edge.source; + const targetOk = typeof cached.target === 'object' ? nodeSet.has(cached.target) : cached.target === edge.target; + if (sourceOk && targetOk) { + cached.type = edge.type; + cached.label = edge.label; + cached.labels = edge.labels; + cached.indexed_at = edge.indexed_at; + return cached; + } + } + const link = { ...edge } as CanvasLink; + linkCache.set(key, link); + return link; + }); + return { nodes: nodes as CanvasNode[], links }; + }, [nodes, edges, linkCache]); + + // Force tuning for design-px node sizes (avatars up to r32, chips ~100 + // wide): strong repulsion between user hubs, short leashes for satellites + // so chips and posts orbit their owner, long rest length between users + // (the design's ~3:1 user-to-user vs user-to-satellite spacing), and a + // collision force so chips never stack over avatars. + useEffect(() => { + const fg = graphRef.current; + if (!fg) return; + const chargeOf = (nodeObj: NodeObject) => { + const node = nodeObj as CanvasNode; + return node.kind === 'user' ? -2000 : node.kind === 'profile_tag' ? -180 : -220; + }; + (fg.d3Force('charge') as { strength?: (s: unknown) => void } | undefined)?.strength?.(chargeOf); + const link = fg.d3Force('link') as + | { + distance?: (d: unknown) => void; + strength?: (s: unknown) => void; + } + | undefined; + link?.distance?.((linkObj: LinkObject) => { + const l = linkObj as CanvasLink; + if (l.type === 'HAS_TAG') return 105; + if (l.type === 'AUTHORED') return 120; + if (l.type === 'REPLIED' || l.type === 'REPOSTED' || l.type === 'MENTIONED') return 130; + // Focus spokes are the constellation's skeleton; long rest length keeps + // a dense first ring airy like the design + const source = endpointId(l.source); + const target = endpointId(l.target); + if (focusId && (source === focusId || target === focusId)) return 480; + return 340; + }); + // A real neighborhood is a dense mesh; if every follow pulls with equal + // force the layout collapses into a hairball. Satellites hold tight to + // their owner, focus spokes shape the constellation, and neighbor-to- + // neighbor follows barely tug (they render as faint texture anyway). + link?.strength?.((linkObj: LinkObject) => { + const l = linkObj as CanvasLink; + if (l.type === 'HAS_TAG') return 0.9; + if (l.type === 'AUTHORED' || l.type === 'REPLIED' || l.type === 'REPOSTED') return 0.7; + const source = endpointId(l.source); + const target = endpointId(l.target); + if (focusId && (source === focusId || target === focusId)) return 0.25; + return 0.02; + }); + (async () => { + try { + const { forceCollide } = (await import('d3-force-3d')) as { + forceCollide: (r: (node: NodeObject) => number) => unknown; + }; + fg.d3Force( + 'collide', + forceCollide((nodeObj: NodeObject) => { + const node = nodeObj as CanvasNode; + if (node.kind === 'user') return AVATAR_RADIUS[sizeTiers.get(node.id) ?? 'other'] + 8; + if (node.kind === 'post') return POST_RADIUS + 6; + const { w } = chipMetrics(node.label, 'count' in node ? node.count : null); + return w / 2 + 6; + }) as never, + ); + } catch { + // Collision is a nicety; the layout still works from charge + distance + } + })(); + }, [graphData, engineReady, sizeTiers, focusId]); + + const hoverNeighbors = useMemo(() => (hoverId ? adjacencyOf(hoverId, edges).add(hoverId) : null), [hoverId, edges]); + // Advanced dimming mechanism (legend hover / social proof); the hover-lift + // cluster brightening below is the default-view behavior + const highlightSet = spotlight ?? (edgeChipsOn ? hoverNeighbors : null); + + const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n as CanvasNode])), [nodes]); + + /** The user id whose cluster a node belongs to (chips/posts follow their owner). */ + const clusterAnchorOf = useCallback((node: CanvasNode): string => { + if (node.kind === 'post') return `user:${node.author_id}`; + if (node.kind === 'profile_tag') return `user:${node.pubky}`; + return node.id; + }, []); + + const hoverAnchor = useMemo(() => { + if (!hoverId) return null; + const node = nodeById.get(hoverId); + return node ? clusterAnchorOf(node) : null; + }, [hoverId, nodeById, clusterAnchorOf]); + + useEffect(() => { + if (hoverAnchor) hoverLiftAt.current = Date.now(); + }, [hoverAnchor]); + + /** + * Design opacity model: the whole cluster (avatar + chips + posts + their + * spokes) paints at its tier alpha (1.0 / 0.6 / 0.4), the hovered cluster + * eases up to 1.0, and an advanced spotlight overrides everything. + */ + const nodeAlpha = useCallback( + (node: CanvasNode): number => { + if (highlightSet !== null) return highlightSet.has(node.id) ? 1 : DIM_ALPHA; + const anchor = clusterAnchorOf(node); + let alpha = TIER_ALPHA[opacityTiers.get(anchor) ?? 'other']; + // Explicitly added tag hubs have no owner cluster; keep them readable + if (node.kind === 'tag') alpha = Math.max(alpha, TIER_ALPHA.direct); + if (anchor === hoverAnchor) { + const progress = Math.min(1, (Date.now() - hoverLiftAt.current) / HOVER_LIFT_MS); + alpha = alpha + (1 - alpha) * progress; + } + return alpha; + }, + [highlightSet, clusterAnchorOf, opacityTiers, hoverAnchor], + ); + + // Unordered "a|b" pair keys of the traced path, for the lime edge paint + const pathPairs = useMemo(() => { + if (!pathIds || pathIds.length < 2) return null; + const pairs = new Set(); + for (let i = 0; i < pathIds.length - 1; i++) { + pairs.add([pathIds[i], pathIds[i + 1]].sort().join('|')); + } + return pairs; + }, [pathIds]); + + const isPathLink = useCallback( + (link: CanvasLink): boolean => { + if (!pathPairs) return false; + return pathPairs.has([endpointId(link.source), endpointId(link.target)].sort().join('|')); + }, + [pathPairs], + ); + + const relationshipColor = useCallback( + (nodeId: string): string => { + switch (relationships.get(nodeId)) { + case 'self': + return theme.self; + case 'friend': + return theme.friend; + case 'following': + return theme.following; + case 'follower': + return theme.follower; + default: + return theme.extended; + } + }, + [relationships, theme], + ); + + // Design sizes: avatars 64/48/32px by signed-in-anchored tier, posts 36px + const nodeRadius = useCallback( + (node: CanvasNode): number => { + if (node.kind === 'user') return AVATAR_RADIUS[sizeTiers.get(node.id) ?? 'other']; + return POST_RADIUS; + }, + [sizeTiers], + ); + + // Exactly one node carries the lime focus ring (path mode: the target) + const ringTarget = ringId !== undefined ? ringId : focusId; + + // Soft community halos, painted under everything else + const paintCommunities = useCallback( + (ctx: CanvasRenderingContext2D) => { + if (!communities) return; + for (const node of graphData.nodes) { + const community = communities.get(node.id); + if (community === undefined || node.x === undefined || node.y === undefined) continue; + ctx.beginPath(); + ctx.arc(node.x, node.y, nodeRadius(node) + 7, 0, 2 * Math.PI); + ctx.fillStyle = hexToRgba(communityColor(community), 0.13); + ctx.fill(); + } + }, + [communities, graphData, nodeRadius], + ); + + // Community captions at each community centroid, over the graph + const paintCaptions = useCallback( + (ctx: CanvasRenderingContext2D, globalScale: number) => { + if (!communities || communityLabels.size === 0) return; + const sums = new Map(); + for (const node of graphData.nodes) { + const community = communities.get(node.id); + if (community === undefined || !communityLabels.has(community)) continue; + if (node.x === undefined || node.y === undefined) continue; + const sum = sums.get(community) ?? { x: 0, y: 0, n: 0 }; + sum.x += node.x; + sum.y += node.y; + sum.n += 1; + sums.set(community, sum); + } + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const [community, sum] of sums) { + const label = communityLabels.get(community)!; + ctx.font = `600 ${Math.max(5, 13 / globalScale)}px "Inter Tight", sans-serif`; + ctx.fillStyle = hexToRgba(communityColor(community), 0.75); + ctx.fillText(`#${label}`, sum.x / sum.n, sum.y / sum.n); + } + }, + [communities, communityLabels, graphData], + ); + + const paintNode = useCallback( + (nodeObj: NodeObject, ctx: CanvasRenderingContext2D) => { + const node = nodeObj as CanvasNode; + const x = node.x ?? 0; + const y = node.y ?? 0; + const alpha = nodeAlpha(node); + ctx.save(); + ctx.globalAlpha = alpha; + + // Birth / focus pulse: an expanding, fading ring + const pulseStart = + node.__bornAt && Date.now() - node.__bornAt < PULSE_MS + ? node.__bornAt + : node.id === focusId && Date.now() - focusPulseAt.current < PULSE_MS + ? focusPulseAt.current + : null; + + if (node.kind === 'user') { + const r = nodeRadius(node); + + if (pulseStart) { + const t = (Date.now() - pulseStart) / PULSE_MS; + ctx.beginPath(); + ctx.arc(x, y, r + t * 24, 0, 2 * Math.PI); + ctx.strokeStyle = hexToRgba(theme.halo, 0.5 * (1 - t)); + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Disc: #303034 underlay, avatar when loaded, else a white initial + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fillStyle = GRAPH_NODE_SURFACE; + ctx.fill(); + + const img = avatarImage(node.pubky, Boolean(node.image)); + if (img) { + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.clip(); + ctx.drawImage(img, x - r, y - r, r * 2, r * 2); + ctx.restore(); + } else { + ctx.fillStyle = '#FFFFFF'; + ctx.font = `600 ${Math.max(8, r)}px "Inter Tight", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText((node.name || node.pubky).charAt(0).toUpperCase(), x, y + 1); + } + + // The design's single lime focus ring: 2px, flush inside the edge + if (node.id === ringTarget) { + ctx.beginPath(); + ctx.arc(x, y, r - 1, 0, 2 * Math.PI); + ctx.strokeStyle = theme.halo; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // A small pin dot marks drag-pinned nodes + if (node.__pinned) { + ctx.beginPath(); + ctx.arc(x + r * 0.72, y - r * 0.72, 3, 0, 2 * Math.PI); + ctx.fillStyle = theme.halo; + ctx.fill(); + } + } else if (node.kind === 'profile_tag' || node.kind === 'tag') { + // Tag chips: the app's PostTag recipe, pre-rasterized + const count = node.count; + const { w, h } = chipMetrics(node.label, count); + if (pulseStart) { + const t = (Date.now() - pulseStart) / PULSE_MS; + ctx.globalAlpha = Math.min(1, t * 2) * alpha; + } + const sprite = chipSprite(node.label, count, generateRandomColor(node.label)); + if (sprite) { + ctx.drawImage(sprite.canvas, x - w / 2, y - h / 2, w, h); + } else { + // Fonts still loading: paint the pill without text for this frame + ctx.beginPath(); + ctx.roundRect(x - w / 2, y - h / 2, w, h, 8); + ctx.fillStyle = GRAPH_NODE_SURFACE; + ctx.fill(); + } + if (node.kind === 'tag' && node.id === selectedId) { + ctx.beginPath(); + ctx.roundRect(x - w / 2, y - h / 2, w, h, 8); + ctx.strokeStyle = theme.halo; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } else { + // Post: 36px #303034 circle with its white post-kind glyph + const r = POST_RADIUS; + if (pulseStart) { + const t = (Date.now() - pulseStart) / PULSE_MS; + ctx.globalAlpha = Math.min(1, t * 2) * alpha; + } + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fillStyle = GRAPH_NODE_SURFACE; + ctx.fill(); + const icon = postIconSprite(postGlyph(node)); + if (icon) { + const s = POST_ICON_SIZE; + ctx.drawImage(icon, x - s / 2, y - s / 2, s, s); + } + if (node.id === selectedId) { + ctx.beginPath(); + ctx.arc(x, y, r - 1, 0, 2 * Math.PI); + ctx.strokeStyle = theme.halo; + ctx.lineWidth = 2; + ctx.stroke(); + } + } + ctx.restore(); + }, + [nodeAlpha, selectedId, focusId, ringTarget, nodeRadius, theme], + ); + + const paintPointerArea = useCallback( + (nodeObj: NodeObject, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => { + const node = nodeObj as CanvasNode; + const x = node.x ?? 0; + const y = node.y ?? 0; + if (process.env.NEXT_PUBLIC_DEBUG_MODE === 'true' && typeof window !== 'undefined') { + // QA instrumentation: proves the shadow canvas actually paints every + // node kind (hit-testing regressions historically hid here) + const w = window as unknown as { __paintStats?: Record }; + const stats = (w.__paintStats = w.__paintStats ?? {}); + stats[node.kind] = (stats[node.kind] ?? 0) + 1; + stats.transform = (ctx.getTransform().a * 1000) | 0; + } + ctx.fillStyle = color; + const pad = coarsePointer ? 6 : 4; + // Pointer areas paint in graph units and shrink with the camera; keep a + // minimum on-screen grab radius so drags land at overview zoom too + // (capped so far-out zoom does not blanket neighbors) + const minRadius = Math.min(20, (coarsePointer ? 14 : 11) / globalScale); + if (node.kind === 'tag' || node.kind === 'profile_tag') { + // Same geometry as the painted chip, so long labels stay clickable + const metrics = chipMetrics(node.label, node.count); + const w = Math.max(metrics.w + pad * 2, minRadius * 2); + const h = Math.max(metrics.h + pad * 2, minRadius * 2); + ctx.fillRect(x - w / 2, y - h / 2, w, h); + return; + } + const r = Math.max(nodeRadius(node) + pad, minRadius); + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fill(); + }, + [nodeRadius, coarsePointer], + ); + + // Follow-edge alpha multipliers by density: spokes off the focus vs the mesh + const followAlpha = useMemo(() => followAlphaFactors(edges, focusId), [edges, focusId]); + + // Timestamp range of follow edges, for the recency ramp normalization + const followTimeRange = useMemo(() => { + let min = Infinity; + let max = -Infinity; + for (const edge of edges) { + if ((edge.type === 'FOLLOWS' || edge.type === 'FRIEND') && edge.indexed_at !== undefined) { + min = Math.min(min, edge.indexed_at); + max = Math.max(max, edge.indexed_at); + } + } + return min < max ? { min, max } : null; + }, [edges]); + + /** Resolve a link endpoint to its node object (engine may hold ids or objects). */ + const endpointNode = useCallback( + (end: string | number | NodeObject | undefined): CanvasNode | null => { + if (typeof end === 'object' && end !== null) return end as CanvasNode; + return nodeById.get(String(end ?? '')) ?? null; + }, + [nodeById], + ); + + // Design edge model: every edge is a 1px #525252 hairline at its dimmer + // endpoint's cluster alpha; traced-path edges paint lime. The advanced + // edge-details lens restores the old encodings (ego relationship colors, + // community tints, recency ramp, tag hues). + const linkColor = useCallback( + (linkObj: LinkObject): string => { + const link = linkObj as CanvasLink; + if (isPathLink(link)) return hexToRgba(theme.halo, 0.95); + const source = endpointId(link.source); + const target = endpointId(link.target); + // An explicit edge spotlight dims by edge identity; otherwise links dim + // when either endpoint is outside the node spotlight + const dimmed = spotlightEdges + ? !spotlightEdges.has(linkKeyOf(link)) + : highlightSet !== null && !(highlightSet.has(source) && highlightSet.has(target)); + + if (!edgeChipsOn) { + if (dimmed) return `rgba(${GRAPH_EDGE_RGB}, 0.04)`; + const a = endpointNode(link.source); + const b = endpointNode(link.target); + let alpha = Math.min(a ? nodeAlpha(a) : TIER_ALPHA.other, b ? nodeAlpha(b) : TIER_ALPHA.other); + // Real neighborhoods are dense meshes (hundreds of neighbor-to- + // neighbor follows); the design reads as hub-and-spoke, so edges not + // touching the focus recede to a faint texture instead of stacking + // into a bright web, and spokes dim more gently with the focus degree + if (link.type === 'FOLLOWS' || link.type === 'FRIEND') { + alpha *= source === focusId || target === focusId ? followAlpha.spoke : followAlpha.mesh; + } + return `rgba(${GRAPH_EDGE_RGB}, ${Number(alpha.toFixed(3))})`; + } + + const alpha = dimmed ? 0.04 : 0.5; + switch (link.type) { + case 'FRIEND': + case 'FOLLOWS': { + if (source === focusId || target === focusId) { + if (link.type === 'FRIEND') return hexToRgba(theme.friend, dimmed ? 0.04 : 0.65); + // Arrow points at the followed side; color by the far endpoint + const far = source === focusId ? target : source; + return hexToRgba(relationshipColor(far), alpha); + } + if (communities) { + const a = communities.get(source); + const b = communities.get(target); + if (a !== undefined && b !== undefined) { + if (a === b) return hexToRgba(communityColor(a), dimmed ? 0.04 : 0.45); + // Bridges between communities are the structurally interesting + // edges; they stay bright and neutral + return dimmed ? 'rgba(245, 245, 255, 0.04)' : 'rgba(245, 245, 255, 0.6)'; + } + } + const t = followTimeRange + ? link.indexed_at !== undefined + ? (link.indexed_at - followTimeRange.min) / (followTimeRange.max - followTimeRange.min) + : 0 + : 0.35; + return edgeRecencyColor(t * t, dimmed); + } + case 'TAGGED': + return hexToRgba(labelColor(link.label ?? ''), alpha); + case 'HAS_TAG': + return `rgba(${GRAPH_EDGE_RGB}, ${dimmed ? 0.04 : 0.5})`; + default: + return hexToRgba(theme.edgeMuted, dimmed ? 0.04 : 0.8); + } + }, + [ + highlightSet, + spotlightEdges, + isPathLink, + edgeChipsOn, + endpointNode, + nodeAlpha, + focusId, + relationshipColor, + theme, + communities, + followTimeRange, + followAlpha, + ], + ); + + // Count chips on aggregated tag edges, drawn over the link line (advanced + // edge-details lens only; the design's default edges carry no chrome) + const paintLink = useCallback( + (linkObj: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => { + if (!edgeChipsOn) return; + const link = linkObj as CanvasLink; + if (!link.labels || link.labels.length < 2) return; + const source = link.source as NodeObject; + const target = link.target as NodeObject; + if (typeof source !== 'object' || typeof target !== 'object') return; + if (source.x === undefined || target.x === undefined) return; + const dimmed = spotlightEdges + ? !spotlightEdges.has(linkKeyOf(link)) + : highlightSet !== null && !(highlightSet.has(String(source.id)) && highlightSet.has(String(target.id))); + const x = (source.x + (target.x ?? 0)) / 2; + const y = ((source.y ?? 0) + (target.y ?? 0)) / 2; + // Chips on short edges inside dense clusters would stack over the nodes + const dist = Math.hypot((target.x ?? 0) - source.x, (target.y ?? 0) - (source.y ?? 0)); + if (dist < 26 && globalScale < 2.2) return; + const color = labelColor(link.label ?? ''); + const text = String(link.labels.length); + const fontSize = 3.8; + ctx.save(); + ctx.globalAlpha = dimmed ? DIM_ALPHA : 0.9; + ctx.font = `700 ${fontSize}px "Inter Tight", sans-serif`; + const r = 3; + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fillStyle = '#101014'; + ctx.fill(); + ctx.strokeStyle = color; + ctx.lineWidth = 0.7; + ctx.stroke(); + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y + 0.3); + ctx.restore(); + }, + [edgeChipsOn, highlightSet, spotlightEdges], + ); + + // The visible edges are hairlines; the interactive surface is painted much + // fatter, and the count chip gets a generous disc so it works as the button + // it looks like (twice the size on touch screens). Advanced lens only: + // default-view edges are non-interactive per the design. + const linkPointerAreaPaint = useCallback( + (linkObj: LinkObject, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => { + if (!edgeChipsOn) return; + const link = linkObj as CanvasLink; + const source = link.source as NodeObject; + const target = link.target as NodeObject; + if (typeof source !== 'object' || typeof target !== 'object') return; + if (source.x === undefined || target.x === undefined) return; + ctx.strokeStyle = color; + ctx.lineWidth = Math.min(10, Math.max(coarsePointer ? 9 : 5, (coarsePointer ? 16 : 10) / globalScale)); + ctx.beginPath(); + ctx.moveTo(source.x, source.y ?? 0); + ctx.lineTo(target.x, target.y ?? 0); + ctx.stroke(); + if ((link.labels?.length ?? 0) > 1) { + const x = (source.x + (target.x ?? 0)) / 2; + const y = ((source.y ?? 0) + (target.y ?? 0)) / 2; + ctx.beginPath(); + ctx.arc( + x, + y, + Math.min(14, Math.max(coarsePointer ? 8 : 5.5, (coarsePointer ? 16 : 11) / globalScale)), + 0, + 2 * Math.PI, + ); + ctx.fillStyle = color; + ctx.fill(); + } + }, + [edgeChipsOn, coarsePointer], + ); + + // Actionable edges advertise themselves with a pointer cursor + const handleLinkHover = useCallback( + (linkObj: LinkObject | null) => { + const link = linkObj as CanvasLink | null; + hoveredLinkRef.current = link !== null; + const canvas = containerRef.current?.querySelector('canvas'); + if (canvas) canvas.style.cursor = edgeChipsOn && link && link.type === 'TAGGED' ? 'pointer' : ''; + }, + [edgeChipsOn], + ); + + const screenPositionOf = useCallback((node: CanvasNode): { x: number; y: number } | null => { + const fg = graphRef.current; + if (!fg || node.x === undefined || node.y === undefined) return null; + const pos = fg.graph2ScreenCoords(node.x, node.y); + return { x: pos.x, y: pos.y }; + }, []); + + const handleNodeHover = useCallback( + (nodeObj: NodeObject | null) => { + const node = nodeObj as CanvasNode | null; + hoveredNodeRef.current = node !== null; + hoveredIdRef.current = node ? String(node.id) : null; + setHoverId(node ? String(node.id) : null); + if (!onUserHover || coarsePointer) return; + if (hoverTimer.current) clearTimeout(hoverTimer.current); + if (node && node.kind === 'user') { + hoverTimer.current = setTimeout(() => { + onUserHover(node, screenPositionOf(node)); + }, HOVER_INTENT_MS); + } else { + onUserHover(null, null); + } + }, + [onUserHover, coarsePointer, screenPositionOf], + ); + + // Stable accessors: force-graph re-materializes per-link state whenever an + // accessor prop changes identity, so inline lambdas would reset it on every + // hover-driven re-render. Design defaults: uniform 1px hairlines, straight, + // no arrowheads; the advanced edge-details lens restores the old chrome. + const linkWidth = useCallback( + (link: LinkObject) => { + const l = link as CanvasLink; + if (isPathLink(l)) return 1.5; + if (edgeChipsOn && l.type === 'FRIEND') return 1.8; + return 1; + }, + [isPathLink, edgeChipsOn], + ); + const linkCurvature = useCallback( + (link: LinkObject) => (edgeChipsOn && (link as CanvasLink).type === 'TAGGED' ? 0.18 : 0), + [edgeChipsOn], + ); + const linkModeAfter = useCallback(() => 'after' as const, []); + const arrowLength = useCallback( + (link: LinkObject) => { + if (!edgeChipsOn) return 0; + const l = link as CanvasLink; + if (l.type === 'FRIEND') return 0; + // Aggregated tag edges have a canonicalized direction: no arrow + if (l.type === 'TAGGED' && (l.labels?.length ?? 0) > 1) return 0; + // Hub edges out of a tag pill read better without arrowheads + if (l.type === 'TAGGED' && endpointId(l.source).startsWith('tag:')) return 0; + return 6; + }, + [edgeChipsOn], + ); + + const handleNodeClick = useCallback( + (nodeObj: NodeObject) => { + const id = String(nodeObj.id); + const now = Date.now(); + if (lastClick.current.id === id && now - lastClick.current.at < DOUBLE_CLICK_MS) { + lastClick.current = { id: '', at: 0 }; + onNodeExpand(id); + return; + } + lastClick.current = { id, at: now }; + onNodeClick(id); + }, + [onNodeClick, onNodeExpand], + ); + + // The engine's own hit canvas refreshes on an 800ms throttle, so during + // simulation ticks and camera motion it lags what the eye sees; re-setting + // the pointer painter forces the library to flush it (cheap at our scale) + const flushHitCanvas = useCallback(() => { + const fg = graphRef.current as unknown as { nodePointerAreaPaint?: (fn: unknown) => unknown } | undefined; + fg?.nodePointerAreaPaint?.(paintPointerArea); + }, [paintPointerArea]); + + // Background clicks are detected here instead of the engine: registering + // onBackgroundClick with the library arms a zero-tolerance gesture guard + // that suppresses EVERY click (nodes and chips included) after 1px of + // mouse jitter between press and release + const hoveredNodeRef = useRef(false); + const hoveredLinkRef = useRef(false); + const hoveredIdRef = useRef(null); + const pressPosRef = useRef<{ x: number; y: number } | null>(null); + + // Single background click deselects (forwarded to the page); a quick second + // click zooms toward the clicked region, mirroring node double-click + const lastBackgroundClick = useRef(0); + const handleBackgroundClick = useCallback( + (event: MouseEvent) => { + const now = Date.now(); + if (now - lastBackgroundClick.current < DOUBLE_CLICK_MS) { + lastBackgroundClick.current = 0; + const fg = graphRef.current; + if (fg) { + const point = fg.screen2GraphCoords(event.offsetX, event.offsetY); + // A directed zoom consumes any pending auto-fit + didInitialFit.current = true; + fg.centerAt(point.x, point.y, 350); + fg.zoom(fg.zoom() * 1.7, 350); + } + return; + } + lastBackgroundClick.current = now; + onBackgroundClick(); + }, + [onBackgroundClick], + ); + + const handleLinkClick = useCallback( + (linkObj: LinkObject, event: MouseEvent) => { + const link = linkObj as CanvasLink; + onLinkClick?.( + { + source: endpointId(link.source), + target: endpointId(link.target), + type: link.type, + label: link.label, + labels: link.labels, + indexed_at: link.indexed_at, + }, + { x: event.offsetX, y: event.offsetY }, + ); + }, + [onLinkClick], + ); + + const markEngineReady = useCallback(() => { + setEngineReady((ready) => (ready ? ready : true)); + }, []); + + const handleEngineTick = useCallback(() => { + settledRef.current = false; + markEngineReady(); + flushHitCanvas(); + }, [markEngineReady, flushHitCanvas]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onPointerDown = (event: PointerEvent) => { + pressPosRef.current = { x: event.clientX, y: event.clientY }; + }; + const onPointerUp = (event: PointerEvent) => { + const press = pressPosRef.current; + pressPosRef.current = null; + if (!press || event.button !== 0) return; + // Same gesture tolerance the engine grants node clicks + if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 5) return; + if (hoveredNodeRef.current || hoveredLinkRef.current) return; + lastClick.current = { id: '', at: 0 }; + const bounds = container.getBoundingClientRect(); + handleBackgroundClick({ + offsetX: event.clientX - bounds.left, + offsetY: event.clientY - bounds.top, + } as MouseEvent); + }; + container.addEventListener('pointerdown', onPointerDown); + container.addEventListener('pointerup', onPointerUp); + return () => { + container.removeEventListener('pointerdown', onPointerDown); + container.removeEventListener('pointerup', onPointerUp); + }; + }, [handleBackgroundClick]); + + useImperativeHandle( + ref, + (): SocialGraphHandle => ({ + zoomIn: () => graphRef.current?.zoom((graphRef.current?.zoom() ?? 1) * 1.4, 300), + zoomOut: () => graphRef.current?.zoom((graphRef.current?.zoom() ?? 1) / 1.4, 300), + fit: () => graphRef.current?.zoomToFit(400, 48), + screenPositionOf: (nodeId: string) => { + const node = graphData.nodes.find((n) => n.id === nodeId); + const fg = graphRef.current; + if (!node || !fg || node.x === undefined || node.y === undefined) return null; + return fg.graph2ScreenCoords(node.x, node.y); + }, + screenMidpointOf: (aId: string, bId: string) => { + const a = graphData.nodes.find((n) => n.id === aId); + const b = graphData.nodes.find((n) => n.id === bId); + const fg = graphRef.current; + if (!a || !b || !fg || a.x === undefined || b.x === undefined) return null; + return fg.graph2ScreenCoords((a.x + b.x) / 2, ((a.y ?? 0) + (b.y ?? 0)) / 2); + }, + centerOn: (nodeId: string) => { + const node = graphData.nodes.find((n) => n.id === nodeId); + const fg = graphRef.current; + if (!node || !fg || node.x === undefined || node.y === undefined) return; + // A directed fly consumes any pending auto-fit, which would otherwise + // zoom back out when the simulation settles; the flag also stops the + // focus-change effect from re-arming that fit (recenter clicks) + didInitialFit.current = true; + suppressRefit.current = true; + // Two phases: ease out a little, then glide onto the target + const current = fg.zoom(); + fg.zoom(Math.max(0.55, current * 0.85), 180); + fg.centerAt(node.x, node.y, 450); + setTimeout(() => fg.zoom(Math.max(current, 0.9), 320), 460); + }, + setPaused: (paused: boolean) => { + for (const node of graphData.nodes) { + if (paused) { + node.fx = node.x; + node.fy = node.y; + } else if (!node.__pinned) { + node.fx = undefined; + node.fy = undefined; + } + } + if (!paused) graphRef.current?.d3ReheatSimulation(); + }, + releasePins: () => { + for (const node of graphData.nodes) { + if (node.__pinned) { + node.__pinned = false; + node.fx = undefined; + node.fy = undefined; + } + } + graphRef.current?.d3ReheatSimulation(); + }, + nodeIds: () => { + const groups: Record<'user' | 'post' | 'tag' | 'profile_tag', string[]> = { + user: [], + post: [], + tag: [], + profile_tag: [], + }; + for (const node of graphData.nodes) groups[node.kind].push(node.id); + return groups; + }, + pinnedIds: () => graphData.nodes.filter((n) => n.__pinned).map((n) => n.id), + isSettled: () => settledRef.current, + zoomLevel: () => graphRef.current?.zoom() ?? null, + hoveredId: () => hoveredIdRef.current, + }), + [graphData], + ); + + return ( +
+ {width > 0 && height > 0 && ( + ''} + linkColor={linkColor} + linkWidth={linkWidth} + linkCurvature={linkCurvature} + linkCanvasObjectMode={linkModeAfter} + linkCanvasObject={paintLink} + linkPointerAreaPaint={linkPointerAreaPaint} + linkDirectionalArrowLength={arrowLength} + linkDirectionalArrowRelPos={0.92} + onNodeClick={handleNodeClick} + onNodeHover={handleNodeHover} + onLinkClick={handleLinkClick} + onLinkHover={handleLinkHover} + onNodeDragEnd={(nodeObj) => { + const node = nodeObj as CanvasNode; + node.fx = node.x; + node.fy = node.y; + node.__pinned = true; + }} + onZoom={flushHitCanvas} + onEngineTick={handleEngineTick} + onRenderFramePre={(ctx) => paintCommunities(ctx)} + onRenderFramePost={(ctx, globalScale) => paintCaptions(ctx, globalScale)} + onEngineStop={() => { + settledRef.current = true; + if (!didInitialFit.current) { + didInitialFit.current = true; + graphRef.current?.zoomToFit(400, 60); + } + }} + cooldownTicks={120} + /> + )} +
+ ); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.types.ts b/src/components/organisms/SocialGraph/SocialGraph.types.ts new file mode 100644 index 0000000000..f0b23f7025 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.types.ts @@ -0,0 +1,71 @@ +import type { + GraphRelationship, + GraphTier, + SocialGraphVisualEdge, + VisualGraphNode, +} from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; + +/** Imperative camera and physics controls exposed to the page overlays. */ +export interface SocialGraphHandle { + zoomIn: () => void; + zoomOut: () => void; + fit: () => void; + /** Screen position of a node inside the canvas container, null when unknown */ + screenPositionOf: (nodeId: string) => { x: number; y: number } | null; + /** Screen position of the midpoint between two nodes (edge chips) */ + screenMidpointOf: (aId: string, bId: string) => { x: number; y: number } | null; + /** Fly the camera to a node id in two phases (no-op when unknown). */ + centerOn: (nodeId: string) => void; + /** Freeze/unfreeze the simulation without stopping rendering. */ + setPaused: (paused: boolean) => void; + /** Release every drag-pinned node back to the simulation. */ + releasePins: () => void; + /** Visible node ids grouped by kind (QA / debug surface). */ + nodeIds: () => Record<'user' | 'post' | 'tag' | 'profile_tag', string[]>; + /** Ids of drag-pinned nodes (QA / debug surface). */ + pinnedIds: () => string[]; + /** True once the simulation has cooled down after the last data change. */ + isSettled: () => boolean; + /** Current camera zoom, null before the engine mounts (QA / debug surface). */ + zoomLevel: () => number | null; + /** Node id the engine currently resolves under the pointer (QA / debug surface). */ + hoveredId: () => string | null; +} + +export interface SocialGraphProps { + nodes: VisualGraphNode[]; + edges: SocialGraphVisualEdge[]; + /** Prefixed id of the user relationships are derived against */ + focusId: string | null; + selectedId: string | null; + relationships: Map; + /** Focus-anchored cluster opacity tier per node (path mode: all 'center') */ + opacityTiers: Map; + /** Signed-in-anchored avatar size tier per node */ + sizeTiers: Map; + /** Node carrying the lime focus ring; defaults to focusId (path mode: the target) */ + ringId?: string | null; + /** When set, everything outside this node-id set dims (advanced legend hover, social proof) */ + spotlight: Set | null; + /** When set, links outside this edge-key set dim (legend edge-row hover); see edgeKey in useSocialGraph.utils */ + spotlightEdges?: Set | null; + /** Path-ordered node ids of a traced shortest path; its edges paint lime */ + pathIds: string[] | null; + /** nodeId -> community index; communities paint soft halos behind users (advanced) */ + communities: Map | null; + /** community index -> caption (dominant tag label) */ + communityLabels: Map; + /** Advanced lens: edge labels, count chips, curvature, arrowheads, and edge popover interactivity */ + edgeChipsOn?: boolean; + /** Single click / tap on a node */ + onNodeClick: (id: string) => void; + /** Double click / double tap on a node */ + onNodeExpand: (id: string) => void; + onBackgroundClick: () => void; + /** Click on an edge (used for aggregated tag-edge popovers; advanced lens only) */ + onLinkClick?: (edge: SocialGraphVisualEdge, screen: { x: number; y: number }) => void; + /** Hover intent on a user node: node + its current screen position, or null on leave */ + onUserHover?: (node: NexusGraphNode | null, screen: { x: number; y: number } | null) => void; + className?: string; +} diff --git a/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx new file mode 100644 index 0000000000..c8c26233bf --- /dev/null +++ b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { SocialGraphNodePanel } from './SocialGraphNodePanel'; + +vi.mock('@/hooks/useIsFollowing/useIsFollowing', () => ({ + useIsFollowing: () => ({ isFollowing: false, isLoading: false }), +})); + +vi.mock('@/hooks/useFollowUser/useFollowUser', () => ({ + useFollowUser: () => ({ toggleFollow: vi.fn(), isUserLoading: () => false }), +})); + +const mockUseTtlSubscription = vi.fn().mockReturnValue({ ref: () => {}, isVisible: true }); +vi.mock('@/hooks/useTtlSubscription/useTtlSubscription', () => ({ + useTtlSubscription: (options: unknown) => mockUseTtlSubscription(options), +})); + +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: () => ({ currentUserPubky: 'viewerpubky' }), +})); + +vi.mock('@/controllers/file/file', () => ({ + FileController: { getAvatarUrl: vi.fn(() => 'https://cdn.example/avatar') }, +})); + +vi.mock('@/molecules/PostPreviewCard/PostPreviewCard', () => ({ + PostPreviewCard: ({ postId }: { postId: string }) =>
{postId}
, +})); + +vi.mock('../DialogReply/DialogReply', () => ({ + DialogReply: () => null, +})); + +const baseProps = { + relationship: 'following' as const, + isExpanded: false, + isExpanding: false, + proofUsers: [{ pubky: 'proof1', name: 'Proof One', image: null }], + onProofHover: vi.fn(), + onExpand: vi.fn(), + onRefreshNode: vi.fn(), + onFocus: vi.fn(), + onTracePath: vi.fn(), + isTracing: false, + onClose: vi.fn(), +}; + +describe('SocialGraphNodePanel', () => { + it('renders a user card with follow, focus, and expand actions', () => { + const node: NexusGraphNode = { kind: 'user', id: 'user:abc', pubky: 'abc', name: 'Alice', image: null }; + render(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + // The shared FollowButton molecule renders for other users + expect(screen.getByLabelText('Follow')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-expand"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-focus"]')).toBeInTheDocument(); + // Social proof strip and trace-path action render for other users + expect(document.querySelector('[data-cy="graph-panel-proof"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-trace"]')).toBeInTheDocument(); + // The pinned profile subscribes to the TTL coordinator for freshness + expect(mockUseTtlSubscription).toHaveBeenCalledWith({ type: 'user', id: 'abc' }); + }); + + it('renders the real post preview with reply', () => { + const node: NexusGraphNode = { + kind: 'post', + id: 'post:abc:123', + author_id: 'abc', + post_id: '123', + content: 'Hello graph world', + post_kind: 'short', + is_reply: false, + indexed_at: 1719000000, + }; + render(); + + // The app's real post preview renders with the composite id + expect(screen.getByTestId('post-preview')).toHaveTextContent('abc:123'); + expect(document.querySelector('[data-cy="graph-panel-reply"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-focus"]')).not.toBeInTheDocument(); + }); + + it('renders a tag card with the label and usage count', () => { + const node: NexusGraphNode = { kind: 'tag', id: 'tag:bitcoin', label: 'bitcoin', count: 12 }; + render(); + + expect(screen.getByText('bitcoin')).toBeInTheDocument(); + }); + + it('disables expand when the node is already expanded', () => { + const node: NexusGraphNode = { kind: 'tag', id: 'tag:x', label: 'x', count: 1 }; + render(); + + expect(document.querySelector('[data-cy="graph-panel-expand"]')).toBeDisabled(); + }); + + it('frosts the panel so its copy stays readable over the canvas', () => { + const node: NexusGraphNode = { kind: 'tag', id: 'tag:dev', label: 'dev', count: 113 }; + render(); + + expect(document.querySelector('[data-cy="graph-panel"]')).toHaveClass('backdrop-blur-md'); + }); + + it('labels a reply as such instead of a generic post', () => { + const node: NexusGraphNode = { + kind: 'post', + id: 'post:abc:124', + author_id: 'abc', + post_id: '124', + content: '@DZ!', + post_kind: 'short', + is_reply: true, + indexed_at: 1719000001, + }; + render(); + + // The kind label (the Reply action button also says "Reply") + expect(document.querySelector('[data-cy="graph-panel"] .uppercase')).toHaveTextContent('Reply'); + expect(screen.queryByText('Post')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx new file mode 100644 index 0000000000..2cebeea761 --- /dev/null +++ b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, MessageCircle, Route, Waypoints, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { APP_ROUTES, getUserProfileUrl, POST_ROUTES } from '@/app/routes'; +import { Button } from '@/atoms/Button/Button'; +import { Link } from '@/atoms/Link/Link'; +import { Tag } from '@/atoms/Tag/Tag'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { FileController } from '@/controllers/file/file'; +import { useFollowUser } from '@/hooks/useFollowUser/useFollowUser'; +import { useIsFollowing } from '@/hooks/useIsFollowing/useIsFollowing'; +import type { GraphRelationship } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { useTtlSubscription } from '@/hooks/useTtlSubscription/useTtlSubscription'; +import { cn, formatPublicKey } from '@/libs/utils/utils'; +import { AvatarGroup } from '@/molecules/AvatarGroup/AvatarGroup'; +import { FollowButton } from '@/molecules/FollowButton/FollowButton'; +import { PostPreviewCard } from '@/molecules/PostPreviewCard/PostPreviewCard'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { AvatarWithFallback } from '../AvatarWithFallback/AvatarWithFallback'; +import { DialogReply } from '../DialogReply/DialogReply'; +import type { SocialGraphNodePanelProps } from './SocialGraphNodePanel.types'; + +const RELATIONSHIP_DOT: Record = { + self: 'bg-brand', + friend: 'bg-(--chart-2)', + following: 'bg-(--chart-3)', + follower: 'bg-(--chart-1)', + extended: 'bg-muted-foreground', +}; + +/** + * SocialGraphNodePanel + * + * Kind-aware inspector for the selected graph node: user card with follow, + * social proof, trace-path and focus actions; post nodes render the app's + * real post preview with reply-in-place; tags get their summary. + */ +export function SocialGraphNodePanel({ + node, + relationship, + isExpanded, + isExpanding, + proofUsers, + onProofHover, + onExpand, + onRefreshNode, + onFocus, + onTracePath, + isTracing, + onClose, + className, +}: SocialGraphNodePanelProps) { + const t = useTranslations('graph'); + const { currentUserPubky } = useAuthStore(); + const targetPubky = node.kind === 'user' ? node.pubky : ''; + const { isFollowing } = useIsFollowing(targetPubky); + const { toggleFollow, isUserLoading } = useFollowUser(); + const [replyOpen, setReplyOpen] = useState(false); + // Keep the pinned profile fresh while it is on screen, like other user + // surfaces do (posts get the same treatment inside PostPreviewCard) + const { ref: ttlRef } = useTtlSubscription({ type: 'user', id: targetPubky }); + + const expandButton = ( + + ); + + return ( +
+
+ + {t(node.kind === 'post' && node.is_reply ? 'legend.reply' : `legend.${node.kind}`)} + + +
+ + {node.kind === 'user' && ( + <> +
+ +
+ + {node.name || formatPublicKey({ key: node.pubky })} + + + {formatPublicKey({ key: node.pubky })} + +
+ + + {t(`legend.${relationship}`)} + +
+
+
+ + {proofUsers.length > 0 && ( + + )} + +
+ {expandButton} + +
+ +
+ {currentUserPubky && currentUserPubky !== node.pubky && ( + toggleFollow(node.pubky, isFollowing, node.name)} + /> + )} + +
+ + {currentUserPubky && currentUserPubky !== node.pubky && ( + + )} + + )} + + {node.kind === 'post' && ( + <> + +
+ {expandButton} + +
+ + { + setReplyOpen(open); + // Refresh on close: if a reply was posted it pops into the graph + if (!open) onRefreshNode(node.id); + }} + /> + + )} + + {node.kind === 'tag' && ( + <> +
+ + + {t('panel.tagUsage', { count: node.count })} + +
+
+ {expandButton} + +
+ + )} +
+ ); +} diff --git a/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts new file mode 100644 index 0000000000..5fe6bad835 --- /dev/null +++ b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts @@ -0,0 +1,31 @@ +import type { GraphRelationship } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { Pubky } from '@/models/models.types'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; + +export type SocialProofUser = { + pubky: Pubky; + name: string; + image: string | null; +}; + +export interface SocialGraphNodePanelProps { + node: NexusGraphNode; + relationship: GraphRelationship; + /** Whether the node's neighborhood is already merged into the view */ + isExpanded: boolean; + isExpanding: boolean; + /** People the viewer follows who follow this user (from edges already on canvas) */ + proofUsers: SocialProofUser[]; + /** Spotlight the proof connections on the canvas while hovering the strip */ + onProofHover: (hovering: boolean) => void; + onExpand: (nodeId: string) => void; + /** Re-fetch a node's neighborhood (used after replying from the panel) */ + onRefreshNode: (nodeId: string) => void; + /** Re-derive relationship colors around a user node (user nodes only) */ + onFocus: (nodeId: string) => void; + /** Trace the shortest follow path from the viewer to this user */ + onTracePath: (pubky: Pubky) => void; + isTracing: boolean; + onClose: () => void; + className?: string; +} diff --git a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx index b102d5ee36..65e21ce871 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/hooks/useFeedLayoutResolution/useFeedLayoutResolution', () => ({ effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: true, isPhoneViewport: false, }), diff --git a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx index 875f756cd9..83f9fa666f 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx @@ -68,6 +68,7 @@ vi.mock('@/hooks/useFeedLayoutResolution/useFeedLayoutResolution', () => ({ effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, })), @@ -207,6 +208,7 @@ const visualLayoutResolution = { effectiveLayout: 'visual' as const, isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }; @@ -216,6 +218,7 @@ const phoneColumnsLayoutResolution = { effectiveLayout: 'columns' as const, isVisualRequested: true, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: true, }; @@ -225,6 +228,7 @@ const columnsLayoutResolution = { effectiveLayout: 'columns' as const, isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }; @@ -288,6 +292,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -335,6 +340,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -351,6 +357,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'columns', isVisualRequested: true, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: true, }); @@ -371,6 +378,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -390,6 +398,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -407,6 +416,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -471,6 +481,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: true, isPhoneViewport: false, }); @@ -529,6 +540,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); diff --git a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx index eba805c08e..a7d68e376d 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx @@ -139,6 +139,7 @@ const gridLayoutResolution: FeedLayoutResolution = { effectiveLayout: LAYOUT.COLUMNS, isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: true, isPhoneViewport: false, }; @@ -149,6 +150,7 @@ const visualGridLayoutResolution: FeedLayoutResolution = { effectiveLayout: LAYOUT.VISUAL, isVisualRequested: true, isVisualActive: true, + isGraphActive: false, }; const mockLoadMore = vi.fn(); diff --git a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx index 780609db51..974e0c7a25 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx @@ -17,6 +17,7 @@ import { PostMainLayoutProvider } from '@/organisms/PostMain/PostMainLayoutConte import { buildFeedKey } from '@/stores/feedOptimistic/feedOptimistic.types'; import { TimelineGridPosts } from '../../Posts/GridPosts/GridPosts'; import { TimelinePosts } from '../../Posts/Posts'; +import { StreamGraphPosts } from '../../Posts/StreamGraphPosts/StreamGraphPosts'; import { NewPostsSection } from '../NewPostsSection/NewPostsSection'; import type { TimelineFeedContextValue, TimelineFeedProps } from '../TimelineFeed/TimelineFeed.types'; import { TimelineFeedContext } from '../TimelineFeed/TimelineFeedContext'; @@ -117,6 +118,7 @@ function TimelineFeedContent({ const previousMutedUserIdSetRef = useRef | null>(null); const isVisualActive = layoutResolution?.isVisualActive ?? false; + const isGraphActive = layoutResolution?.isGraphActive ?? false; const isGridActive = layoutResolution?.isGridActive ?? false; const { postIds: rawPostIds, @@ -203,7 +205,7 @@ function TimelineFeedContent({ }; const showGridEndMessage = variant !== TIMELINE_FEED_VARIANT.COLLECTION && variant !== TIMELINE_FEED_VARIANT.BOOKMARKS; - const shouldRenderChildren = !isVisualActive || isGridActive; + const shouldRenderChildren = (!isVisualActive && !isGraphActive) || isGridActive; return ( @@ -231,6 +233,14 @@ function TimelineFeedContent({ emptyState={emptyState} trailingSlot={gridTrailingSlot} /> + ) : isGraphActive ? ( + ) : isVisualActive ? ( = {}; + +vi.mock('@/hooks/useStreamGraph/useStreamGraph', () => ({ + useStreamGraph: () => ({ + nodes: [{ kind: 'user', id: 'user:a', pubky: 'a', name: 'Alice', image: null }], + edges: [], + relationships: new Map([['user:a', 'extended']]), + opacityTiers: new Map([['user:a', 'other']]), + sizeTiers: new Map([['user:a', 'other']]), + classCounts: new Map([['extended', 1]]), + focusId: null, + selectedNode: null, + expandedIds: new Set(), + pathIds: null, + timeBounds: { min: 1, max: 2 }, + timelineStamps: [1, 2], + timeCap: null, + declutter: false, + hiddenClasses: new Set(), + isExpanding: false, + isTracing: false, + select: vi.fn(), + expand: vi.fn(), + refreshNode: vi.fn(), + recenter: vi.fn(), + addTag: vi.fn(), + tracePath: vi.fn(), + clearPath: vi.fn(), + toggleClass: vi.fn(), + toggleDeclutter: vi.fn(), + setTimeCap: vi.fn(), + ...baseGraph, + }), +})); + +vi.mock('@/organisms/SocialGraph/SocialGraph', () => ({ + SocialGraph: () =>
, +})); + +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: () => ({ currentUserPubky: null }), +})); + +const props = { + postIds: ['a:1'], + loading: false, + loadingMore: false, + hasMore: true, + loadMore: vi.fn(), +}; + +describe('StreamGraphPosts', () => { + it('renders the canvas, the design pill controls, and the load-more pill', () => { + render(); + + expect(screen.getByTestId('canvas-stub')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="stream-graph"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-controls"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-zoom-in"]')).toBeInTheDocument(); + // The legend lives behind the advanced popover now, not on the canvas + expect(document.querySelector('[data-cy="graph-legend"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-advanced"]')).toBeInTheDocument(); + // Signed out: no recenter pill + expect(document.querySelector('[data-cy="graph-recenter"]')).not.toBeInTheDocument(); + + fireEvent.click(document.querySelector('[data-cy="stream-graph-load-more"]')!); + expect(props.loadMore).toHaveBeenCalled(); + }); + + it('hides load-more when the stream is exhausted', () => { + render(); + expect(document.querySelector('[data-cy="stream-graph-load-more"]')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx b/src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx new file mode 100644 index 0000000000..8c019b70f2 --- /dev/null +++ b/src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx @@ -0,0 +1,358 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Loader2, StickyNote, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Spinner } from '@/atoms/Spinner/Spinner'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GRAPH_PILL_CLASS } from '@/config/theme'; +import { useFullscreenToggle } from '@/hooks/useFullscreenToggle/useFullscreenToggle'; +import { useGraphDebug } from '@/hooks/useGraphDebug/useGraphDebug'; +import type { HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { socialProof } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { useStreamGraph } from '@/hooks/useStreamGraph/useStreamGraph'; +import { useTrackedPoint } from '@/hooks/useTrackedPoint/useTrackedPoint'; +import { cn } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import { GraphTimeMachine } from '@/molecules/GraphTimeMachine/GraphTimeMachine'; +import { SocialGraphAdvancedPanel } from '@/molecules/SocialGraphAdvancedPanel/SocialGraphAdvancedPanel'; +import { SocialGraphControls } from '@/molecules/SocialGraphControls/SocialGraphControls'; +import { SocialGraphLegend } from '@/molecules/SocialGraphLegend/SocialGraphLegend'; +import { GraphUserHoverCard } from '@/organisms/GraphUserHoverCard/GraphUserHoverCard'; +import { SocialGraph } from '@/organisms/SocialGraph/SocialGraph'; +import type { SocialGraphHandle } from '@/organisms/SocialGraph/SocialGraph.types'; +import { SocialGraphNodePanel } from '@/organisms/SocialGraphNodePanel/SocialGraphNodePanel'; +import type { NexusGraphNode, NexusGraphUserNode } from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; + +export interface StreamGraphPostsProps { + postIds: string[]; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + className?: string; +} + +type HoverCard = { node: NexusGraphUserNode; x: number; y: number }; + +/** + * StreamGraphPosts + * + * The feed's graph layout: the current stream as a living constellation. + * Authors are avatar clusters with their profile-tag chips and post glyphs; + * the signed-in user always seeds the view. Load-more merges the next page + * in with birth pulses instead of appending rows. + */ +export function StreamGraphPosts({ + postIds, + loading, + loadingMore, + hasMore, + loadMore, + className, +}: StreamGraphPostsProps) { + const t = useTranslations('graph'); + const { currentUserPubky } = useAuthStore(); + const graph = useStreamGraph(postIds); + const canvasRef = useRef(null); + const { isFullscreen, toggleFullscreen } = useFullscreenToggle(() => canvasRef.current?.fit()); + const [spotlight, setSpotlight] = useState | null>(null); + const [timeMachineOn, setTimeMachineOn] = useState(false); + const [physicsPaused, setPhysicsPaused] = useState(false); + const [hoverCard, setHoverCard] = useState(null); + const hoverCloseTimer = useRef | null>(null); + // A recenter click flies the camera itself; the growth auto-fit below must + // not undo it when the expansion merge lands + const recenterAt = useRef(0); + + const { edgeChipsOn, tagHubsOn, toggleEdgeChips, toggleTagHubs } = useGraphStore(); + const meId = currentUserPubky ? `user:${currentUserPubky}` : null; + + // QA/debug surface for the cypress interaction audit (debug builds only) + const { focusId: graphFocusId, pathIds: graphPathIds } = graph; + useGraphDebug(canvasRef, { + focusId: useCallback(() => graphFocusId, [graphFocusId]), + pathIds: useCallback(() => graphPathIds, [graphPathIds]), + }); + + const proofUsers = useMemo(() => { + if (!meId || !graph.selectedNode || graph.selectedNode.kind !== 'user' || graph.selectedNode.id === meId) { + return []; + } + const ids = new Set(socialProof(meId, graph.selectedNode.id, graph.edges)); + return graph.nodes + .filter((n): n is Extract => n.kind === 'user' && ids.has(n.id)) + .map((n) => ({ pubky: n.pubky, name: n.name, image: n.image })); + }, [meId, graph.selectedNode, graph.edges, graph.nodes]); + + const spotlightClass = useCallback( + (cls: HideableClass | null) => { + if (!cls) { + setSpotlight(null); + return; + } + const members = new Set(); + for (const node of graph.nodes) { + const nodeClass = node.kind === 'user' ? (graph.relationships.get(node.id) ?? 'extended') : node.kind; + if (nodeClass === cls) members.add(node.id); + } + setSpotlight(members); + }, + [graph.nodes, graph.relationships], + ); + + // Re-fit the camera once each merged page settles, but only when the RAW + // graph grew: filter toggles and time-machine scrubs also change the visible + // count and must not yank the camera around. Recenter-driven growth is + // exempt: the click already flew the camera onto its target. + const prevRawCount = useRef(0); + useEffect(() => { + if (graph.rawNodeCount <= prevRawCount.current) { + prevRawCount.current = graph.rawNodeCount; + return; + } + prevRawCount.current = graph.rawNodeCount; + if (Date.now() - recenterAt.current < 3000) return; + const timer = setTimeout(() => canvasRef.current?.fit(), 1400); + return () => clearTimeout(timer); + }, [graph.rawNodeCount]); + + // Design click semantics: user click centers + focuses; a chip click + // expands its tag into the graph; posts/hubs keep the inspector panel + const { recenter, addTag, select: graphSelect } = graph; + const flyTimer = useRef | null>(null); + useEffect( + () => () => { + if (flyTimer.current) clearTimeout(flyTimer.current); + }, + [], + ); + const handleNodeClick = useCallback( + (id: string) => { + if (id.startsWith('user:')) { + setHoverCard(null); + recenterAt.current = Date.now(); + void recenter(id); + canvasRef.current?.centerOn(id); + return; + } + if (id.startsWith('ptag:')) { + const label = id.split(':').slice(2).join(':'); + if (!label) return; + recenterAt.current = Date.now(); + void addTag(label); + // Fly once the merge lands and the physics places the hub + if (flyTimer.current) clearTimeout(flyTimer.current); + flyTimer.current = setTimeout(() => canvasRef.current?.centerOn(`tag:${label}`), 900); + return; + } + graphSelect(id); + }, + [recenter, addTag, graphSelect], + ); + + const handleRecenterSelf = useCallback(() => { + if (!meId) return; + if (graph.nodes.some((n) => n.id === meId)) { + recenterAt.current = Date.now(); + void recenter(meId); + canvasRef.current?.centerOn(meId); + } + }, [meId, graph.nodes, recenter]); + + const handleUserHover = useCallback((node: NexusGraphNode | null, screen: { x: number; y: number } | null) => { + if (hoverCloseTimer.current) clearTimeout(hoverCloseTimer.current); + if (node && node.kind === 'user' && screen) { + setHoverCard({ node, x: screen.x, y: screen.y }); + } else { + hoverCloseTimer.current = setTimeout(() => setHoverCard(null), 250); + } + }, []); + + const handleTraceConnection = useCallback( + (pubky: string) => { + setHoverCard(null); + void graph.tracePath(pubky as Pubky); + }, + [graph], + ); + + const hoverNodeId = hoverCard?.node.id ?? null; + const computeHoverPoint = useCallback( + () => (hoverNodeId ? (canvasRef.current?.screenPositionOf(hoverNodeId) ?? null) : null), + [hoverNodeId], + ); + const hoverPoint = useTrackedPoint(hoverNodeId ? computeHoverPoint : null); + + const isEmpty = !loading && graph.nodes.length === 0; + + return ( +
+ { + graph.select(null); + graph.clearPath(); + setSpotlight(null); + }} + /> + + canvasRef.current?.zoomIn()} + onZoomOut={() => canvasRef.current?.zoomOut()} + timeMachineOn={timeMachineOn} + timeMachineAvailable={graph.timeBounds !== null} + onToggleTimeMachine={() => + setTimeMachineOn((prev) => { + if (prev) graph.setTimeCap(null); + return !prev; + }) + } + onRecenterSelf={meId ? handleRecenterSelf : undefined} + isFullscreen={isFullscreen} + onToggleFullscreen={toggleFullscreen} + advancedContent={ + undefined} + edgeChipsOn={edgeChipsOn} + onToggleEdgeChips={toggleEdgeChips} + tagHubsOn={tagHubsOn} + onToggleTagHubs={toggleTagHubs} + physicsPaused={physicsPaused} + onTogglePhysics={() => { + const next = !physicsPaused; + setPhysicsPaused(next); + canvasRef.current?.setPaused(next); + }} + onReleasePins={() => canvasRef.current?.releasePins()} + onFit={() => canvasRef.current?.fit()} + legend={ + + } + /> + } + /> + + {graph.pathIds && ( + + )} + + {timeMachineOn && graph.timeBounds && ( + setTimeMachineOn(false)} + /> + )} + + {graph.selectedNode && ( + undefined} + onExpand={graph.expand} + onRefreshNode={graph.refreshNode} + onFocus={(id) => canvasRef.current?.centerOn(id)} + onTracePath={graph.tracePath} + isTracing={graph.isTracing} + onClose={() => graph.select(null)} + /> + )} + + {hoverCard && ( + { + if (hoverCloseTimer.current) clearTimeout(hoverCloseTimer.current); + }} + onPointerLeave={() => setHoverCard(null)} + /> + )} + + {hasMore && !isEmpty && ( + + )} + + {(loading || isEmpty) && ( +
+ {loading ? ( + + ) : ( + + {t('stream.empty')} + + )} +
+ )} +
+ ); +} diff --git a/src/components/templates/Graph/Graph.tsx b/src/components/templates/Graph/Graph.tsx new file mode 100644 index 0000000000..c26ed42c72 --- /dev/null +++ b/src/components/templates/Graph/Graph.tsx @@ -0,0 +1,596 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { RotateCcw, Users, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { APP_ROUTES } from '@/app/routes'; +import { Button } from '@/atoms/Button/Button'; +import { Link } from '@/atoms/Link/Link'; +import { Spinner } from '@/atoms/Spinner/Spinner'; +import { Tag } from '@/atoms/Tag/Tag'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GRAPH_PILL_CLASS, GRAPH_SURFACE_CLASS } from '@/config/theme'; +import { useFullscreenToggle } from '@/hooks/useFullscreenToggle/useFullscreenToggle'; +import { useGraphDebug } from '@/hooks/useGraphDebug/useGraphDebug'; +import { useIsMobile } from '@/hooks/useIsMobile/useIsMobile'; +import { useSocialGraph } from '@/hooks/useSocialGraph/useSocialGraph'; +import type { HideableClass, TrailEntry } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { edgeKey, type SocialGraphVisualEdge, socialProof } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { useTrackedPoint } from '@/hooks/useTrackedPoint/useTrackedPoint'; +import { cn } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import { CanvasAnchoredPopover } from '@/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover'; +import { GraphBreadcrumbs } from '@/molecules/GraphBreadcrumbs/GraphBreadcrumbs'; +import { GraphSearch } from '@/molecules/GraphSearch/GraphSearch'; +import { GraphTimeMachine } from '@/molecules/GraphTimeMachine/GraphTimeMachine'; +import { MobileFooter } from '@/molecules/MobileFooter/MobileFooter'; +import { SocialGraphAdvancedPanel } from '@/molecules/SocialGraphAdvancedPanel/SocialGraphAdvancedPanel'; +import { SocialGraphControls } from '@/molecules/SocialGraphControls/SocialGraphControls'; +import { type EdgeLegendKind, SocialGraphLegend } from '@/molecules/SocialGraphLegend/SocialGraphLegend'; +import { GraphUserHoverCard } from '@/organisms/GraphUserHoverCard/GraphUserHoverCard'; +import { SocialGraph } from '@/organisms/SocialGraph/SocialGraph'; +import type { SocialGraphHandle } from '@/organisms/SocialGraph/SocialGraph.types'; +import { SocialGraphNodePanel } from '@/organisms/SocialGraphNodePanel/SocialGraphNodePanel'; +import type { NexusGraphNode, NexusGraphUserNode } from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; + +type TagEdgePopover = { labels: string[]; sourceId: string; targetId: string; x: number; y: number }; +type HoverCard = { node: NexusGraphUserNode; x: number; y: number }; + +/** + * Graph + * + * The graph explorer page: a full-bleed force-directed canvas of the social + * graph around a user (`?user=` deep link, else the signed-in user), + * with an interactive legend, breadcrumb trail, search-to-add, time machine, + * declutter and community lenses, and a kind-aware inspector panel. + */ +export function Graph() { + const t = useTranslations('graph'); + const tCommon = useTranslations('common'); + const searchParams = useSearchParams(); + const { currentUserPubky } = useAuthStore(); + const centerPubky = (searchParams.get('user') as Pubky | null) ?? currentUserPubky; + const graph = useSocialGraph(); + const canvasRef = useRef(null); + const { isFullscreen, toggleFullscreen } = useFullscreenToggle(() => canvasRef.current?.fit()); + // The positioned container overlays anchor against; canvas screen + // coordinates are relative to it + const pageRef = useRef(null); + const [spotlight, setSpotlight] = useState | null>(null); + const [edgeSpotlight, setEdgeSpotlight] = useState | null>(null); + const [physicsPaused, setPhysicsPaused] = useState(false); + const [timeMachineOn, setTimeMachineOn] = useState(false); + const [tagPopover, setTagPopover] = useState(null); + const [hoverCard, setHoverCard] = useState(null); + const hoverCloseTimer = useRef | null>(null); + const isMobile = useIsMobile(); + const { load } = graph; + + // QA/debug surface for the cypress interaction audit (debug builds only) + const { focusId: graphFocusId, pathIds: graphPathIds } = graph; + useGraphDebug(canvasRef, { + focusId: useCallback(() => graphFocusId, [graphFocusId]), + pathIds: useCallback(() => graphPathIds, [graphPathIds]), + }); + + useEffect(() => { + if (centerPubky) load(centerPubky); + }, [centerPubky, load]); + + // A search pick focuses, expands, and flies the camera onto the node. The + // fly is delayed so the merge lands and the physics assigns coordinates + // (centerOn no-ops on nodes without a position yet). + const flyTimer = useRef | null>(null); + const flyToNode = useCallback((nodeId: string) => { + if (flyTimer.current) clearTimeout(flyTimer.current); + flyTimer.current = setTimeout(() => canvasRef.current?.centerOn(nodeId), 900); + }, []); + useEffect( + () => () => { + if (flyTimer.current) clearTimeout(flyTimer.current); + }, + [], + ); + + const { addUser, addTag, expand } = graph; + const handlePickUser = useCallback( + async (pubky: Pubky) => { + const nodeId = `user:${pubky}`; + await addUser(pubky); + // Expands nodes that were already on the canvas; freshly added centers + // arrive with their neighborhood and no-op here + await expand(nodeId); + flyToNode(nodeId); + }, + [addUser, expand, flyToNode], + ); + const handlePickTag = useCallback( + async (label: string) => { + const nodeId = `tag:${label}`; + await addTag(label); + await expand(nodeId); + flyToNode(nodeId); + }, + [addTag, expand, flyToNode], + ); + + // Advanced lens preferences (design-off defaults) + const { edgeChipsOn, tagHubsOn, toggleEdgeChips, toggleTagHubs } = useGraphStore(); + + // Picks made in the global header search while on this page + const searchTarget = useGraphStore((state) => state.searchTarget); + useEffect(() => { + if (!searchTarget) return; + if (searchTarget.kind === 'user') void handlePickUser(searchTarget.pubky as Pubky); + else void handlePickTag(searchTarget.label); + useGraphStore.getState().clearSearchTarget(); + }, [searchTarget, handlePickUser, handlePickTag]); + + const meId = currentUserPubky ? `user:${currentUserPubky}` : null; + + // "Followed by ..." strip data, straight from edges already on the canvas + const proofUsers = useMemo(() => { + if (!meId || !graph.selectedNode || graph.selectedNode.kind !== 'user' || graph.selectedNode.id === meId) { + return []; + } + const ids = new Set(socialProof(meId, graph.selectedNode.id, graph.edges)); + return graph.nodes + .filter((n): n is Extract => n.kind === 'user' && ids.has(n.id)) + .map((n) => ({ pubky: n.pubky, name: n.name, image: n.image })); + }, [meId, graph.selectedNode, graph.edges, graph.nodes]); + + const spotlightClass = useCallback( + (cls: HideableClass | null) => { + setEdgeSpotlight(null); + if (!cls) { + setSpotlight(null); + return; + } + const members = new Set(); + for (const node of graph.nodes) { + const nodeClass = node.kind === 'user' ? (graph.relationships.get(node.id) ?? 'extended') : node.kind; + if (nodeClass === cls) members.add(node.id); + } + setSpotlight(members); + }, + [graph.nodes, graph.relationships], + ); + + // Edge rows of the legend spotlight matching edges plus their endpoints + const spotlightEdgeKind = useCallback( + (kind: EdgeLegendKind | null) => { + if (!kind) { + setEdgeSpotlight(null); + setSpotlight(null); + return; + } + const follows = graph.edges.filter( + (edge) => + (edge.type === 'FOLLOWS' || edge.type === 'FRIEND') && + edge.source !== graph.focusId && + edge.target !== graph.focusId, + ); + const keys = new Set(); + const endpoints = new Set(); + if (kind === 'fresh') { + const stamped = follows.filter((edge) => edge.indexed_at !== undefined); + let min = Infinity; + let max = -Infinity; + for (const edge of stamped) { + min = Math.min(min, edge.indexed_at!); + max = Math.max(max, edge.indexed_at!); + } + if (min < max) { + for (const edge of stamped) { + // Same normalization as the canvas ramp; spotlight the bright end + if ((edge.indexed_at! - min) / (max - min) >= 0.7) { + keys.add(edgeKey(edge)); + endpoints.add(edge.source); + endpoints.add(edge.target); + } + } + } + } else if (graph.communities) { + for (const edge of follows) { + const a = graph.communities.get(edge.source); + const b = graph.communities.get(edge.target); + if (a === undefined || b === undefined) continue; + if ((kind === 'intra') === (a === b)) { + keys.add(edgeKey(edge)); + endpoints.add(edge.source); + endpoints.add(edge.target); + } + } + } + setEdgeSpotlight(keys.size > 0 ? keys : null); + setSpotlight(endpoints.size > 0 ? endpoints : null); + }, + [graph.edges, graph.focusId, graph.communities], + ); + + const hasTies = useMemo( + () => + graph.edges.some( + (edge) => + (edge.type === 'FOLLOWS' || edge.type === 'FRIEND') && + edge.source !== graph.focusId && + edge.target !== graph.focusId, + ), + [graph.edges, graph.focusId], + ); + + const spotlightProof = useCallback( + (hovering: boolean) => { + setEdgeSpotlight(null); + if (!hovering || !meId || !graph.selectedNode) { + setSpotlight(null); + return; + } + const set = new Set([meId, graph.selectedNode.id]); + for (const user of proofUsers) set.add(`user:${user.pubky}`); + setSpotlight(set); + }, + [meId, graph.selectedNode, proofUsers], + ); + + const handleUserHover = useCallback((node: NexusGraphNode | null, screen: { x: number; y: number } | null) => { + if (hoverCloseTimer.current) clearTimeout(hoverCloseTimer.current); + if (node && node.kind === 'user' && screen) { + setHoverCard({ node, x: screen.x, y: screen.y }); + } else { + // Grace period so the pointer can travel from node to card + hoverCloseTimer.current = setTimeout(() => setHoverCard(null), 250); + } + }, []); + + // Design click semantics: a user click centers + focuses (and dismisses any + // hover card); a chip click expands its tag into the graph; posts and hubs + // keep the inspector panel. Touch has no hover card, so a second tap on the + // focused user opens the bottom-sheet panel instead. + const { recenter, select: graphSelect } = graph; + const handleNodeClick = useCallback( + (id: string) => { + if (id.startsWith('user:')) { + setHoverCard(null); + if (isMobile && graph.focusId === id) { + graphSelect(id); + return; + } + void recenter(id); + canvasRef.current?.centerOn(id); + return; + } + if (id.startsWith('ptag:')) { + const label = id.split(':').slice(2).join(':'); + if (label) void handlePickTag(label); + return; + } + graphSelect(id); + }, + [recenter, graphSelect, handlePickTag, isMobile, graph.focusId], + ); + + const handleRecenterSelf = useCallback(() => { + if (!currentUserPubky) return; + const nodeId = `user:${currentUserPubky}`; + if (graph.nodes.some((n) => n.id === nodeId)) { + void recenter(nodeId); + canvasRef.current?.centerOn(nodeId); + } else { + void handlePickUser(currentUserPubky); + } + }, [currentUserPubky, graph.nodes, recenter, handlePickUser]); + + const handleTraceConnection = useCallback( + (pubky: string) => { + setHoverCard(null); + void graph.tracePath(pubky as Pubky); + }, + [graph], + ); + + const handleLinkClick = useCallback((edge: SocialGraphVisualEdge, screen: { x: number; y: number }) => { + // Any tag edge is inspectable; single-label edges just show one pill + const labels = edge.labels ?? (edge.type === 'TAGGED' && edge.label ? [edge.label] : null); + if (labels && labels.length > 0) { + setTagPopover({ labels, sourceId: edge.source, targetId: edge.target, x: screen.x, y: screen.y }); + } + }, []); + + const handleHop = useCallback( + (entry: TrailEntry) => { + graph.focus(entry.id); + canvasRef.current?.centerOn(entry.id); + }, + [graph], + ); + + // A search-added graph counts as content even without a signed-in center + const hasContent = graph.nodes.length > 1; + const isEmpty = !graph.isLoading && !graph.error && !hasContent; + + // Tracked anchor points: overlays follow their canvas entity per frame + const hoverNodeId = hoverCard?.node.id ?? null; + const computeHoverPoint = useCallback( + () => (hoverNodeId ? (canvasRef.current?.screenPositionOf(hoverNodeId) ?? null) : null), + [hoverNodeId], + ); + const hoverPoint = useTrackedPoint(hoverNodeId ? computeHoverPoint : null); + + const tagSourceId = tagPopover?.sourceId ?? null; + const tagTargetId = tagPopover?.targetId ?? null; + const computeTagPoint = useCallback( + () => (tagSourceId && tagTargetId ? (canvasRef.current?.screenMidpointOf(tagSourceId, tagTargetId) ?? null) : null), + [tagSourceId, tagTargetId], + ); + const tagPoint = useTrackedPoint(tagSourceId ? computeTagPoint : null); + + const selectedNodeId = graph.selectedNode?.id ?? null; + const computeSelectedPoint = useCallback( + () => (selectedNodeId ? (canvasRef.current?.screenPositionOf(selectedNodeId) ?? null) : null), + [selectedNodeId], + ); + const selectedPoint = useTrackedPoint(selectedNodeId && !isMobile ? computeSelectedPoint : null); + + const renderNodePanel = (className: string) => + graph.selectedNode ? ( + { + graph.focus(id); + canvasRef.current?.centerOn(id); + }} + onTracePath={graph.tracePath} + isTracing={graph.isTracing} + onClose={() => graph.select(null)} + /> + ) : null; + + return ( + + ); +} diff --git a/src/config/theme.ts b/src/config/theme.ts index 8d34f8bb75..794f264882 100644 --- a/src/config/theme.ts +++ b/src/config/theme.ts @@ -19,6 +19,27 @@ export const COLORS = { /** Core black - matches --background CSS variable */ background: '#05050A', + /** + * Social graph canvas palette: hex mirrors of the OKLCH tokens in + * globals.css, for surfaces that cannot consume CSS variables (2D canvas). + */ + graph: { + /** Matches --brand */ + self: '#C8FF00', + /** Matches --chart-2 */ + friend: '#31E581', + /** Matches --chart-3 */ + following: '#4FD7E8', + /** Matches --chart-1 */ + follower: '#4B48E5', + /** Matches --muted-foreground */ + extended: '#89898F', + post: '#89898F', + edgeMuted: '#3B3B41', + label: '#FFFFFF', + /** Focus/selection halo - matches --brand */ + halo: '#C8FF00', + }, } as const; /** @@ -55,3 +76,18 @@ export type Breakpoint = keyof typeof BREAKPOINTS; export function getBreakpoint(breakpoint: Breakpoint): number { return BREAKPOINTS[breakpoint]; } + +/** + * Shared frosted-glass surface for panels floating over the graph canvas. + * One definition so a restyle is a single edit. + */ +export const GLASS_PANEL_CLASS = 'rounded-2xl border border-white/10 bg-black/40 backdrop-blur-md'; + +/** + * Graph design surfaces (Figma "Feed - Graph"): solid near-black panels with + * a hairline secondary border, and the 44x32 stadium control pills. + */ +export const GRAPH_SURFACE_CLASS = 'rounded-lg border border-secondary bg-background'; +export const GRAPH_PILL_CLASS = + 'h-8 w-11 rounded-full border border-secondary bg-white/[0.045] text-white hover:bg-white/10'; +export const GRAPH_PILL_ACTIVE_CLASS = 'bg-secondary text-[#D4D4DB] hover:bg-secondary'; diff --git a/src/core/application/graph/graph.test.ts b/src/core/application/graph/graph.test.ts new file mode 100644 index 0000000000..da75a47eca --- /dev/null +++ b/src/core/application/graph/graph.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GraphApplication } from '@/application/graph/graph'; +import { PostStreamApplication } from '@/application/stream/posts/post'; +import { UserStreamApplication } from '@/application/stream/users/users'; +import type { Pubky } from '@/models/models.types'; +import { LocalStreamPostsService } from '@/services/local/stream/posts/posts'; +import { LocalStreamUsersService } from '@/services/local/stream/users/users'; +import { NexusGraphService } from '@/services/nexus/graph/graph'; +import type { NexusGraph } from '@/services/nexus/graph/graph.types'; + +vi.mock('@/services/nexus/graph/graph', () => ({ + NexusGraphService: { neighborhood: vi.fn(), path: vi.fn() }, +})); +vi.mock('@/services/local/stream/users/users', () => ({ + LocalStreamUsersService: { getNotPersistedUsersInCache: vi.fn() }, +})); +vi.mock('@/services/local/stream/posts/posts', () => ({ + LocalStreamPostsService: { getNotPersistedPostsInCache: vi.fn() }, +})); +vi.mock('@/application/stream/users/users', () => ({ + UserStreamApplication: { fetchMissingUsersFromNexus: vi.fn() }, +})); +vi.mock('@/application/stream/posts/post', () => ({ + PostStreamApplication: { fetchMissingPostsFromNexus: vi.fn() }, +})); + +const VIEWER = 'viewer00000000000000000000000000000000000000000000000' as Pubky; +const ALICE = 'alice0000000000000000000000000000000000000000000000000' as Pubky; +const BOB = 'bob000000000000000000000000000000000000000000000000000' as Pubky; + +const GRAPH: NexusGraph = { + nodes: [ + { kind: 'user', id: `user:${ALICE}`, pubky: ALICE, name: 'Alice', image: null }, + { kind: 'user', id: `user:${BOB}`, pubky: BOB, name: 'Bob', image: null }, + { + kind: 'post', + id: `post:${ALICE}:0032ABC`, + author_id: ALICE, + post_id: '0032ABC', + content: 'hi', + post_kind: 'short', + is_reply: false, + indexed_at: 1, + }, + { kind: 'tag', id: 'tag:pubky', label: 'pubky', count: 3 }, + ], + edges: [], +}; + +/** Resolves once queued microtasks (the fire-and-forget ingestion) have run. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('GraphApplication', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(NexusGraphService.neighborhood).mockResolvedValue(GRAPH); + vi.mocked(NexusGraphService.path).mockResolvedValue(GRAPH); + vi.mocked(LocalStreamUsersService.getNotPersistedUsersInCache).mockResolvedValue([ALICE]); + vi.mocked(LocalStreamPostsService.getNotPersistedPostsInCache).mockResolvedValue([`${ALICE}:0032ABC`]); + vi.mocked(UserStreamApplication.fetchMissingUsersFromNexus).mockResolvedValue(undefined); + vi.mocked(PostStreamApplication.fetchMissingPostsFromNexus).mockResolvedValue(undefined); + }); + + it('fetchNeighborhood returns the graph and ingests cache-missed entities through the stream pipeline', async () => { + const result = await GraphApplication.fetchNeighborhood({ kind: 'user', id: ALICE }, VIEWER); + expect(result).toEqual(GRAPH); + await flush(); + + expect(LocalStreamUsersService.getNotPersistedUsersInCache).toHaveBeenCalledWith([ALICE, BOB]); + expect(LocalStreamPostsService.getNotPersistedPostsInCache).toHaveBeenCalledWith([`${ALICE}:0032ABC`]); + expect(UserStreamApplication.fetchMissingUsersFromNexus).toHaveBeenCalledWith({ + cacheMissUserIds: [ALICE], + viewerId: VIEWER, + }); + expect(PostStreamApplication.fetchMissingPostsFromNexus).toHaveBeenCalledWith({ + cacheMissPostIds: [`${ALICE}:0032ABC`], + viewerId: VIEWER, + }); + }); + + it('fetchPath ingests too', async () => { + await GraphApplication.fetchPath({ from: VIEWER, to: ALICE }); + await flush(); + expect(UserStreamApplication.fetchMissingUsersFromNexus).toHaveBeenCalled(); + }); + + it('skips the post fetch when everything is already persisted', async () => { + vi.mocked(LocalStreamUsersService.getNotPersistedUsersInCache).mockResolvedValue([]); + vi.mocked(LocalStreamPostsService.getNotPersistedPostsInCache).mockResolvedValue([]); + await GraphApplication.fetchNeighborhood({ kind: 'user', id: ALICE }); + await flush(); + expect(PostStreamApplication.fetchMissingPostsFromNexus).not.toHaveBeenCalled(); + }); + + it('never rejects the caller when ingestion fails', async () => { + vi.mocked(LocalStreamUsersService.getNotPersistedUsersInCache).mockRejectedValue(new Error('dexie down')); + const result = await GraphApplication.fetchNeighborhood({ kind: 'user', id: ALICE }); + expect(result).toEqual(GRAPH); + await flush(); + }); +}); diff --git a/src/core/application/graph/graph.ts b/src/core/application/graph/graph.ts new file mode 100644 index 0000000000..860a6c7f21 --- /dev/null +++ b/src/core/application/graph/graph.ts @@ -0,0 +1,72 @@ +import { PostStreamApplication } from '@/application/stream/posts/post'; +import { UserStreamApplication } from '@/application/stream/users/users'; +import { UserApplication } from '@/application/user/user'; +import { Logger } from '@/libs/logger/logger'; +import type { Pubky } from '@/models/models.types'; +import { buildCompositeId } from '@/models/models.utils'; +import { LocalStreamPostsService } from '@/services/local/stream/posts/posts'; +import { LocalStreamUsersService } from '@/services/local/stream/users/users'; +import { NexusGraphService } from '@/services/nexus/graph/graph'; +import type { NexusGraph, TGraphNeighborhoodParams, TGraphPathParams } from '@/services/nexus/graph/graph.types'; + +export class GraphApplication { + private constructor() {} + + /** + * Fetch a neighborhood graph from Nexus. + * + * Graph topology is ephemeral view state (the canvas merges and prunes it + * client-side), so unlike other domains there is no Dexie table behind this; + * the entities the payload references are backfilled into the local cache in + * the background so selection/hover surfaces read them locally. + */ + static async fetchNeighborhood(params: TGraphNeighborhoodParams, viewerId?: Pubky | null): Promise { + const graph = await NexusGraphService.neighborhood(params); + void this.ingestGraphEntities(graph, viewerId); + return graph; + } + + /** Shortest FOLLOWS path between two users; see NexusGraphService.path */ + static async fetchPath(params: TGraphPathParams, viewerId?: Pubky | null): Promise { + const graph = await NexusGraphService.path(params); + void this.ingestGraphEntities(graph, viewerId); + return graph; + } + + /** + * Backfill Dexie with the full entities behind a graph payload, fire and + * forget. The payload rows are partial (no bio, links or counts) so they are + * never upserted directly; the ids funnel through the stream ingestion + * pipeline instead, which persists details, counts, tags, relationships and + * TTL in one shot. Ghost post nodes get hydrated the same way. + */ + private static async ingestGraphEntities(graph: NexusGraph, viewerId?: Pubky | null): Promise { + try { + const userIds: Pubky[] = []; + const postIds: string[] = []; + for (const node of graph.nodes) { + if (node.kind === 'user') userIds.push(node.pubky); + else if (node.kind === 'post') postIds.push(buildCompositeId({ pubky: node.author_id, id: node.post_id })); + } + const [cacheMissUserIds, cacheMissPostIds] = await Promise.all([ + LocalStreamUsersService.getNotPersistedUsersInCache(userIds), + LocalStreamPostsService.getNotPersistedPostsInCache(postIds), + ]); + await Promise.all([ + // No-ops internally on an empty id list + UserStreamApplication.fetchMissingUsersFromNexus({ cacheMissUserIds, viewerId: viewerId ?? undefined }), + cacheMissPostIds.length > 0 + ? PostStreamApplication.fetchMissingPostsFromNexus({ cacheMissPostIds, viewerId }) + : Promise.resolve(), + ]); + // Users persisted earlier through the details-only path have no user_tags + // row (the stream miss-check above is details-based), so the canvas would + // render them without profile-tag chips forever. Runs after the stream + // ingestion so freshly persisted tags are not re-fetched; does its own + // tags-table miss check internally. + await UserApplication.getManyTagsOrFetch({ userIds }); + } catch (error) { + Logger.warn('GraphApplication: failed to ingest graph entities', { error }); + } + } +} diff --git a/src/core/application/user/user.ts b/src/core/application/user/user.ts index d4dda3219f..beb8efc56b 100644 --- a/src/core/application/user/user.ts +++ b/src/core/application/user/user.ts @@ -286,6 +286,18 @@ export class UserApplication { return await LocalUserService.readBulkTags({ userIds }); } + /** + * Bulk read user tags from the local cache only, never the network. + * The graph canvas reads this from a live query on every recompute; gaps are + * filled by the ingestion pipeline, not the reader. + * @param userIds - Array of user IDs to read tags for + * @returns Promise resolving to a Map of user ID to tags array + */ + static async getManyTags({ userIds }: TPubkyListParams): Promise> { + if (userIds.length === 0) return new Map(); + return await LocalUserService.readBulkTags({ userIds }); + } + /** * Fetch missing user tags from Nexus API and persist to cache. * @param cacheMissUserIds - Array of user IDs that need tags fetched diff --git a/src/core/controllers/graph/graph.ts b/src/core/controllers/graph/graph.ts new file mode 100644 index 0000000000..2625ef9263 --- /dev/null +++ b/src/core/controllers/graph/graph.ts @@ -0,0 +1,27 @@ +import { GraphApplication } from '@/application/graph/graph'; +import type { Pubky } from '@/models/models.types'; +import type { NexusGraph, TGraphNeighborhoodParams, TGraphPathParams } from '@/services/nexus/graph/graph.types'; + +export class GraphController { + private constructor() {} // Prevent instantiation + + /** + * Fetch the neighborhood graph around a center entity (user, post, or tag) + * @param params - Center kind + id, plus optional depth/limit/kinds filters + * @param viewerId - Optional viewer for relationship data on the ingested entities + * @returns Nodes and edges around the center, ids kind-prefixed + */ + static async fetchNeighborhood(params: TGraphNeighborhoodParams, viewerId?: Pubky | null): Promise { + return await GraphApplication.fetchNeighborhood(params, viewerId); + } + + /** + * Fetch the shortest FOLLOWS path between two users (max 6 hops) + * @param params - from/to pubkies + * @param viewerId - Optional viewer for relationship data on the ingested entities + * @returns Path graph; nodes are ordered along the path + */ + static async fetchPath(params: TGraphPathParams, viewerId?: Pubky | null): Promise { + return await GraphApplication.fetchPath(params, viewerId); + } +} diff --git a/src/core/controllers/user/user.ts b/src/core/controllers/user/user.ts index 25c18505f3..c750630112 100644 --- a/src/core/controllers/user/user.ts +++ b/src/core/controllers/user/user.ts @@ -164,6 +164,14 @@ export class UserController { return await UserApplication.getManyTagsOrFetch(params); } + /** + * Get multiple user tags from the local cache only (bulk operation). + * Safe for live queries: never fires network. + */ + static async getManyTags(params: TPubkyListParams): Promise> { + return await UserApplication.getManyTags(params); + } + /** * Commit a follow action to indexeddb and the homeserver * @param eventType - The event type (PUT or DELETE) diff --git a/src/core/services/nexus/graph/graph.api.ts b/src/core/services/nexus/graph/graph.api.ts new file mode 100644 index 0000000000..abd3cdccdc --- /dev/null +++ b/src/core/services/nexus/graph/graph.api.ts @@ -0,0 +1,27 @@ +import { + GRAPH_PATH_PARAMS, + type TGraphNeighborhoodParams, + type TGraphPathParams, +} from '@/services/nexus/graph/graph.types'; +import { buildNexusUrl, buildUrlWithQuery, encodePathSegment } from '@/services/nexus/nexus.utils'; + +/** + * Graph API Endpoints + * + * Typed neighborhood graphs (nodes + edges) for the graph explorer. + */ + +const PREFIX = 'v0/graph'; + +export const graphApi = { + neighborhood: (params: TGraphNeighborhoodParams) => { + const id = encodePathSegment(params.id); + return buildUrlWithQuery({ + baseRoute: `${PREFIX}/${params.kind}/${id}`, + params, + excludeKeys: [...GRAPH_PATH_PARAMS], + }); + }, + path: (params: TGraphPathParams) => + buildNexusUrl(`${PREFIX}/path/${encodePathSegment(params.from)}/${encodePathSegment(params.to)}`), +}; diff --git a/src/core/services/nexus/graph/graph.test.ts b/src/core/services/nexus/graph/graph.test.ts new file mode 100644 index 0000000000..3a4bb67445 --- /dev/null +++ b/src/core/services/nexus/graph/graph.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNexusUrl } from '@/config/nexus'; +import { NexusGraphService } from '@/services/nexus/graph/graph'; +import { graphApi } from '@/services/nexus/graph/graph.api'; +import type { NexusGraph } from '@/services/nexus/graph/graph.types'; +import { fetchNexus } from '@/services/nexus/nexus.utils'; + +vi.mock('@/services/nexus/nexus.utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchNexus: vi.fn(), + }; +}); + +const mockFetchNexus = vi.mocked(fetchNexus); + +const testPubky = 'qr3xqyz3e5cyf9npgxc5zfp15ehhcis6gqsxob4une7bwwazekry'; + +describe('Graph API', () => { + it('builds a plain neighborhood URL from kind + id', () => { + const url = graphApi.neighborhood({ kind: 'user', id: testPubky }); + expect(url).toBe(`${getNexusUrl()}/v0/graph/user/${testPubky}`); + }); + + it('appends query params and keeps path params out of the query string', () => { + const url = graphApi.neighborhood({ kind: 'user', id: testPubky, depth: 2, limit: 10, kinds: 'user' }); + expect(url).toContain(`/v0/graph/user/${testPubky}?`); + expect(url).toContain('depth=2'); + expect(url).toContain('limit=10'); + expect(url).toContain('kinds=user'); + expect(url).not.toContain('id='); + expect(url).not.toContain('kind='); + }); + + it('percent-encodes the id path segment', () => { + const url = graphApi.neighborhood({ kind: 'post', id: `${testPubky}:0032FNCGXE3R0` }); + expect(url).toContain(`/v0/graph/post/${encodeURIComponent(`${testPubky}:0032FNCGXE3R0`)}`); + }); +}); + +describe('Graph path API', () => { + it('builds the shortest-path URL', () => { + const url = graphApi.path({ from: 'aaa', to: 'bbb' }); + expect(url).toBe(`${getNexusUrl()}/v0/graph/path/aaa/bbb`); + }); +}); + +describe('NexusGraphService', () => { + beforeEach(() => { + mockFetchNexus.mockReset(); + }); + + it('fetches the neighborhood through fetchNexus and returns it', async () => { + const graph: NexusGraph = { + nodes: [{ kind: 'user', id: `user:${testPubky}`, pubky: testPubky, name: 'Aldert', image: null }], + edges: [], + }; + mockFetchNexus.mockResolvedValueOnce(graph); + + const result = await NexusGraphService.neighborhood({ kind: 'user', id: testPubky }); + + expect(mockFetchNexus).toHaveBeenCalledWith({ url: graphApi.neighborhood({ kind: 'user', id: testPubky }) }); + expect(result).toEqual(graph); + }); + + it('fetches a shortest path through fetchNexus', async () => { + const graph: NexusGraph = { nodes: [], edges: [] }; + mockFetchNexus.mockResolvedValueOnce(graph); + + const result = await NexusGraphService.path({ from: 'aaa', to: 'bbb' }); + + expect(mockFetchNexus).toHaveBeenCalledWith({ url: graphApi.path({ from: 'aaa', to: 'bbb' }) }); + expect(result).toEqual(graph); + }); +}); diff --git a/src/core/services/nexus/graph/graph.ts b/src/core/services/nexus/graph/graph.ts new file mode 100644 index 0000000000..7ef4def22d --- /dev/null +++ b/src/core/services/nexus/graph/graph.ts @@ -0,0 +1,37 @@ +import { graphApi } from '@/services/nexus/graph/graph.api'; +import type { NexusGraph, TGraphNeighborhoodParams, TGraphPathParams } from '@/services/nexus/graph/graph.types'; +import { fetchNexus } from '@/services/nexus/nexus.utils'; + +/** + * Nexus Graph Service + * + * Fetches typed neighborhood graphs around a user, post, or tag from + * `GET /v0/graph/{kind}/{id}` for the interactive graph explorer. + */ +export class NexusGraphService { + /** + * Retrieves the neighborhood graph around a center entity + * + * @param params - Center kind + id, plus optional depth/limit/kinds filters + * @returns Nodes and edges around the center, ids kind-prefixed + */ + static async neighborhood(params: TGraphNeighborhoodParams): Promise { + const url = graphApi.neighborhood(params); + // Plain fetch, not queryNexus: the nexus query client retries 404s for up + // to ~15s (indexing lag policy) and serves 20s-stale responses, both of + // which are wrong here: a missing center should error fast, and the + // reply-refresh flow needs the post-reply neighborhood, not a cached one. + return await fetchNexus({ url }); + } + + /** + * Retrieves the shortest FOLLOWS path between two users (max 6 hops) + * + * @param params - from/to pubkies + * @returns Path graph; nodes are ordered along the path + */ + static async path(params: TGraphPathParams): Promise { + const url = graphApi.path(params); + return await fetchNexus({ url }); + } +} diff --git a/src/core/services/nexus/graph/graph.types.ts b/src/core/services/nexus/graph/graph.types.ts new file mode 100644 index 0000000000..fcfea48b4b --- /dev/null +++ b/src/core/services/nexus/graph/graph.types.ts @@ -0,0 +1,78 @@ +import type { Pubky } from '@/models/models.types'; + +/** + * Graph Neighborhood API types + * + * Mirrors the Nexus `GET /v0/graph/{kind}/{id}` response: a typed node-link + * graph around a center entity. Node ids are kind-prefixed and globally + * unique: `user:{pubky}`, `post:{author}:{post_id}`, `tag:{label}`. + */ + +export type GraphNodeKind = 'user' | 'post' | 'tag'; + +export type NexusGraphUserNode = { + kind: 'user'; + id: string; + pubky: Pubky; + name: string; + image: string | null; +}; + +export type NexusGraphPostNode = { + kind: 'post'; + id: string; + author_id: Pubky; + post_id: string; + content: string; + post_kind: string; + /** Replies to a post outside the neighborhood carry no REPLIED edge, so the flag travels with the node */ + is_reply: boolean; + indexed_at: number; +}; + +export type NexusGraphTagNode = { + kind: 'tag'; + id: string; + label: string; + count: number; +}; + +export type NexusGraphNode = NexusGraphUserNode | NexusGraphPostNode | NexusGraphTagNode; + +export type NexusGraphEdgeType = 'FOLLOWS' | 'AUTHORED' | 'TAGGED' | 'REPLIED' | 'REPOSTED' | 'MENTIONED'; + +export type NexusGraphEdge = { + source: string; + target: string; + type: NexusGraphEdgeType; + /** Present only on TAGGED edges */ + label?: string; + /** When the relationship was indexed; drives the client time filters */ + indexed_at?: number; +}; + +export type NexusGraph = { + nodes: NexusGraphNode[]; + edges: NexusGraphEdge[]; +}; + +export type TGraphNeighborhoodParams = { + kind: GraphNodeKind; + /** pubky | `{author}:{post_id}` | tag label */ + id: string; + /** FOLLOWS hops around a user center, 1..2 (user kind only) */ + depth?: 1 | 2; + /** Per-class neighbor cap, 1..50 */ + limit?: number; + /** CSV filter of node kinds to include, e.g. 'user' or 'user,post,tag' */ + kinds?: string; +}; + +export const GRAPH_PATH_PARAMS = ['kind', 'id'] as const; + +export type TGraphPathParams = { + /** Starting user pubky */ + from: string; + /** Destination user pubky */ + to: string; +}; diff --git a/src/core/stores/graph/graph.actions.ts b/src/core/stores/graph/graph.actions.ts new file mode 100644 index 0000000000..8b895edbdb --- /dev/null +++ b/src/core/stores/graph/graph.actions.ts @@ -0,0 +1,49 @@ +import { ZustandSet } from '../stores.types'; +import { GraphActions, GraphActionTypes, graphInitialState, GraphStore } from './graph.types'; + +// Actions/Mutators - State modification functions +export const createGraphActions = (set: ZustandSet): GraphActions => ({ + setDeclutter: (declutter) => { + set({ declutter }, false, GraphActionTypes.SET_GRAPH_DECLUTTER); + }, + + toggleDeclutter: () => { + set((state) => ({ declutter: !state.declutter }), false, GraphActionTypes.TOGGLE_GRAPH_DECLUTTER); + }, + + toggleClass: (cls) => { + set( + (state) => ({ + hiddenClasses: state.hiddenClasses.includes(cls) + ? state.hiddenClasses.filter((hidden) => hidden !== cls) + : [...state.hiddenClasses, cls], + }), + false, + GraphActionTypes.TOGGLE_GRAPH_CLASS, + ); + }, + + toggleCommunities: () => { + set((state) => ({ communitiesOn: !state.communitiesOn }), false, GraphActionTypes.TOGGLE_GRAPH_COMMUNITIES); + }, + + toggleTagHubs: () => { + set((state) => ({ tagHubsOn: !state.tagHubsOn }), false, GraphActionTypes.TOGGLE_GRAPH_TAG_HUBS); + }, + + toggleEdgeChips: () => { + set((state) => ({ edgeChipsOn: !state.edgeChipsOn }), false, GraphActionTypes.TOGGLE_GRAPH_EDGE_CHIPS); + }, + + requestSearch: (target) => { + set({ searchTarget: target }, false, GraphActionTypes.REQUEST_GRAPH_SEARCH); + }, + + clearSearchTarget: () => { + set({ searchTarget: null }, false, GraphActionTypes.CLEAR_GRAPH_SEARCH_TARGET); + }, + + reset: () => { + set(graphInitialState, false, GraphActionTypes.RESET_GRAPH); + }, +}); diff --git a/src/core/stores/graph/graph.store.test.ts b/src/core/stores/graph/graph.store.test.ts new file mode 100644 index 0000000000..29ba60e1b9 --- /dev/null +++ b/src/core/stores/graph/graph.store.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useGraphStore } from './graph.store'; +import { graphInitialState } from './graph.types'; + +describe('GraphStore', () => { + beforeEach(() => { + useGraphStore.getState().reset(); + }); + + it('initializes with everything visible and modes off', () => { + const state = useGraphStore.getState(); + expect(state.declutter).toBe(graphInitialState.declutter); + expect(state.hiddenClasses).toEqual([]); + expect(state.communitiesOn).toBe(false); + }); + + it('toggles declutter and communities', () => { + useGraphStore.getState().toggleDeclutter(); + expect(useGraphStore.getState().declutter).toBe(true); + useGraphStore.getState().toggleCommunities(); + expect(useGraphStore.getState().communitiesOn).toBe(true); + useGraphStore.getState().toggleDeclutter(); + expect(useGraphStore.getState().declutter).toBe(false); + }); + + it('setDeclutter sets an absolute value (auto-declutter path)', () => { + useGraphStore.getState().setDeclutter(true); + expect(useGraphStore.getState().declutter).toBe(true); + useGraphStore.getState().setDeclutter(true); + expect(useGraphStore.getState().declutter).toBe(true); + }); + + it('toggleClass adds then removes a hidden class', () => { + useGraphStore.getState().toggleClass('post'); + useGraphStore.getState().toggleClass('follower'); + expect(useGraphStore.getState().hiddenClasses).toEqual(['post', 'follower']); + useGraphStore.getState().toggleClass('post'); + expect(useGraphStore.getState().hiddenClasses).toEqual(['follower']); + }); + + it('hands a header-search pick to the graph and clears it after consumption', () => { + useGraphStore.getState().requestSearch({ kind: 'user', pubky: 'abc' }); + expect(useGraphStore.getState().searchTarget).toEqual({ kind: 'user', pubky: 'abc' }); + useGraphStore.getState().clearSearchTarget(); + expect(useGraphStore.getState().searchTarget).toBeNull(); + }); + + it('reset restores every preference', () => { + useGraphStore.getState().setDeclutter(true); + useGraphStore.getState().toggleClass('tag'); + useGraphStore.getState().toggleCommunities(); + useGraphStore.getState().reset(); + const state = useGraphStore.getState(); + expect(state.declutter).toBe(false); + expect(state.hiddenClasses).toEqual([]); + expect(state.communitiesOn).toBe(false); + }); +}); diff --git a/src/core/stores/graph/graph.store.ts b/src/core/stores/graph/graph.store.ts new file mode 100644 index 0000000000..92e9aca467 --- /dev/null +++ b/src/core/stores/graph/graph.store.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; +import { GRAPH_PERSIST_KEY } from '../persistedKeys'; +import { createGraphActions } from './graph.actions'; +import { GRAPH_NODE_CLASSES, graphInitialState, type GraphNodeClass, GraphStore } from './graph.types'; + +// Store creation +export const useGraphStore = create()( + devtools( + persist( + (set) => ({ + ...graphInitialState, + ...createGraphActions(set), + }), + { + name: GRAPH_PERSIST_KEY, + // v1 adds tagHubsOn/edgeChipsOn; migration is purely additive (the + // merge below lets missing keys fall back to initial state), the bump + // documents the shape change and guards future renames + version: 1, + migrate: (persisted) => persisted as GraphStore, + // Explicit toggles survive reloads; declutter deliberately does not, + // because the dense-graph heuristic flips it automatically and an + // automatic writer must not overwrite a stored user preference. + // Transient state (time cap, selection) stays in the hooks. + partialize: (state) => ({ + hiddenClasses: state.hiddenClasses, + communitiesOn: state.communitiesOn, + tagHubsOn: state.tagHubsOn, + edgeChipsOn: state.edgeChipsOn, + }), + // Drop class names that no longer exist after a rename/removal so + // nothing stays hidden with no legend row to unhide it + merge: (persisted, current) => { + const stored = (persisted ?? {}) as Partial; + return { + ...current, + ...stored, + hiddenClasses: (stored.hiddenClasses ?? []).filter((cls): cls is GraphNodeClass => + (GRAPH_NODE_CLASSES as readonly string[]).includes(cls), + ), + }; + }, + }, + ), + { + name: 'graph-store', + enabled: process.env.NODE_ENV === 'development', + }, + ), +); diff --git a/src/core/stores/graph/graph.types.ts b/src/core/stores/graph/graph.types.ts new file mode 100644 index 0000000000..776096acf7 --- /dev/null +++ b/src/core/stores/graph/graph.types.ts @@ -0,0 +1,61 @@ +// Graph view preference constants +export const GRAPH_NODE_CLASSES = ['self', 'friend', 'following', 'follower', 'extended', 'post', 'tag'] as const; + +/** Everything the graph legend can hide: relationship classes plus node kinds. */ +export type GraphNodeClass = (typeof GRAPH_NODE_CLASSES)[number]; + +/** A pick from the global header search, routed to the graph page. */ +export type GraphSearchTarget = { kind: 'user'; pubky: string } | { kind: 'tag'; label: string }; + +export interface GraphState { + /** Hide stale edges and low-signal nodes */ + declutter: boolean; + /** Legend classes currently hidden (array for persistence; hooks derive a Set) */ + hiddenClasses: GraphNodeClass[]; + /** Louvain community halos */ + communitiesOn: boolean; + /** Fetch shared tag hub nodes with neighborhoods (advanced; default view shows per-user chips only) */ + tagHubsOn: boolean; + /** Aggregated tag-edge count chips + edge popovers (advanced) */ + edgeChipsOn: boolean; + /** Pending header-search pick for the graph page to consume (transient, never persisted) */ + searchTarget: GraphSearchTarget | null; +} + +export interface GraphActions { + setDeclutter: (declutter: boolean) => void; + toggleDeclutter: () => void; + toggleClass: (cls: GraphNodeClass) => void; + toggleCommunities: () => void; + toggleTagHubs: () => void; + toggleEdgeChips: () => void; + /** Header search on the graph page hands its pick to the canvas */ + requestSearch: (target: GraphSearchTarget) => void; + clearSearchTarget: () => void; + reset: () => void; +} + +export type GraphStore = GraphState & GraphActions; + +// Initial state +export const graphInitialState: GraphState = { + declutter: false, + hiddenClasses: [], + communitiesOn: false, + tagHubsOn: false, + edgeChipsOn: false, + searchTarget: null, +}; + +// Action types for DevTools +export enum GraphActionTypes { + SET_GRAPH_DECLUTTER = 'SET_GRAPH_DECLUTTER', + TOGGLE_GRAPH_DECLUTTER = 'TOGGLE_GRAPH_DECLUTTER', + TOGGLE_GRAPH_CLASS = 'TOGGLE_GRAPH_CLASS', + TOGGLE_GRAPH_COMMUNITIES = 'TOGGLE_GRAPH_COMMUNITIES', + TOGGLE_GRAPH_TAG_HUBS = 'TOGGLE_GRAPH_TAG_HUBS', + TOGGLE_GRAPH_EDGE_CHIPS = 'TOGGLE_GRAPH_EDGE_CHIPS', + REQUEST_GRAPH_SEARCH = 'REQUEST_GRAPH_SEARCH', + CLEAR_GRAPH_SEARCH_TARGET = 'CLEAR_GRAPH_SEARCH_TARGET', + RESET_GRAPH = 'RESET_GRAPH', +} diff --git a/src/core/stores/home/home.types.ts b/src/core/stores/home/home.types.ts index 5fbe107aaf..d59ebb998e 100644 --- a/src/core/stores/home/home.types.ts +++ b/src/core/stores/home/home.types.ts @@ -3,6 +3,7 @@ export const LAYOUT = { COLUMNS: 'columns', WIDE: 'wide', VISUAL: 'visual', + GRAPH: 'graph', } as const; export const SORT = { diff --git a/src/core/stores/persistedKeys.ts b/src/core/stores/persistedKeys.ts index bbfabcbf37..d73e42b6ef 100644 --- a/src/core/stores/persistedKeys.ts +++ b/src/core/stores/persistedKeys.ts @@ -3,6 +3,7 @@ export const ONBOARDING_PERSIST_KEY = 'onboarding-storage'; export const NOTIFICATION_PERSIST_KEY = 'notification-store'; export const SEARCH_PERSIST_KEY = 'search-store'; export const HOME_PERSIST_KEY = 'home-store'; +export const GRAPH_PERSIST_KEY = 'graph-store'; export const HOT_PERSIST_KEY = 'hot-store'; export const SETTINGS_PERSIST_KEY = 'settings-storage'; export const MIGRATION_STORE_KEY = 'migration-store'; @@ -15,6 +16,7 @@ export const PERSISTED_STORE_KEYS = [ NOTIFICATION_PERSIST_KEY, SEARCH_PERSIST_KEY, HOME_PERSIST_KEY, + GRAPH_PERSIST_KEY, HOT_PERSIST_KEY, SETTINGS_PERSIST_KEY, ] as const; diff --git a/src/d3-force-3d.d.ts b/src/d3-force-3d.d.ts new file mode 100644 index 0000000000..3e73d1e99b --- /dev/null +++ b/src/d3-force-3d.d.ts @@ -0,0 +1,5 @@ +// Minimal surface of d3-force-3d (transitive dependency of force-graph, no +// bundled types); the graph canvas only pulls its collision force. +declare module 'd3-force-3d' { + export function forceCollide(radius?: number | ((node: unknown) => number)): unknown; +} diff --git a/src/hooks/useFeedLayoutResolution/useFeedLayoutResolution.ts b/src/hooks/useFeedLayoutResolution/useFeedLayoutResolution.ts index 6c3ae2f84d..4068fa3d08 100644 --- a/src/hooks/useFeedLayoutResolution/useFeedLayoutResolution.ts +++ b/src/hooks/useFeedLayoutResolution/useFeedLayoutResolution.ts @@ -18,6 +18,7 @@ export interface FeedLayoutResolution { effectiveLayout: LayoutType; isVisualRequested: boolean; isVisualActive: boolean; + isGraphActive: boolean; /** * Whether this variant renders its posts in a fixed card grid (decision D5). * Orthogonal to `effectiveLayout` — grid is variant-driven, not a `LayoutType`. @@ -41,8 +42,12 @@ export function resolveFeedLayout({ const isVisualSupported = !isPhoneViewport && RICH_LAYOUT_SUPPORTED_FEED_VARIANTS.has(variant); const isWideRequested = requestedLayout === LAYOUT.WIDE; const isWideSupported = RICH_LAYOUT_SUPPORTED_FEED_VARIANTS.has(variant); + // Graph shares visual's constraints: desktop-only, rich feed variants only + const isGraphRequested = requestedLayout === LAYOUT.GRAPH; const effectiveLayout = - (isVisualRequested && !isVisualSupported) || (isWideRequested && !isWideSupported) + (isVisualRequested && !isVisualSupported) || + (isGraphRequested && !isVisualSupported) || + (isWideRequested && !isWideSupported) ? LAYOUT.COLUMNS : requestedLayout; @@ -51,6 +56,7 @@ export function resolveFeedLayout({ effectiveLayout, isVisualRequested, isVisualActive: effectiveLayout === LAYOUT.VISUAL, + isGraphActive: effectiveLayout === LAYOUT.GRAPH, isGridActive: GRID_LAYOUT_VARIANTS.has(variant), isPhoneViewport, }; diff --git a/src/hooks/useFullscreenToggle/useFullscreenToggle.test.ts b/src/hooks/useFullscreenToggle/useFullscreenToggle.test.ts new file mode 100644 index 0000000000..71cc404770 --- /dev/null +++ b/src/hooks/useFullscreenToggle/useFullscreenToggle.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useFullscreenToggle } from './useFullscreenToggle'; + +describe('useFullscreenToggle', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.style.overflow = ''; + }); + + it('starts windowed and toggles', () => { + const { result } = renderHook(() => useFullscreenToggle()); + expect(result.current.isFullscreen).toBe(false); + + act(() => result.current.toggleFullscreen()); + expect(result.current.isFullscreen).toBe(true); + + act(() => result.current.toggleFullscreen()); + expect(result.current.isFullscreen).toBe(false); + }); + + it('locks body scroll while fullscreen and restores it after', () => { + document.body.style.overflow = 'auto'; + const { result } = renderHook(() => useFullscreenToggle()); + + act(() => result.current.toggleFullscreen()); + expect(document.body.style.overflow).toBe('hidden'); + + act(() => result.current.toggleFullscreen()); + expect(document.body.style.overflow).toBe('auto'); + }); + + it('exits on Escape unless another layer already handled the key', () => { + const { result } = renderHook(() => useFullscreenToggle()); + act(() => result.current.toggleFullscreen()); + + // A Radix popover/dialog consumes Escape in the capture phase + document.addEventListener('keydown', (event) => event.preventDefault(), { capture: true, once: true }); + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + }); + expect(result.current.isFullscreen).toBe(true); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + }); + expect(result.current.isFullscreen).toBe(false); + }); + + it('ignores Escape while windowed and drops the listener on unmount', () => { + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + const { result, unmount } = renderHook(() => useFullscreenToggle()); + expect(add.mock.calls.filter(([type]) => type === 'keydown')).toHaveLength(0); + + act(() => result.current.toggleFullscreen()); + expect(add.mock.calls.filter(([type]) => type === 'keydown')).toHaveLength(1); + + unmount(); + expect(remove.mock.calls.filter(([type]) => type === 'keydown')).toHaveLength(1); + }); + + it('reports each toggle two frames later, once the resized canvas has re-rendered', () => { + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => frames.push(cb)); + const flushFrame = () => { + const pending = frames.splice(0); + pending.forEach((cb) => cb(performance.now())); + }; + const onToggled = vi.fn(); + const { result } = renderHook(() => useFullscreenToggle(onToggled)); + flushFrame(); + flushFrame(); + // Mount is not a toggle + expect(onToggled).not.toHaveBeenCalled(); + + act(() => result.current.toggleFullscreen()); + expect(onToggled).not.toHaveBeenCalled(); + flushFrame(); + expect(onToggled).not.toHaveBeenCalled(); + flushFrame(); + expect(onToggled).toHaveBeenCalledTimes(1); + + act(() => result.current.toggleFullscreen()); + flushFrame(); + flushFrame(); + expect(onToggled).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/hooks/useFullscreenToggle/useFullscreenToggle.ts b/src/hooks/useFullscreenToggle/useFullscreenToggle.ts new file mode 100644 index 0000000000..ad38c64e34 --- /dev/null +++ b/src/hooks/useFullscreenToggle/useFullscreenToggle.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useBodyScrollLock } from '@/hooks/useBodyScrollLock/useBodyScrollLock'; + +/** + * Viewport-sized overlay mode for a canvas card. + * + * A CSS overlay rather than the Fullscreen API: layers portaled to body + * (popovers, dialogs, toasts) stay visible, and it works on iPhone. + * `onToggled` fires two frames after each toggle, once the wrapper has + * resized and the canvas has re-rendered at the new size, so callers can + * refit the camera. + */ +export function useFullscreenToggle(onToggled?: () => void) { + const [isFullscreen, setIsFullscreen] = useState(false); + const onToggledRef = useRef(onToggled); + const reportedRef = useRef(isFullscreen); + + useEffect(() => { + onToggledRef.current = onToggled; + }, [onToggled]); + + useBodyScrollLock(isFullscreen); + + const toggleFullscreen = useCallback(() => setIsFullscreen((on) => !on), []); + + // Escape exits, unless a popover or dialog layer already consumed the key + useEffect(() => { + if (!isFullscreen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.defaultPrevented) setIsFullscreen(false); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isFullscreen]); + + useEffect(() => { + if (reportedRef.current === isFullscreen) return; + reportedRef.current = isFullscreen; + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => onToggledRef.current?.()); + }); + return () => cancelAnimationFrame(frame); + }, [isFullscreen]); + + return { isFullscreen, toggleFullscreen }; +} diff --git a/src/hooks/useGraphCore/useGraphCore.ts b/src/hooks/useGraphCore/useGraphCore.ts new file mode 100644 index 0000000000..e5853212a7 --- /dev/null +++ b/src/hooks/useGraphCore/useGraphCore.ts @@ -0,0 +1,492 @@ +'use client'; + +import { type MutableRefObject, useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { GraphController } from '@/controllers/graph/graph'; +import { useGraphProfileTags } from '@/hooks/useGraphProfileTags/useGraphProfileTags'; +import { type HideableClass, MAX_CLIENT_NODES } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { + aggregateParallelEdges, + applyDeclutter, + applyPathExclusive, + applyTimeCap, + collapseMutualFollows, + deriveSatellites, + type GraphRelationship, + type GraphTier, + mergeGraph, + postTierCap, + pruneToBudget, + type SocialGraphVisualEdge, + tierOf, + type VisualGraphNode, +} from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { Logger } from '@/libs/logger/logger'; +import type { Pubky } from '@/models/models.types'; +import { toast } from '@/molecules/Toaster/use-toast'; +import type { + NexusGraph, + NexusGraphEdge, + NexusGraphNode, + TGraphNeighborhoodParams, +} from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; + +const EMPTY_GRAPH: NexusGraph = { nodes: [], edges: [] }; + +/** Simulation-facing transient fields force-graph and the painter live on. */ +export type SimNode = VisualGraphNode & { x?: number; y?: number; __bornAt?: number }; + +export type GraphCoreOptions = { + /** Error-log prefix, e.g. 'useSocialGraph' */ + logTag: string; + /** Prefixed id the time-cap exemption and default prune anchoring center on; a + * function form derives it from the current graph (feed layout: the viewer) */ + focusId: string | null | ((graph: NexusGraph) => string | null); + /** Fallback prune anchor for merges without an explicit anchor */ + resolveAnchor: (graph: NexusGraph, parent: NexusGraphNode | null) => string; + /** Derives focus-anchored relationships (opacity tiers) for the post-time-cap node set */ + deriveRelationships: (nodeIds: string[], edges: NexusGraphEdge[]) => Map; + /** + * Derives signed-in-anchored relationships for the size/satellite tiers + * (design: avatar sizes and chip counts stay relative to the signed-in user + * while opacity re-anchors on the focus). Defaults to deriveRelationships. + */ + deriveSizeRelationships?: (nodeIds: string[], edges: NexusGraphEdge[]) => Map; + /** Exempt the focus node itself from legend class hiding (explorer behavior) */ + exemptFocus?: boolean; + /** + * Apply the design's 3/2/1 posts-per-author cap (explorer). The feed turns + * this off: its posts ARE the content being visualized. + */ + capPostsByTier?: boolean; +}; + +export type GraphCore = { + graph: NexusGraph; + setGraph: React.Dispatch>; + /** Bumped by a full reload; in-flight expansions/traces from before are dropped */ + loadNonce: MutableRefObject; + currentUserPubky: Pubky | null; + // Selection + selectedId: string | null; + selectedNode: NexusGraphNode | null; + select: (nodeId: string | null) => void; + // Expansion bookkeeping + expandedIds: Set; + setExpandedIds: React.Dispatch>>; + isExpanding: boolean; + setIsExpanding: (value: boolean) => void; + // Path tracing + pathIds: string[] | null; + setPathIds: React.Dispatch>; + isTracing: boolean; + // Store-backed view preferences + declutter: boolean; + hiddenClasses: Set; + toggleClass: (cls: HideableClass) => void; + toggleDeclutter: () => void; + setDeclutter: (value: boolean) => void; + // Time machine (session state) + timeCap: number | null; + setTimeCap: (cap: number | null) => void; + timeBounds: { min: number; max: number } | null; + /** Sorted raw-graph event timestamps; identity is stable while a cap moves */ + timelineStamps: number[]; + // Derived visual model + nodes: VisualGraphNode[]; + edges: SocialGraphVisualEdge[]; + relationships: Map; + /** Focus-anchored opacity tier per visible node; path mode forces all to 'center' */ + opacityTiers: Map; + /** Signed-in-anchored size/satellite tier per visible node */ + sizeTiers: Map; + classCounts: Map; + /** kinds= filter for neighborhood fetches derived from the tag-hubs pref */ + fetchKinds: string | undefined; + // Actions + mergeNeighborhood: (incoming: NexusGraph, parent: NexusGraphNode | null, anchorId?: string) => void; + expand: (nodeId: string, anchorId?: string) => Promise; + refreshNode: (nodeId: string) => Promise; + /** Merge a tag's neighborhood in and select its hub (chip click / search) */ + addTag: (label: string) => Promise; + tracePath: (targetPubky: Pubky) => Promise; + clearPath: () => void; +}; + +/** Expansion parameters for a node's own neighborhood, by node kind. */ +function expandParamsOf(node: NexusGraphNode, kinds: string | undefined): TGraphNeighborhoodParams { + switch (node.kind) { + case 'user': + // Only user neighborhoods take the kinds filter: a tag/post expansion + // centered on the excluded kind would be self-defeating + return { kind: 'user', id: node.pubky, depth: 1, ...(kinds ? { kinds } : {}) }; + case 'post': + return { kind: 'post', id: `${node.author_id}:${node.post_id}` }; + case 'tag': + return { kind: 'tag', id: node.label }; + } +} + +/** New nodes spawn at their parent's coordinates and get flung out by the physics. */ +export function markBirths(prev: NexusGraph, incoming: NexusGraph, parent: NexusGraphNode | null) { + const known = new Set(prev.nodes.map((n) => n.id)); + const origin = parent as SimNode | null; + for (const node of incoming.nodes as SimNode[]) { + if (known.has(node.id)) continue; + node.__bornAt = Date.now(); + if (origin?.x !== undefined && origin?.y !== undefined) { + // Small jitter so simultaneous births do not stack on one pixel + node.x = origin.x + (Math.random() - 0.5) * 8; + node.y = origin.y + (Math.random() - 0.5) * 8; + } + } +} + +/** + * useGraphCore + * + * The shared state machine behind both graph surfaces (the explorer page and + * the feed's graph layout): graph accumulation with budget pruning, node + * expansion, shortest-path tracing, and the pure visual-model pipeline + * (time-machine capping, legend class filtering, declutter, mutual-follow + * collapsing, tag-edge aggregation). Callers own what genuinely differs: + * where the graph comes from and how relationship colors are derived. + * + * View preferences (declutter, hidden classes) are store-backed and persist + * across navigation; the time cap and selection are session state. + */ +export function useGraphCore({ + logTag, + focusId: focusIdOption, + resolveAnchor, + deriveRelationships, + deriveSizeRelationships, + exemptFocus = false, + capPostsByTier = true, +}: GraphCoreOptions): GraphCore { + const t = useTranslations('graph'); + const { currentUserPubky } = useAuthStore(); + const { + declutter, + hiddenClasses: hiddenClassList, + tagHubsOn, + toggleClass, + toggleDeclutter, + setDeclutter, + } = useGraphStore(); + const [graph, setGraph] = useState(EMPTY_GRAPH); + const [selectedId, setSelectedId] = useState(null); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [pathIds, setPathIds] = useState(null); + const [timeCap, setTimeCapState] = useState(null); + const [isExpanding, setIsExpanding] = useState(false); + const [isTracing, setIsTracing] = useState(false); + // Guards against a stale expansion/trace resolving after a newer load started + const loadNonce = useRef(0); + // Chip node objects survive recomputes so the sim never resets their layout + const satelliteCache = useRef(new Map()); + // Last committed opacity tiers, kept while a recenter expansion is in flight + // so the canvas does not flash all-dim before the new focus's edges merge + const opacityTiersRef = useRef>(new Map()); + + const hiddenClasses = useMemo(() => new Set(hiddenClassList), [hiddenClassList]); + const focusId = typeof focusIdOption === 'function' ? focusIdOption(graph) : focusIdOption; + // Default view fetches no shared tag hubs; the advanced pref restores them + const fetchKinds = tagHubsOn ? undefined : 'user,post'; + + // One bulk live query behind every profile-tag chip on the canvas + const userPubkys = useMemo(() => graph.nodes.flatMap((n) => (n.kind === 'user' ? [n.pubky] : [])), [graph]); + const tagsMap = useGraphProfileTags(userPubkys); + + const mergeNeighborhood = useCallback( + (incoming: NexusGraph, parent: NexusGraphNode | null, anchorId?: string) => { + // Computed against the closed-over graph (all callers depend on it), not + // inside the updater: React defers queued updaters, which would race the + // pruned-count toast below + markBirths(graph, incoming, parent); + const merged = mergeGraph(graph, incoming); + const result = pruneToBudget( + merged, + { focusId: anchorId ?? resolveAnchor(graph, parent), selectedId, expandedIds }, + MAX_CLIENT_NODES, + ); + setGraph(result.graph); + if (result.evictedIds.size > 0) { + // A node whose neighborhood was evicted must become expandable again + setExpandedIds((prev) => new Set([...prev].filter((id) => !result.evictedIds.has(id)))); + } + if (result.pruned > 0) toast({ description: t('states.tooManyNodes') }); + }, + [graph, resolveAnchor, selectedId, expandedIds, t], + ); + + const doExpand = useCallback( + async (nodeId: string, force: boolean, anchorId?: string) => { + const node = graph.nodes.find((n) => n.id === nodeId); + if (!node || isExpanding) return; + if (!force && expandedIds.has(nodeId)) return; + const nonce = loadNonce.current; + setIsExpanding(true); + try { + const neighborhood = await GraphController.fetchNeighborhood( + expandParamsOf(node, fetchKinds), + currentUserPubky, + ); + // A newer load() replaced the graph while we were in flight + if (nonce !== loadNonce.current) return; + // Recenter passes the clicked node as anchor: focus state has not + // committed yet in the same handler, so resolveAnchor would prune + // around the OLD focus and could evict the just-clicked cluster + mergeNeighborhood(neighborhood, node, anchorId); + setExpandedIds((prev) => new Set(prev).add(nodeId)); + } catch (err) { + // Non-fatal: the current graph stays untouched + Logger.error(`${logTag}: failed to expand node`, err); + toast({ description: t('states.expandError') }); + } finally { + setIsExpanding(false); + } + }, + [graph, expandedIds, isExpanding, mergeNeighborhood, currentUserPubky, fetchKinds, logTag, t], + ); + + const expand = useCallback((nodeId: string, anchorId?: string) => doExpand(nodeId, false, anchorId), [doExpand]); + const refreshNode = useCallback((nodeId: string) => doExpand(nodeId, true), [doExpand]); + + /** + * Merge a tag's neighborhood in (chip click / search-to-add) and select its + * hub. Shared by both surfaces so feed chips behave exactly like explorer + * chips. The explicit expandedIds entry doubles as the hub's visibility + * pass in the default view. + */ + const addTag = useCallback( + async (label: string) => { + const nodeId = `tag:${label}`; + if (graph.nodes.some((n) => n.id === nodeId)) { + setSelectedId(nodeId); + return; + } + const nonce = loadNonce.current; + setIsExpanding(true); + try { + const neighborhood = await GraphController.fetchNeighborhood({ kind: 'tag', id: label }, currentUserPubky); + if (nonce !== loadNonce.current) return; + // Anchor the prune on the incoming hub: a disconnected added cluster + // is otherwise "infinitely far" from the focus and gets evicted + mergeNeighborhood(neighborhood, null, nodeId); + setExpandedIds((prev) => new Set(prev).add(nodeId)); + setSelectedId(nodeId); + } catch (err) { + Logger.error(`${logTag}: failed to add tag`, err); + toast({ description: t('states.expandError') }); + } finally { + setIsExpanding(false); + } + }, + [graph, loadNonce, currentUserPubky, mergeNeighborhood, logTag, t], + ); + + const tracePath = useCallback( + async (targetPubky: Pubky) => { + if (!currentUserPubky || isTracing) return; + const nonce = loadNonce.current; + setIsTracing(true); + try { + const path = await GraphController.fetchPath({ from: currentUserPubky, to: targetPubky }, currentUserPubky); + if (nonce !== loadNonce.current) return; + const me = graph.nodes.find((n) => n.id === `user:${currentUserPubky}`) ?? null; + mergeNeighborhood(path, me, me?.id); + setPathIds(path.nodes.map((n) => n.id)); + } catch (err) { + Logger.error(`${logTag}: failed to trace path`, err); + toast({ description: t('states.noPath') }); + } finally { + setIsTracing(false); + } + }, + [currentUserPubky, isTracing, graph, mergeNeighborhood, logTag, t], + ); + + // Full timestamp range of the raw graph (slider bounds) + const timeBounds = useMemo(() => { + let min = Infinity; + let max = -Infinity; + for (const edge of graph.edges) { + if (edge.indexed_at !== undefined) { + min = Math.min(min, edge.indexed_at); + max = Math.max(max, edge.indexed_at); + } + } + for (const node of graph.nodes) { + if (node.kind === 'post' && node.indexed_at > 0) { + min = Math.min(min, node.indexed_at); + max = Math.max(max, node.indexed_at); + } + } + return min < max ? { min, max } : null; + }, [graph]); + + // Sorted event timeline for constant-rate playback. Derived from the RAW + // graph on purpose: the visible edge set shrinks under the moving cap, and + // stamps derived from it would change identity on every playback tick, + // restarting the player at index zero forever. + const timelineStamps = useMemo(() => { + const stamps: number[] = []; + for (const edge of graph.edges) if (edge.indexed_at !== undefined) stamps.push(edge.indexed_at); + for (const node of graph.nodes) if (node.kind === 'post' && node.indexed_at > 0) stamps.push(node.indexed_at); + return stamps.sort((a, b) => a - b); + }, [graph]); + + // The visual-model pipeline; each stage is a pure, unit-tested function + const { nodes, edges, relationships, opacityTiers, sizeTiers, classCounts } = useMemo(() => { + const timed = applyTimeCap(graph.nodes, graph.edges, timeCap, focusId); + const timedIds = timed.nodes.map((n) => n.id); + const relationships = deriveRelationships(timedIds, timed.edges); + + // Sizes and chip counts anchor on the signed-in user; opacity anchors on + // the focus (designer notes name two different anchors: "Signed in user + // avatar size is 64px" vs "Centered user is shown 100% opacity") + const sizeRelationships = deriveSizeRelationships ? deriveSizeRelationships(timedIds, timed.edges) : relationships; + const sizeTiers = new Map([...sizeRelationships].map(([id, rel]) => [id, tierOf(rel)])); + + let opacityTiers: Map; + if (pathIds) { + // How-connected view: everything visible paints at full opacity + opacityTiers = new Map(timedIds.map((id) => [id, 'center' as GraphTier])); + } else { + opacityTiers = new Map([...relationships].map(([id, rel]) => [id, tierOf(rel)])); + // A recenter re-anchors opacity before the new focus's neighborhood has + // merged; every node would transiently classify 'other' and the canvas + // would flash all-dim. Hold the previous tiers until the merge lands. + const hasDirect = [...opacityTiers.values()].some((tier) => tier === 'direct'); + if (!hasDirect && isExpanding && opacityTiersRef.current.size > 0) { + opacityTiers = opacityTiersRef.current; + } else { + opacityTiersRef.current = opacityTiers; + } + } + + // Legend counts reflect what COULD be shown (pre class-hiding) + const classCounts = new Map(); + for (const node of timed.nodes) { + const cls: HideableClass = node.kind === 'user' ? (relationships.get(node.id) ?? 'extended') : node.kind; + classCounts.set(cls, (classCounts.get(cls) ?? 0) + 1); + } + + let nodes: NexusGraphNode[]; + let edges: NexusGraphEdge[]; + if (pathIds) { + // Path mode bypasses class hiding and declutter outright: a stored + // hidden class must never amputate a mid-path user + const exclusive = applyPathExclusive(timed.nodes, timed.edges, new Set(pathIds)); + nodes = exclusive.nodes; + edges = exclusive.edges; + } else { + nodes = timed.nodes.filter((node) => { + // Default view has no shared tag hubs (the feed synthesizes them + // client-side, bypassing the kinds filter); explicitly-added hubs + // (search/chip expansion marks them expanded) always stay + if (node.kind === 'tag' && !tagHubsOn && !expandedIds.has(node.id)) return false; + const cls: HideableClass = node.kind === 'user' ? (relationships.get(node.id) ?? 'extended') : node.kind; + if (exemptFocus && node.id === focusId) return true; + return !hiddenClasses.has(cls); + }); + const kept = new Set(nodes.map((n) => n.id)); + edges = timed.edges.filter((edge) => { + if (!kept.has(edge.source) || !kept.has(edge.target)) return false; + if (edge.type === 'TAGGED') return !hiddenClasses.has('tag'); + if (edge.type === 'FOLLOWS') return true; + return !hiddenClasses.has('post'); + }); + + if (declutter) { + // Staleness is relative to the capped moment, else to the newest + // stamp in view (not the wall clock: on a stale snapshot every post + // is "old" and declutter would silently empty the graph) + const result = applyDeclutter(nodes, edges, relationships, timeCap ?? timeBounds?.max ?? Date.now()); + nodes = result.nodes; + edges = result.edges; + } + } + + // Design view: at most 3/2/1 posts per author by size tier, newest first. + // The feed opts out: its posts are the content being visualized. + const capped = capPostsByTier ? postTierCap(nodes, edges, sizeTiers) : { nodes, edges }; + + // Per-user profile-tag chips, derived last so they follow exactly the + // visible users; chip objects keep identity through the cache + const visibleUsers = capped.nodes.filter((n): n is Extract => n.kind === 'user'); + const satellites = deriveSatellites(visibleUsers, sizeTiers, tagsMap, satelliteCache.current, Date.now()); + + const visualEdges = aggregateParallelEdges(collapseMutualFollows([...capped.edges, ...satellites.edges])); + return { + // Satellites first: nodes paint in array order, so avatars and post + // circles land on top where a chip drifts underneath one + nodes: [...satellites.nodes, ...(capped.nodes as VisualGraphNode[])], + edges: visualEdges, + relationships, + opacityTiers, + sizeTiers, + classCounts, + }; + }, [ + graph, + timeCap, + focusId, + pathIds, + isExpanding, + deriveRelationships, + deriveSizeRelationships, + exemptFocus, + hiddenClasses, + declutter, + timeBounds, + capPostsByTier, + tagHubsOn, + expandedIds, + tagsMap, + ]); + + const selectedNode = useMemo(() => graph.nodes.find((node) => node.id === selectedId) ?? null, [graph, selectedId]); + + return { + graph, + setGraph, + loadNonce, + currentUserPubky, + selectedId, + selectedNode, + select: useCallback((nodeId: string | null) => setSelectedId(nodeId), []), + expandedIds, + setExpandedIds, + isExpanding, + setIsExpanding, + pathIds, + setPathIds, + isTracing, + declutter, + hiddenClasses, + toggleClass, + toggleDeclutter, + setDeclutter, + timeCap, + setTimeCap: useCallback((cap: number | null) => setTimeCapState(cap), []), + timeBounds, + timelineStamps, + nodes, + edges, + relationships, + opacityTiers, + sizeTiers, + classCounts, + fetchKinds, + mergeNeighborhood, + expand, + refreshNode, + addTag, + tracePath, + clearPath: useCallback(() => setPathIds(null), []), + }; +} diff --git a/src/hooks/useGraphDebug/useGraphDebug.ts b/src/hooks/useGraphDebug/useGraphDebug.ts new file mode 100644 index 0000000000..1c7e6c9341 --- /dev/null +++ b/src/hooks/useGraphDebug/useGraphDebug.ts @@ -0,0 +1,59 @@ +'use client'; + +import { type RefObject, useEffect } from 'react'; +import { IS_DEBUG } from '@/config/logs'; +import type { SocialGraphHandle } from '@/organisms/SocialGraph/SocialGraph.types'; + +/** QA surface exposed on window in debug builds; inert everywhere else. */ +export type GraphDebugSurface = { + nodeIds: () => Record<'user' | 'post' | 'tag' | 'profile_tag', string[]>; + screenPositionOf: (nodeId: string) => { x: number; y: number } | null; + screenMidpointOf: (aId: string, bId: string) => { x: number; y: number } | null; + pinnedIds: () => string[]; + settled: () => boolean; + zoom: () => number | null; + hoveredId: () => string | null; + focusId: () => string | null; + pathIds: () => string[] | null; + /** Freeze/unfreeze the simulation so audits interact with a static layout */ + setPaused: (paused: boolean) => void; +}; + +declare global { + interface Window { + __graphDebug?: GraphDebugSurface; + } +} + +/** + * useGraphDebug + * + * Publishes the canvas handle plus a few state getters as window.__graphDebug + * so the cypress interaction audit can enumerate nodes, resolve their screen + * positions, and assert focus/settledness. Gated on NEXT_PUBLIC_DEBUG_MODE: + * production deployments never expose it. + */ +export function useGraphDebug( + canvasRef: RefObject, + getters: { focusId: () => string | null; pathIds: () => string[] | null }, +): void { + const { focusId, pathIds } = getters; + useEffect(() => { + if (!IS_DEBUG || typeof window === 'undefined') return; + window.__graphDebug = { + nodeIds: () => canvasRef.current?.nodeIds() ?? { user: [], post: [], tag: [], profile_tag: [] }, + screenPositionOf: (nodeId) => canvasRef.current?.screenPositionOf(nodeId) ?? null, + screenMidpointOf: (aId, bId) => canvasRef.current?.screenMidpointOf(aId, bId) ?? null, + pinnedIds: () => canvasRef.current?.pinnedIds() ?? [], + settled: () => canvasRef.current?.isSettled() ?? false, + zoom: () => canvasRef.current?.zoomLevel() ?? null, + hoveredId: () => canvasRef.current?.hoveredId() ?? null, + focusId, + pathIds, + setPaused: (paused) => canvasRef.current?.setPaused(paused), + }; + return () => { + delete window.__graphDebug; + }; + }, [canvasRef, focusId, pathIds]); +} diff --git a/src/hooks/useGraphProfileTags/useGraphProfileTags.test.ts b/src/hooks/useGraphProfileTags/useGraphProfileTags.test.ts new file mode 100644 index 0000000000..634e7dc815 --- /dev/null +++ b/src/hooks/useGraphProfileTags/useGraphProfileTags.test.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserController } from '@/controllers/user/user'; +import type { Pubky } from '@/models/models.types'; +import type { NexusTag } from '@/services/nexus/nexus.types'; +import { useGraphProfileTags } from './useGraphProfileTags'; + +vi.mock('@/controllers/user/user', () => ({ + UserController: { + getManyTags: vi.fn(), + getManyTagsOrFetch: vi.fn(), + }, +})); + +const mockGetManyTags = vi.mocked(UserController.getManyTags); +const mockGetManyTagsOrFetch = vi.mocked(UserController.getManyTagsOrFetch); + +const PK_A = 'a'.repeat(52) as Pubky; +const PK_B = 'b'.repeat(52) as Pubky; + +describe('useGraphProfileTags', () => { + beforeEach(() => { + mockGetManyTags.mockReset(); + mockGetManyTagsOrFetch.mockReset(); + }); + + it('reads tags for the canvas users through the local-only bulk reader', async () => { + const tags = new Map([ + [PK_A, [{ label: 'dev', taggers: [], taggers_count: 3, relationship: false }]], + ]); + mockGetManyTags.mockResolvedValue(tags); + + const { result } = renderHook(() => useGraphProfileTags([PK_A, PK_B])); + + await waitFor(() => expect(result.current.get(PK_A)?.[0]?.label).toBe('dev')); + expect(mockGetManyTags).toHaveBeenCalledWith({ userIds: [PK_A, PK_B] }); + }); + + it('never fires the network-capable fetch path (zero-network-on-hover contract)', async () => { + mockGetManyTags.mockResolvedValue(new Map()); + + const { result } = renderHook(() => useGraphProfileTags([PK_A])); + + await waitFor(() => expect(mockGetManyTags).toHaveBeenCalled()); + expect(result.current.size).toBe(0); + expect(mockGetManyTagsOrFetch).not.toHaveBeenCalled(); + }); + + it('returns an empty map for an empty pubky list without querying', async () => { + const { result } = renderHook(() => useGraphProfileTags([])); + await waitFor(() => expect(result.current.size).toBe(0)); + expect(mockGetManyTags).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useGraphProfileTags/useGraphProfileTags.ts b/src/hooks/useGraphProfileTags/useGraphProfileTags.ts new file mode 100644 index 0000000000..0da90770af --- /dev/null +++ b/src/hooks/useGraphProfileTags/useGraphProfileTags.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useMemo } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { UserController } from '@/controllers/user/user'; +import { Logger } from '@/libs/logger/logger'; +import type { Pubky } from '@/models/models.types'; +import type { NexusTag } from '@/services/nexus/nexus.types'; + +const EMPTY_TAGS: Map = new Map(); + +/** + * useGraphProfileTags + * + * One bulk live query over the local user_tags table for every user on the + * graph canvas. Strictly local by contract (the ingestion pipeline fills + * gaps, never this reader); Dexie re-fires it when tag rows land, so chips + * pop in without any per-node fetching. + */ +export function useGraphProfileTags(pubkys: Pubky[]): Map { + const pubkyKey = useMemo(() => [...pubkys].sort().join(','), [pubkys]); + const tags = useLiveQuery(async () => { + try { + if (pubkys.length === 0) return EMPTY_TAGS; + return await UserController.getManyTags({ userIds: pubkys }); + } catch (error) { + Logger.error('useGraphProfileTags: failed to read user tags', { error }); + return EMPTY_TAGS; + } + }, [pubkyKey]); + return tags ?? EMPTY_TAGS; +} diff --git a/src/hooks/useProfileMenuActions/useProfileMenuActions.constants.ts b/src/hooks/useProfileMenuActions/useProfileMenuActions.constants.ts index b2b00b6a3f..0f09d1e8f4 100644 --- a/src/hooks/useProfileMenuActions/useProfileMenuActions.constants.ts +++ b/src/hooks/useProfileMenuActions/useProfileMenuActions.constants.ts @@ -6,5 +6,6 @@ export const PROFILE_MENU_ACTION_IDS = { FOLLOW: 'follow', COPY_PUBKY: 'copy-pubky', COPY_LINK: 'copy-link', + OPEN_IN_GRAPH: 'open-in-graph', MUTE: 'mute', } as const; diff --git a/src/hooks/useProfileMenuActions/useProfileMenuActions.test.tsx b/src/hooks/useProfileMenuActions/useProfileMenuActions.test.tsx index 5e11cfeeba..0c808f08ac 100644 --- a/src/hooks/useProfileMenuActions/useProfileMenuActions.test.tsx +++ b/src/hooks/useProfileMenuActions/useProfileMenuActions.test.tsx @@ -28,6 +28,10 @@ const { })); // Mock next-intl +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + vi.mock('next-intl', () => ({ useTranslations: (namespace: string) => mockUseTranslations(namespace), })); @@ -489,6 +493,7 @@ describe('useProfileMenuActions', () => { PROFILE_MENU_ACTION_IDS.FOLLOW, PROFILE_MENU_ACTION_IDS.COPY_PUBKY, PROFILE_MENU_ACTION_IDS.COPY_LINK, + PROFILE_MENU_ACTION_IDS.OPEN_IN_GRAPH, PROFILE_MENU_ACTION_IDS.MUTE, ]); }); diff --git a/src/hooks/useProfileMenuActions/useProfileMenuActions.tsx b/src/hooks/useProfileMenuActions/useProfileMenuActions.tsx index d79b85628b..b3e95013c8 100644 --- a/src/hooks/useProfileMenuActions/useProfileMenuActions.tsx +++ b/src/hooks/useProfileMenuActions/useProfileMenuActions.tsx @@ -1,8 +1,9 @@ 'use client'; -import { Key, Link, Megaphone, MegaphoneOff, UserRoundMinus, UserRoundPlus } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Key, Link, Megaphone, MegaphoneOff, UserRoundMinus, UserRoundPlus, Waypoints } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { PROFILE_ROUTES } from '@/app/routes'; +import { APP_ROUTES, PROFILE_ROUTES } from '@/app/routes'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard/useCopyToClipboard'; import { useFollowUser } from '@/hooks/useFollowUser/useFollowUser'; import { useIsFollowing } from '@/hooks/useIsFollowing/useIsFollowing'; @@ -28,6 +29,7 @@ import type { ProfileMenuActionItem, UseProfileMenuActionsResult } from './usePr export function useProfileMenuActions(userId: string): UseProfileMenuActionsResult { const t = useTranslations('profile.actions'); const tToast = useTranslations('toast'); + const router = useRouter(); const { profile, isLoading: isProfileLoading } = useUserProfile(userId); const { isFollowing, isLoading: isFollowingLoading } = useIsFollowing(userId); const { toggleFollow, isLoading: isFollowLoading, isUserLoading } = useFollowUser(); @@ -98,6 +100,16 @@ export function useProfileMenuActions(userId: string): UseProfileMenuActionsResu }, }); + // Open in graph explorer + menuItems.push({ + id: PROFILE_MENU_ACTION_IDS.OPEN_IN_GRAPH, + label: t('openInGraph'), + icon: Waypoints, + onClick: async () => { + router.push(`${APP_ROUTES.GRAPH}?user=${userId}`); + }, + }); + // Mute/Unmute menuItems.push({ id: PROFILE_MENU_ACTION_IDS.MUTE, diff --git a/src/hooks/useRelativeTime/useRelativeTime.test.tsx b/src/hooks/useRelativeTime/useRelativeTime.test.tsx new file mode 100644 index 0000000000..b3d2660b9b --- /dev/null +++ b/src/hooks/useRelativeTime/useRelativeTime.test.tsx @@ -0,0 +1,108 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useRelativeTime } from './useRelativeTime'; + +describe('useRelativeTime', () => { + const now = new Date('2026-07-16T12:00:00Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const DAY = 24 * 60 * 60 * 1000; + + it.each([ + ['4s', 4 * 1000], + ['3m', 3 * 60 * 1000], + ['7h', 7 * 60 * 60 * 1000], + ['2d', 2 * DAY], + ['5w', 5 * 7 * DAY], + ['6w', 6 * 7 * DAY], + ['6M', 6 * 30 * DAY], + ['1Y', 365 * DAY], + ['2Y', 730 * DAY], + ['1Y 1M', 395 * DAY], + ['2Y 1M', 760 * DAY], + ['1Y 10M', 665 * DAY], + ['2Y 10M', 1030 * DAY], + ['3Y 10M', 1395 * DAY], + ])('formats %s timestamps with a compact label', (expected, elapsedMs) => { + const { result } = renderHook(() => useRelativeTime()); + + expect(result.current.formatRelativeTime(new Date(now.getTime() - elapsedMs))).toBe(expected); + }); + + it('does not show negative values for future timestamps', () => { + const { result } = renderHook(() => useRelativeTime()); + + expect(result.current.formatRelativeTime(new Date(now.getTime() + 10 * 1000))).toBe('0s'); + }); + + it.each([ + ['59s', 59 * 1000, '1m', 60 * 1000], + ['59m', 59 * 60 * 1000, '1h', 60 * 60 * 1000], + ['23h', 23 * 60 * 60 * 1000, '1d', 24 * 60 * 60 * 1000], + ['6d', 6 * DAY, '1w', 7 * DAY], + ['7w', 55 * DAY, '2M', 56 * DAY], + ['11M', 364 * DAY, '1Y', 365 * DAY], + ['1Y', (365 + 27) * DAY, '1Y 1M', (365 + 28) * DAY], + ])('does not skip or go backwards across the %s / %s boundary', (belowExpected, belowMs, atExpected, atMs) => { + const { result } = renderHook(() => useRelativeTime()); + + expect(result.current.formatRelativeTime(new Date(now.getTime() - belowMs))).toBe(belowExpected); + expect(result.current.formatRelativeTime(new Date(now.getTime() - atMs))).toBe(atExpected); + }); + + it('never reports less elapsed time as the real elapsed time grows (exhaustive sweep, 0s to 10y)', () => { + const { result } = renderHook(() => useRelativeTime()); + const { formatRelativeTime } = result.current; + + const SECOND = 1000; + const MINUTE = 60 * SECOND; + const HOUR = 60 * MINUTE; + // seconds-equivalent of each unit's label, used only to check the label sequence + // never implies less time than a previous label as real elapsed time increases. + const UNIT_SECONDS: Record = { + s: 1, + m: 60, + h: 3600, + d: 86400, + w: 604800, + M: 2592000, + Y: 365 * 86400, + }; + + function labelSeconds(elapsedMs: number): number { + const label = formatRelativeTime(new Date(now.getTime() - elapsedMs)); + // years render as one or two tokens, e.g. "2Y" or "1Y 1M" + return label.split(' ').reduce((total, token) => { + const match = token.match(/^(\d+)([smhdwMY])$/); + if (!match) throw new Error(`Unexpected label format: "${label}" (token "${token}") at elapsedMs=${elapsedMs}`); + return total + Number(match[1]) * UNIT_SECONDS[match[2]]; + }, 0); + } + + function assertNonDecreasing(elapsedMsValues: number[]) { + let prev = -Infinity; + for (const elapsedMs of elapsedMsValues) { + const seconds = labelSeconds(elapsedMs); + expect(seconds).toBeGreaterThanOrEqual(prev); + prev = seconds; + } + } + + // every second up to just under an hour (covers s->m and m->h boundaries) + assertNonDecreasing(Array.from({ length: 3600 }, (_, s) => s * SECOND)); + // every minute up to just under a day (covers m->h and h->d boundaries) + assertNonDecreasing(Array.from({ length: 1440 }, (_, m) => m * MINUTE)); + // every hour up to just under a week (covers h->d and d->w boundaries) + assertNonDecreasing(Array.from({ length: 168 }, (_, h) => h * HOUR)); + // every day for 10 years (covers d->w and w->M boundaries, and all later M values) + assertNonDecreasing(Array.from({ length: 3650 }, (_, d) => d * DAY)); + }); +}); diff --git a/src/hooks/useRelativeTime/useRelativeTime.ts b/src/hooks/useRelativeTime/useRelativeTime.ts index 5c6267fc0b..886dcf3333 100644 --- a/src/hooks/useRelativeTime/useRelativeTime.ts +++ b/src/hooks/useRelativeTime/useRelativeTime.ts @@ -1,38 +1,47 @@ 'use client'; - -import { useFormatter, useTranslations } from 'next-intl'; +const DAYS_PER_WEEK = 7; +const WEEKS_PER_MONTH = 4; +const DAYS_PER_YEAR = 365; +const MAX_REMAINDER_MONTHS = 11; /** - * Hook to format relative time with localization support. + * Hook to format relative time. * - * Uses next-intl's useFormatter for proper locale-aware relative time formatting. - * Falls back to short format (e.g., "2h", "3m") for very recent times, - * and full relative format (e.g., "3 months ago") for older times. + * Uses compact labels for every duration (e.g., "2h", "3w", "6M", "1Y 1M"). * * @returns Object with formatRelativeTime function * * @example * const { formatRelativeTime } = useRelativeTime(); * const timeAgo = formatRelativeTime(new Date(post.indexed_at)); - * // Returns: "now", "5m", "2h", "3 days ago", "2 months ago", etc. + * // Returns: "4s", "5m", "2h", "3d", "5w", "6M", "1Y 1M", "2Y", etc. */ export function useRelativeTime() { - const format = useFormatter(); - const t = useTranslations('time'); - function formatRelativeTime(date: Date): string { const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffSeconds = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1000)); + const diffMins = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / DAYS_PER_WEEK); + const diffMonths = Math.floor(diffWeeks / WEEKS_PER_MONTH); - // Short format for very recent times - if (diffMins < 1) return t('now'); - if (diffMins < 60) return t('minutesShort', { count: diffMins }); - if (diffHours < 24) return t('hoursShort', { count: diffHours }); + if (diffSeconds < 60) return `${diffSeconds}s`; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + if (diffWeeks < 8) return `${diffWeeks}w`; + // Keep years at 365 days while using the same four-week month approximation + // on both sides of the year boundary. + if (diffDays < DAYS_PER_YEAR) { + return `${Math.min(diffMonths, MAX_REMAINDER_MONTHS)}M`; + } - // Use next-intl's relative time formatting for older times - return format.relativeTime(date, now); + const diffYears = Math.floor(diffDays / DAYS_PER_YEAR); + const remainderWeeks = Math.floor((diffDays % DAYS_PER_YEAR) / DAYS_PER_WEEK); + const remainderMonths = Math.min(Math.floor(remainderWeeks / WEEKS_PER_MONTH), MAX_REMAINDER_MONTHS); + if (remainderMonths === 0) return `${diffYears}Y`; + return `${diffYears}Y ${remainderMonths}M`; } return { formatRelativeTime }; diff --git a/src/hooks/useSocialGraph/useSocialGraph.satellites.test.ts b/src/hooks/useSocialGraph/useSocialGraph.satellites.test.ts new file mode 100644 index 0000000000..52eedc4f29 --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.satellites.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import type { Pubky } from '@/models/models.types'; +import type { NexusGraphNode, NexusGraphUserNode } from '@/services/nexus/graph/graph.types'; +import type { NexusTag } from '@/services/nexus/nexus.types'; +import { + applyPathExclusive, + deriveSatellites, + facepileCandidates, + type GraphTier, + postTierCap, + type SatelliteTagNode, + tierOf, + topProfileTags, +} from './useSocialGraph.utils'; + +const pk = (n: number) => `pubky${n}`.padEnd(52, 'x') as Pubky; + +const userNode = (n: number, pos?: { x: number; y: number }): NexusGraphUserNode & { x?: number; y?: number } => ({ + kind: 'user', + id: `user:${pk(n)}`, + pubky: pk(n), + name: `User ${n}`, + image: null, + ...pos, +}); + +const postNode = (author: number, id: string, indexedAt: number): NexusGraphNode => ({ + kind: 'post', + id: `post:${pk(author)}:${id}`, + author_id: pk(author), + post_id: id, + content: 'hello', + post_kind: 'short', + is_reply: false, + indexed_at: indexedAt, +}); + +const tag = (label: string, count: number): NexusTag => ({ + label, + taggers: [], + taggers_count: count, + relationship: false, +}); + +describe('tierOf', () => { + it('maps relationships onto the three design tiers', () => { + expect(tierOf('self')).toBe('center'); + expect(tierOf('friend')).toBe('direct'); + expect(tierOf('following')).toBe('direct'); + expect(tierOf('follower')).toBe('direct'); + expect(tierOf('extended')).toBe('other'); + expect(tierOf(undefined)).toBe('other'); + }); +}); + +describe('topProfileTags', () => { + it('sorts by tagger count desc with alphabetical tie-break', () => { + const tags = [tag('zeta', 5), tag('alpha', 5), tag('big', 9), tag('tiny', 1)]; + expect(topProfileTags(tags, 3).map((t) => t.label)).toEqual(['big', 'alpha', 'zeta']); + }); + + it('handles n <= 0 and does not mutate its input', () => { + const tags = [tag('b', 2), tag('a', 1)]; + expect(topProfileTags(tags, 0)).toEqual([]); + topProfileTags(tags, 1); + expect(tags.map((t) => t.label)).toEqual(['b', 'a']); + }); +}); + +describe('deriveSatellites', () => { + const tiers = new Map([ + [`user:${pk(1)}`, 'center'], + [`user:${pk(2)}`, 'direct'], + [`user:${pk(3)}`, 'other'], + ]); + const tagsMap = new Map([ + [pk(1), [tag('a', 9), tag('b', 8), tag('c', 7), tag('d', 6)]], + [pk(2), [tag('a', 4), tag('b', 3), tag('c', 2)]], + [pk(3), [tag('x', 2), tag('y', 1)]], + ]); + + it('derives top 3/2/1 chips by tier with owner edges', () => { + const cache = new Map(); + const { nodes, edges } = deriveSatellites([userNode(1), userNode(2), userNode(3)], tiers, tagsMap, cache, 1000); + expect(nodes.map((n) => n.id)).toEqual([ + `ptag:${pk(1)}:a`, + `ptag:${pk(1)}:b`, + `ptag:${pk(1)}:c`, + `ptag:${pk(2)}:a`, + `ptag:${pk(2)}:b`, + `ptag:${pk(3)}:x`, + ]); + expect(edges.every((e) => e.type === 'HAS_TAG')).toBe(true); + expect(edges[0]).toMatchObject({ source: `user:${pk(1)}`, target: `ptag:${pk(1)}:a` }); + // Same label on two users stays two distinct chips with their own counts + const aChips = nodes.filter((n) => n.label === 'a'); + expect(aChips.map((n) => n.count)).toEqual([9, 4]); + }); + + it('keeps chip object identity across recomputes and updates counts in place', () => { + const cache = new Map(); + const first = deriveSatellites([userNode(1)], tiers, tagsMap, cache, 1000); + const bumped = new Map([[pk(1), [tag('a', 20), tag('b', 8), tag('c', 7)]]]); + const second = deriveSatellites([userNode(1)], tiers, bumped, cache, 2000); + expect(second.nodes[0]).toBe(first.nodes[0]); + expect(second.nodes[0].count).toBe(20); + }); + + it('spawns new chips beside their owner, not at the origin', () => { + const cache = new Map(); + const { nodes } = deriveSatellites([userNode(1, { x: 500, y: -200 })], tiers, tagsMap, cache, 1000); + const chip = nodes[0] as SatelliteTagNode & { x?: number; y?: number; __bornAt?: number }; + expect(chip.x).toBeDefined(); + expect(Math.hypot((chip.x ?? 0) - 500, (chip.y ?? 0) + 200)).toBeCloseTo(60, 5); + expect(chip.__bornAt).toBe(1000); + }); + + it('drops chips whose owner left the visible set and shrinks on tier demotion', () => { + const cache = new Map(); + deriveSatellites([userNode(1)], tiers, tagsMap, cache, 1000); + const demoted = new Map([[`user:${pk(1)}`, 'other']]); + const { nodes } = deriveSatellites([userNode(1)], demoted, tagsMap, cache, 2000); + expect(nodes.map((n) => n.label)).toEqual(['a']); + const gone = deriveSatellites([], tiers, tagsMap, cache, 3000); + expect(gone.nodes).toEqual([]); + expect(gone.edges).toEqual([]); + }); +}); + +describe('applyPathExclusive', () => { + it('keeps only path users, their posts, and edges among them', () => { + const nodes = [userNode(1), userNode(2), userNode(3), postNode(1, 'p1', 10), postNode(3, 'p3', 10)]; + const edges = [ + { source: `user:${pk(1)}`, target: `user:${pk(2)}`, type: 'FOLLOWS' as const }, + { source: `user:${pk(2)}`, target: `user:${pk(3)}`, type: 'FOLLOWS' as const }, + { source: `user:${pk(1)}`, target: `post:${pk(1)}:p1`, type: 'AUTHORED' as const }, + { source: `user:${pk(3)}`, target: `post:${pk(3)}:p3`, type: 'AUTHORED' as const }, + ]; + const pathIds = new Set([`user:${pk(1)}`, `user:${pk(2)}`]); + const result = applyPathExclusive(nodes, edges, pathIds); + expect(result.nodes.map((n) => n.id)).toEqual([`user:${pk(1)}`, `user:${pk(2)}`, `post:${pk(1)}:p1`]); + expect(result.edges).toHaveLength(2); + expect(result.edges.some((e) => e.target === `user:${pk(3)}`)).toBe(false); + }); +}); + +describe('postTierCap', () => { + it('caps posts per author by tier, keeping the newest', () => { + const tiers = new Map([ + [`user:${pk(1)}`, 'center'], + [`user:${pk(2)}`, 'other'], + ]); + const nodes = [ + userNode(1), + userNode(2), + postNode(1, 'a', 1), + postNode(1, 'b', 2), + postNode(1, 'c', 3), + postNode(1, 'd', 4), + postNode(2, 'e', 1), + postNode(2, 'f', 2), + ]; + const edges = nodes + .filter((n) => n.kind === 'post') + .map((n) => ({ + source: `user:${n.kind === 'post' ? n.author_id : ''}`, + target: n.id, + type: 'AUTHORED' as const, + })); + const result = postTierCap(nodes, edges, tiers); + const keptPosts = result.nodes.filter((n) => n.kind === 'post').map((n) => n.id); + // center keeps 3 newest (b,c,d), other keeps 1 newest (f) + expect(keptPosts).toEqual([`post:${pk(1)}:b`, `post:${pk(1)}:c`, `post:${pk(1)}:d`, `post:${pk(2)}:f`]); + expect(result.edges.every((e) => keptPosts.includes(e.target))).toBe(true); + }); + + it('is a no-op when under every cap (same array references)', () => { + const tiers = new Map([[`user:${pk(1)}`, 'center']]); + const nodes = [userNode(1), postNode(1, 'a', 1)]; + const result = postTierCap(nodes, [], tiers); + expect(result.nodes).toBe(nodes); + }); +}); + +describe('facepileCandidates', () => { + const edges = [ + { source: `user:${pk(2)}`, target: `user:${pk(9)}`, type: 'FOLLOWS' as const }, + { source: `user:${pk(3)}`, target: `user:${pk(9)}`, type: 'FOLLOWS' as const }, + { source: `user:${pk(4)}`, target: `user:${pk(9)}`, type: 'FRIEND' as const }, + { source: `user:${pk(9)}`, target: `user:${pk(5)}`, type: 'FOLLOWS' as const }, + // viewer follows pk(3) + { source: `user:${pk(1)}`, target: `user:${pk(3)}`, type: 'FOLLOWS' as const }, + ]; + + it('ranks viewer-followed candidates first and caps the list', () => { + const followers = facepileCandidates(`user:${pk(9)}`, edges, `user:${pk(1)}`, 'followers'); + expect(followers[0]).toBe(`user:${pk(3)}`); + expect(followers).toHaveLength(3); + expect(followers).not.toContain(`user:${pk(5)}`); + }); + + it('handles the following direction and excludes self/target', () => { + const following = facepileCandidates(`user:${pk(9)}`, edges, null, 'following'); + expect(following).toContain(`user:${pk(5)}`); + expect(following).toContain(`user:${pk(4)}`); // FRIEND counts both ways + expect(following).not.toContain(`user:${pk(9)}`); + }); +}); diff --git a/src/hooks/useSocialGraph/useSocialGraph.test.tsx b/src/hooks/useSocialGraph/useSocialGraph.test.tsx new file mode 100644 index 0000000000..dac64216ce --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.test.tsx @@ -0,0 +1,300 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GraphController } from '@/controllers/graph/graph'; +import type { NexusGraph } from '@/services/nexus/graph/graph.types'; +import { useGraphStore } from '@/stores/graph/graph.store'; +import { useSocialGraph } from './useSocialGraph'; + +vi.mock('@/controllers/graph/graph', () => ({ + GraphController: { fetchNeighborhood: vi.fn(), fetchPath: vi.fn() }, +})); + +vi.mock('@/molecules/Toaster/use-toast', () => ({ + toast: vi.fn(), +})); + +vi.mock('@/libs/logger/logger', () => ({ + Logger: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: () => ({ currentUserPubky: 'mepubky' }), +})); + +const mockGetNeighborhood = vi.mocked(GraphController.fetchNeighborhood); +const mockGetPath = vi.mocked(GraphController.fetchPath); + +const ME = 'mepubky'; +const initialGraph: NexusGraph = { + nodes: [ + { kind: 'user', id: `user:${ME}`, pubky: ME, name: 'Me', image: null }, + { kind: 'user', id: 'user:friend', pubky: 'friend', name: 'Friend', image: null }, + { + kind: 'post', + id: `post:${ME}:p1`, + author_id: ME, + post_id: 'p1', + content: 'hi', + post_kind: 'short', + is_reply: false, + indexed_at: 1, + }, + { kind: 'tag', id: 'tag:pubky', label: 'pubky', count: 3 }, + ], + edges: [ + { source: `user:${ME}`, target: 'user:friend', type: 'FOLLOWS', indexed_at: 10 }, + { source: 'user:friend', target: `user:${ME}`, type: 'FOLLOWS', indexed_at: 20 }, + { source: `user:${ME}`, target: `post:${ME}:p1`, type: 'AUTHORED' }, + { source: 'tag:pubky', target: `user:${ME}`, type: 'TAGGED', label: 'pubky' }, + ], +}; + +async function loadedHook() { + mockGetNeighborhood.mockResolvedValueOnce(initialGraph); + const rendered = renderHook(() => useSocialGraph()); + act(() => { + rendered.result.current.load(ME); + }); + // Default view: the shared tag hub is filtered out (tagHubsOn is off) + await waitFor(() => expect(rendered.result.current.nodes).toHaveLength(3)); + return rendered; +} + +describe('useSocialGraph', () => { + beforeEach(() => { + mockGetNeighborhood.mockReset(); + mockGetPath.mockReset(); + // View preferences live in a persisted store shared across tests + useGraphStore.getState().reset(); + }); + + it('loads a neighborhood, starts the trail, and derives the visual model', async () => { + const { result } = await loadedHook(); + + expect(mockGetNeighborhood).toHaveBeenCalledWith({ kind: 'user', id: ME, depth: 1, kinds: 'user,post' }, ME); + expect(result.current.focusId).toBe(`user:${ME}`); + expect(result.current.trail.map((t) => t.id)).toEqual([`user:${ME}`]); + expect(result.current.edges.filter((e) => e.type === 'FRIEND')).toHaveLength(1); + expect(result.current.relationships.get('user:friend')).toBe('friend'); + expect(result.current.classCounts.get('friend')).toBe(1); + expect(result.current.classCounts.get('post')).toBe(1); + expect(result.current.timeBounds).toEqual({ min: 1, max: 20 }); + }); + + it('expands a node by merging its neighborhood and is idempotent', async () => { + const { result } = await loadedHook(); + + mockGetNeighborhood.mockResolvedValueOnce({ + nodes: [ + { kind: 'user', id: 'user:friend', pubky: 'friend', name: 'Friend', image: null }, + { kind: 'user', id: 'user:new', pubky: 'new', name: 'New', image: null }, + ], + edges: [{ source: 'user:friend', target: 'user:new', type: 'FOLLOWS' }], + }); + + await act(async () => { + await result.current.expand('user:friend'); + }); + + expect(mockGetNeighborhood).toHaveBeenLastCalledWith( + { kind: 'user', id: 'friend', depth: 1, kinds: 'user,post' }, + ME, + ); + expect(result.current.nodes).toHaveLength(4); + expect(result.current.expandedIds.has('user:friend')).toBe(true); + + await act(async () => { + await result.current.expand('user:friend'); + }); + expect(mockGetNeighborhood).toHaveBeenCalledTimes(2); + }); + + it('refreshNode bypasses the expanded guard', async () => { + const { result } = await loadedHook(); + + mockGetNeighborhood.mockResolvedValue({ nodes: [], edges: [] }); + await act(async () => { + await result.current.expand('user:friend'); + }); + await act(async () => { + await result.current.refreshNode('user:friend'); + }); + + expect(mockGetNeighborhood).toHaveBeenCalledTimes(3); + }); + + it('keeps the graph untouched when an expansion fails', async () => { + const { result } = await loadedHook(); + + mockGetNeighborhood.mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await result.current.expand('user:friend'); + }); + + expect(result.current.nodes).toHaveLength(3); + expect(result.current.expandedIds.has('user:friend')).toBe(false); + expect(result.current.error).toBe(false); + }); + + it('legend class toggles hide nodes and their edge families', async () => { + const { result } = await loadedHook(); + + act(() => { + result.current.toggleClass('post'); + result.current.toggleClass('tag'); + }); + + expect(result.current.nodes.every((n) => n.kind === 'user')).toBe(true); + expect(result.current.edges.every((e) => e.type === 'FRIEND' || e.type === 'FOLLOWS')).toBe(true); + + act(() => { + result.current.toggleClass('friend'); + }); + expect(result.current.nodes.map((n) => n.id)).toEqual([`user:${ME}`]); + }); + + it('time cap hides newer edges and re-derives relationships', async () => { + const { result } = await loadedHook(); + + act(() => { + result.current.setTimeCap(15); + }); + + // Only the me->friend edge (ts 10) survives; the return edge (ts 20) is in the future + expect(result.current.relationships.get('user:friend')).toBe('following'); + expect(result.current.edges.filter((e) => e.type === 'FRIEND')).toHaveLength(0); + + act(() => { + result.current.setTimeCap(null); + }); + expect(result.current.relationships.get('user:friend')).toBe('friend'); + }); + + it('focus pushes trail hops without consecutive duplicates', async () => { + const { result } = await loadedHook(); + + act(() => { + result.current.focus('user:friend'); + }); + act(() => { + result.current.focus('user:friend'); + }); + + expect(result.current.trail.map((t) => t.id)).toEqual([`user:${ME}`, 'user:friend']); + expect(result.current.relationships.get('user:friend')).toBe('self'); + }); + + it('tracePath merges the path and exposes its ordered ids', async () => { + const { result } = await loadedHook(); + + mockGetPath.mockResolvedValueOnce({ + nodes: [ + { kind: 'user', id: `user:${ME}`, pubky: ME, name: 'Me', image: null }, + { kind: 'user', id: 'user:mid', pubky: 'mid', name: 'Mid', image: null }, + { kind: 'user', id: 'user:far', pubky: 'far', name: 'Far', image: null }, + ], + edges: [ + { source: `user:${ME}`, target: 'user:mid', type: 'FOLLOWS' }, + { source: 'user:mid', target: 'user:far', type: 'FOLLOWS' }, + ], + }); + + await act(async () => { + await result.current.tracePath('far'); + }); + + expect(mockGetPath).toHaveBeenCalledWith({ from: ME, to: 'far' }, ME); + expect(result.current.pathIds).toEqual([`user:${ME}`, 'user:mid', 'user:far']); + expect(result.current.nodes.map((n) => n.id)).toContain('user:far'); + + act(() => { + result.current.clearPath(); + }); + expect(result.current.pathIds).toBeNull(); + }); + + it('recenter focuses and expands the clicked user, pruning around it, only once', async () => { + const { result } = await loadedHook(); + + mockGetNeighborhood.mockResolvedValueOnce({ + nodes: [ + { kind: 'user', id: 'user:friend', pubky: 'friend', name: 'Friend', image: null }, + { kind: 'user', id: 'user:new', pubky: 'new', name: 'New', image: null }, + ], + edges: [{ source: 'user:friend', target: 'user:new', type: 'FOLLOWS' }], + }); + + await act(async () => { + await result.current.recenter('user:friend'); + }); + + expect(result.current.focusId).toBe('user:friend'); + expect(result.current.trail.at(-1)?.id).toBe('user:friend'); + expect(mockGetNeighborhood).toHaveBeenLastCalledWith( + { kind: 'user', id: 'friend', depth: 1, kinds: 'user,post' }, + ME, + ); + + // Already expanded: a second recenter only refocuses, no refetch + await act(async () => { + await result.current.recenter('user:friend'); + }); + expect(mockGetNeighborhood).toHaveBeenCalledTimes(2); + + // Non-user nodes never recenter + await act(async () => { + await result.current.recenter('tag:pubky'); + }); + expect(result.current.focusId).toBe('user:friend'); + }); + + it('path mode keeps only path clusters, bypassing hidden classes, at full opacity', async () => { + const { result } = await loadedHook(); + + // A stored hidden class must not amputate path members + act(() => { + result.current.toggleClass('friend'); + }); + expect(result.current.nodes.some((n) => n.id === 'user:friend')).toBe(false); + + mockGetPath.mockResolvedValueOnce({ + nodes: [ + { kind: 'user', id: `user:${ME}`, pubky: ME, name: 'Me', image: null }, + { kind: 'user', id: 'user:friend', pubky: 'friend', name: 'Friend', image: null }, + ], + edges: [{ source: `user:${ME}`, target: 'user:friend', type: 'FOLLOWS' }], + }); + await act(async () => { + await result.current.tracePath('friend'); + }); + + const ids = result.current.nodes.map((n) => n.id); + expect(ids).toContain('user:friend'); // hidden class bypassed + expect(ids).toContain(`post:${ME}:p1`); // path users keep their posts + expect(ids).not.toContain('tag:pubky'); // everything else is removed + expect(result.current.opacityTiers.get('user:friend')).toBe('center'); + expect(result.current.opacityTiers.get(`user:${ME}`)).toBe('center'); + + act(() => { + result.current.clearPath(); + }); + // Class hiding applies again; the hub stays out (default view) + expect(result.current.nodes.some((n) => n.id === 'user:friend')).toBe(false); + expect(result.current.nodes.map((n) => n.id)).toEqual([`user:${ME}`, `post:${ME}:p1`]); + }); + + it('anchors sizes on the signed-in user while opacity follows the focus', async () => { + const { result } = await loadedHook(); + + act(() => { + result.current.focus('user:friend'); + }); + + // Sizes never move off the viewer + expect(result.current.sizeTiers.get(`user:${ME}`)).toBe('center'); + expect(result.current.sizeTiers.get('user:friend')).toBe('direct'); + // Opacity re-anchors on the focused user (mutuals, so me reads direct) + expect(result.current.opacityTiers.get('user:friend')).toBe('center'); + expect(result.current.opacityTiers.get(`user:${ME}`)).toBe('direct'); + }); +}); diff --git a/src/hooks/useSocialGraph/useSocialGraph.tsx b/src/hooks/useSocialGraph/useSocialGraph.tsx new file mode 100644 index 0000000000..85b0690c9d --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { GraphController } from '@/controllers/graph/graph'; +import { useGraphCore } from '@/hooks/useGraphCore/useGraphCore'; +import { Logger } from '@/libs/logger/logger'; +import type { Pubky } from '@/models/models.types'; +import { toast } from '@/molecules/Toaster/use-toast'; +import type { NexusGraph, NexusGraphEdge, NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; +import { AUTO_DECLUTTER_EDGES, type TrailEntry, type UseSocialGraphResult } from './useSocialGraph.types'; +import { detectCommunities, dominantLabel, type GraphRelationship, relationshipMap } from './useSocialGraph.utils'; + +function trailEntryOf(node: NexusGraphNode): TrailEntry | null { + if (node.kind !== 'user') return null; + return { id: node.id, pubky: node.pubky, name: node.name, image: node.image }; +} + +/** + * useSocialGraph + * + * State machine of the graph explorer: loads a neighborhood centered on a + * user and layers focus history, search-to-add, and community detection on + * top of the shared graph core (accumulation, expansion, path tracing, and + * the visual-model pipeline live in useGraphCore). + */ +export function useSocialGraph(): UseSocialGraphResult { + const t = useTranslations('graph'); + const [focusId, setFocusId] = useState(null); + const [trail, setTrail] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(false); + const autoDecluttered = useRef(false); + + const { currentUserPubky: viewerPubky } = useAuthStore(); + const meNodeId = viewerPubky ? `user:${viewerPubky}` : null; + + // Opacity tiers derive from the FOLLOWS topology around the focus + const deriveRelationships = useCallback( + (nodeIds: string[], edges: NexusGraphEdge[]): Map => + relationshipMap(focusId ?? '', nodeIds, edges), + [focusId], + ); + // Sizes/chip counts stay anchored on the signed-in user; signed-out deep + // links fall back to the focus so the center still reads 64px + const deriveSizeRelationships = useCallback( + (nodeIds: string[], edges: NexusGraphEdge[]): Map => + relationshipMap(meNodeId ?? focusId ?? '', nodeIds, edges), + [meNodeId, focusId], + ); + const resolveAnchor = useCallback( + (_graph: NexusGraph, parent: NexusGraphNode | null) => focusId ?? parent?.id ?? '', + [focusId], + ); + + const core = useGraphCore({ + logTag: 'useSocialGraph', + focusId, + resolveAnchor, + deriveRelationships, + deriveSizeRelationships, + exemptFocus: true, + }); + const { + graph, + setGraph, + loadNonce, + currentUserPubky, + expandedIds, + expand, + setExpandedIds, + setPathIds, + setIsExpanding, + mergeNeighborhood, + select, + setTimeCap, + setDeclutter, + edges, + } = core; + + const load = useCallback( + async (pubky: Pubky) => { + const nonce = ++loadNonce.current; + setIsLoading(true); + setError(false); + select(null); + setPathIds(null); + setTimeCap(null); + try { + const neighborhood = await GraphController.fetchNeighborhood( + { kind: 'user', id: pubky, depth: 1, ...(core.fetchKinds ? { kinds: core.fetchKinds } : {}) }, + currentUserPubky, + ); + if (nonce !== loadNonce.current) return; + setGraph(neighborhood); + setFocusId(`user:${pubky}`); + setExpandedIds(new Set([`user:${pubky}`])); + const center = neighborhood.nodes.find((n) => n.id === `user:${pubky}`); + const entry = center && trailEntryOf(center); + setTrail(entry ? [entry] : []); + } catch (err) { + if (nonce !== loadNonce.current) return; + Logger.error('useSocialGraph: failed to load graph', err); + setError(true); + } finally { + if (nonce === loadNonce.current) setIsLoading(false); + } + }, + [loadNonce, currentUserPubky, core.fetchKinds, select, setPathIds, setTimeCap, setGraph, setExpandedIds], + ); + + const focus = useCallback( + (nodeId: string) => { + const node = graph.nodes.find((n) => n.id === nodeId && n.kind === 'user'); + if (!node) return; + setFocusId(nodeId); + const entry = trailEntryOf(node); + if (entry) { + setTrail((prev) => (prev.at(-1)?.id === nodeId ? prev : [...prev, entry])); + } + }, + [graph], + ); + + /** Search-to-add: merge a user's neighborhood in and make them the focus. */ + const addUser = useCallback( + async (pubky: Pubky) => { + const nodeId = `user:${pubky}`; + const existing = graph.nodes.find((n) => n.id === nodeId); + if (existing) { + focus(nodeId); + return; + } + const nonce = loadNonce.current; + setIsExpanding(true); + try { + const neighborhood = await GraphController.fetchNeighborhood( + { kind: 'user', id: pubky, depth: 1, ...(core.fetchKinds ? { kinds: core.fetchKinds } : {}) }, + currentUserPubky, + ); + if (nonce !== loadNonce.current) return; + // Anchor the prune on the incoming center: a disconnected search-added + // cluster is otherwise "infinitely far" from the old focus and gets + // evicted the moment it lands + mergeNeighborhood(neighborhood, null, nodeId); + setExpandedIds((prev) => new Set(prev).add(nodeId)); + setFocusId(nodeId); + const center = neighborhood.nodes.find((n) => n.id === nodeId); + const entry = center && trailEntryOf(center); + if (entry) setTrail((prev) => (prev.at(-1)?.id === nodeId ? prev : [...prev, entry])); + } catch (err) { + Logger.error('useSocialGraph: failed to add user', err); + toast({ description: t('states.expandError') }); + } finally { + setIsExpanding(false); + } + }, + [graph, focus, loadNonce, currentUserPubky, core.fetchKinds, mergeNeighborhood, setExpandedIds, setIsExpanding, t], + ); + + /** + * Design behavior: single click on a user centers + focuses them. Re-anchors + * opacity tiers, moves the ring, and (once) pulls in their neighborhood, + * pruning around the clicked node rather than the previous focus. The + * camera flight is the template's job (it owns the canvas handle). + */ + const recenter = useCallback( + async (nodeId: string) => { + const node = graph.nodes.find((n) => n.id === nodeId && n.kind === 'user'); + if (!node) return; + focus(nodeId); + if (!expandedIds.has(nodeId)) await expand(nodeId, nodeId); + }, + [graph, focus, expandedIds, expand], + ); + + /** Search-to-add for tags: shared core behavior (chip click / search). */ + const addTag = core.addTag; + + const { communitiesOn, toggleCommunities } = useGraphStore(); + const { communities, communityLabels } = useMemo(() => { + if (!communitiesOn) return { communities: null, communityLabels: new Map() }; + // Detected on the raw graph: community structure should not churn (nor + // Louvain re-run 20 times a second) while the time machine scrubs or a + // legend class is toggled; the canvas only halos visible members anyway + const communities = detectCommunities( + graph.nodes.map((n) => n.id), + graph.edges, + ); + const members = new Map>(); + for (const [id, community] of communities) { + if (!members.has(community)) members.set(community, new Set()); + members.get(community)!.add(id); + } + const communityLabels = new Map(); + for (const [community, ids] of members) { + if (ids.size < 3) continue; // captioning pairs is noise + const label = dominantLabel(ids, graph.edges); + if (label) communityLabels.set(community, label); + } + return { communities, communityLabels }; + }, [communitiesOn, graph]); + + // Dense graphs start decluttered; the user can always toggle back. + // Satellite HAS_TAG spokes do not count: they scale with visible users by + // design and would silently halve the effective threshold. + const realEdgeCount = useMemo(() => edges.reduce((n, e) => n + (e.type === 'HAS_TAG' ? 0 : 1), 0), [edges]); + useEffect(() => { + if (autoDecluttered.current || realEdgeCount <= AUTO_DECLUTTER_EDGES) return; + autoDecluttered.current = true; + setDeclutter(true); + toast({ description: t('states.autoDeclutter') }); + }, [realEdgeCount, setDeclutter, t]); + + return { + nodes: core.nodes, + edges, + focusId, + selectedNode: core.selectedNode, + expandedIds: core.expandedIds, + relationships: core.relationships, + opacityTiers: core.opacityTiers, + sizeTiers: core.sizeTiers, + classCounts: core.classCounts, + trail, + pathIds: core.pathIds, + communities, + communityLabels, + timeBounds: core.timeBounds, + timelineStamps: core.timelineStamps, + timeCap: core.timeCap, + declutter: core.declutter, + hiddenClasses: core.hiddenClasses, + communitiesOn, + isLoading, + isExpanding: core.isExpanding, + isTracing: core.isTracing, + error, + load, + expand: core.expand, + refreshNode: core.refreshNode, + addUser, + addTag, + focus, + recenter, + select, + toggleClass: core.toggleClass, + toggleDeclutter: core.toggleDeclutter, + setTimeCap, + toggleCommunities, + tracePath: core.tracePath, + clearPath: core.clearPath, + }; +} + +export type { GraphRelationship }; diff --git a/src/hooks/useSocialGraph/useSocialGraph.types.ts b/src/hooks/useSocialGraph/useSocialGraph.types.ts new file mode 100644 index 0000000000..466e012eb1 --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.types.ts @@ -0,0 +1,83 @@ +import type { Pubky } from '@/models/models.types'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import type { GraphNodeClass } from '@/stores/graph/graph.types'; +import type { GraphRelationship, GraphTier, SocialGraphVisualEdge, VisualGraphNode } from './useSocialGraph.utils'; + +/** Client-side cap on rendered nodes; merges beyond it evict far nodes. */ +export const MAX_CLIENT_NODES = 400; + +/** Visible-edge threshold that auto-engages declutter (once per session). */ +export const AUTO_DECLUTTER_EDGES = 600; + +/** Everything the legend can hide: relationship classes plus node kinds (store-persisted). */ +export type HideableClass = GraphNodeClass; + +/** One hop of the focus history. */ +export type TrailEntry = { + id: string; + pubky: Pubky; + name: string; + image: string | null; +}; + +export type UseSocialGraphResult = { + /** Nodes after time, class, and declutter filtering, plus derived tag-chip satellites */ + nodes: VisualGraphNode[]; + /** Edges after filtering, mutual-FOLLOWS collapsing, and tag aggregation */ + edges: SocialGraphVisualEdge[]; + /** Prefixed id (`user:{pubky}`) of the user the view is centered on */ + focusId: string | null; + selectedNode: NexusGraphNode | null; + expandedIds: Set; + /** Relationship of every node to the focus (pre class-hiding, so the legend can count hidden classes) */ + relationships: Map; + /** Focus-anchored opacity tier per visible node; path mode forces all 'center' */ + opacityTiers: Map; + /** Signed-in-anchored size/chip tier per visible node */ + sizeTiers: Map; + /** Visible entity counts per legend class */ + classCounts: Map; + /** Focus history; click a chip to hop back */ + trail: TrailEntry[]; + /** Path-ordered node ids of the last traced path, null when none */ + pathIds: string[] | null; + /** nodeId -> community index when communities are on */ + communities: Map | null; + /** community index -> dominant tag label */ + communityLabels: Map; + /** Oldest/newest timestamp across the raw graph, null when no data */ + timeBounds: { min: number; max: number } | null; + /** Sorted raw-graph event timestamps for constant-rate playback */ + timelineStamps: number[]; + /** Current time-machine cap, null = live view */ + timeCap: number | null; + declutter: boolean; + hiddenClasses: Set; + communitiesOn: boolean; + isLoading: boolean; + isExpanding: boolean; + isTracing: boolean; + error: boolean; + /** (Re)load the graph centered on a user */ + load: (pubky: Pubky) => void; + /** Fetch a node's own neighborhood and merge it into the view, pruning around anchorId when given */ + expand: (nodeId: string, anchorId?: string) => Promise; + /** Re-fetch a node's neighborhood even if it was already expanded */ + refreshNode: (nodeId: string) => Promise; + /** Merge a user's neighborhood in (search-to-add) and focus them */ + addUser: (pubky: Pubky) => Promise; + /** Merge a tag's neighborhood in (search-to-add) and select it */ + addTag: (label: string) => Promise; + /** Re-derive relationship colors around a user node and record the hop */ + focus: (nodeId: string) => void; + /** Design click behavior: focus + one-time expand pruned around the clicked user */ + recenter: (nodeId: string) => Promise; + select: (nodeId: string | null) => void; + toggleClass: (cls: HideableClass) => void; + toggleDeclutter: () => void; + setTimeCap: (cap: number | null) => void; + toggleCommunities: () => void; + /** Shortest-path trace from the signed-in user to a target user node */ + tracePath: (targetPubky: Pubky) => Promise; + clearPath: () => void; +}; diff --git a/src/hooks/useSocialGraph/useSocialGraph.utils.test.ts b/src/hooks/useSocialGraph/useSocialGraph.utils.test.ts new file mode 100644 index 0000000000..c08820df9c --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.utils.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import type { NexusGraph, NexusGraphEdge } from '@/services/nexus/graph/graph.types'; +import { adjacencyOf, collapseMutualFollows, mergeGraph, pruneToBudget, relationshipMap } from './useSocialGraph.utils'; + +const user = (pubky: string, name = pubky) => ({ + kind: 'user' as const, + id: `user:${pubky}`, + pubky, + name, + image: null, +}); + +const follows = (source: string, target: string): NexusGraphEdge => ({ + source: `user:${source}`, + target: `user:${target}`, + type: 'FOLLOWS', +}); + +describe('mergeGraph', () => { + it('preserves existing node object identity and appends only new nodes', () => { + const existing = user('alice'); + const prev: NexusGraph = { nodes: [existing], edges: [] }; + const incoming: NexusGraph = { nodes: [user('alice', 'Alice Fresh'), user('bob')], edges: [] }; + + const merged = mergeGraph(prev, incoming); + + expect(merged.nodes).toHaveLength(2); + // Same object reference: force-graph stores simulation coordinates on the + // node objects, so replacing them would reset the layout. + expect(merged.nodes[0]).toBe(existing); + expect(merged.nodes[1].id).toBe('user:bob'); + }); + + it('dedupes edges by source, type, target and label', () => { + const tagged: NexusGraphEdge = { source: 'user:a', target: 'user:b', type: 'TAGGED', label: 'legend' }; + const taggedOther: NexusGraphEdge = { source: 'user:a', target: 'user:b', type: 'TAGGED', label: 'dev' }; + const prev: NexusGraph = { nodes: [user('a'), user('b')], edges: [follows('a', 'b'), tagged] }; + const incoming: NexusGraph = { nodes: [], edges: [follows('a', 'b'), { ...tagged }, taggedOther] }; + + const merged = mergeGraph(prev, incoming); + + expect(merged.edges).toHaveLength(3); + expect(merged.edges).toContainEqual(taggedOther); + }); +}); + +describe('collapseMutualFollows', () => { + it('collapses a mutual FOLLOWS pair into one canonical FRIEND edge', () => { + const result = collapseMutualFollows([follows('b', 'a'), follows('a', 'b'), follows('a', 'c')]); + + const friends = result.filter((e) => e.type === 'FRIEND'); + expect(friends).toHaveLength(1); + // Canonical direction: lexicographically smaller endpoint first + expect(friends[0]).toMatchObject({ source: 'user:a', target: 'user:b' }); + + const plainFollows = result.filter((e) => e.type === 'FOLLOWS'); + expect(plainFollows).toHaveLength(1); + expect(plainFollows[0]).toMatchObject({ source: 'user:a', target: 'user:c' }); + }); + + it('leaves non-FOLLOWS edges untouched', () => { + const authored: NexusGraphEdge = { source: 'user:a', target: 'post:a:1', type: 'AUTHORED' }; + expect(collapseMutualFollows([authored])).toEqual([authored]); + }); +}); + +describe('relationshipMap', () => { + it('classifies nodes relative to the focus', () => { + const nodeIds = ['user:me', 'user:friend', 'user:idol', 'user:fan', 'user:stranger']; + const edges = [follows('me', 'friend'), follows('friend', 'me'), follows('me', 'idol'), follows('fan', 'me')]; + + const map = relationshipMap('user:me', nodeIds, edges); + + expect(map.get('user:me')).toBe('self'); + expect(map.get('user:friend')).toBe('friend'); + expect(map.get('user:idol')).toBe('following'); + expect(map.get('user:fan')).toBe('follower'); + expect(map.get('user:stranger')).toBe('extended'); + }); +}); + +describe('adjacencyOf', () => { + it('collects neighbors in both directions', () => { + const edges = [follows('a', 'b'), follows('c', 'a'), follows('b', 'c')]; + expect(adjacencyOf('user:a', edges)).toEqual(new Set(['user:b', 'user:c'])); + }); +}); + +describe('pruneToBudget', () => { + const chain: NexusGraph = { + nodes: [user('focus'), user('a'), user('b'), user('c')], + edges: [follows('focus', 'a'), follows('a', 'b'), follows('b', 'c')], + }; + + it('returns the graph unchanged when under budget', () => { + const { graph, pruned } = pruneToBudget(chain, { focusId: 'user:focus' }, 10); + expect(graph).toEqual(chain); + expect(pruned).toBe(0); + }); + + it('evicts the nodes farthest from the focus first, dropping their edges', () => { + const { graph, pruned, evictedIds } = pruneToBudget(chain, { focusId: 'user:focus' }, 3); + + expect(pruned).toBe(1); + expect([...evictedIds]).toEqual(['user:c']); + expect(graph.nodes.map((n) => n.id)).toEqual(['user:focus', 'user:a', 'user:b']); + expect(graph.edges).toHaveLength(2); + }); + + it('never evicts the focus, the selection, or expanded nodes', () => { + const { graph } = pruneToBudget( + chain, + { focusId: 'user:focus', selectedId: 'user:c', expandedIds: new Set(['user:b']) }, + 3, + ); + + const ids = graph.nodes.map((n) => n.id); + expect(ids).toContain('user:focus'); + expect(ids).toContain('user:b'); + expect(ids).toContain('user:c'); + expect(ids).not.toContain('user:a'); + }); + + it('evicts unreachable nodes before reachable ones', () => { + const withIsland: NexusGraph = { + nodes: [...chain.nodes, user('island')], + edges: chain.edges, + }; + + const { graph } = pruneToBudget(withIsland, { focusId: 'user:focus' }, 4); + + expect(graph.nodes.map((n) => n.id)).not.toContain('user:island'); + expect(graph.nodes).toHaveLength(4); + }); +}); diff --git a/src/hooks/useSocialGraph/useSocialGraph.utils.ts b/src/hooks/useSocialGraph/useSocialGraph.utils.ts new file mode 100644 index 0000000000..4be81e515d --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.utils.ts @@ -0,0 +1,555 @@ +import Graph from 'graphology'; +import louvain from 'graphology-communities-louvain'; +import type { Pubky } from '@/models/models.types'; +import type { + NexusGraph, + NexusGraphEdge, + NexusGraphNode, + NexusGraphUserNode, +} from '@/services/nexus/graph/graph.types'; +import type { NexusTag } from '@/services/nexus/nexus.types'; + +/** How a user node relates to the focused user. */ +export type GraphRelationship = 'self' | 'friend' | 'following' | 'follower' | 'extended'; + +/** Opacity/size tier of a user cluster relative to an anchor user. */ +export type GraphTier = 'center' | 'direct' | 'other'; + +/** Visual edge model: mutual FOLLOWS pairs collapse into a single FRIEND edge. */ +export type SocialGraphVisualEdge = Omit & { + type: NexusGraphEdge['type'] | 'FRIEND' | 'HAS_TAG'; + /** All tag labels carried by an aggregated user-to-user TAGGED edge */ + labels?: string[]; +}; + +/** + * Client-derived per-user profile-tag chip. Never stored in the accumulated + * graph state: satellites are re-derived from local tag data each recompute + * and keep object identity through a per-hook cache so the simulation never + * resets their positions. + */ +export type SatelliteTagNode = { + kind: 'profile_tag'; + /** `ptag:{pubky}:{label}` */ + id: string; + pubky: Pubky; + label: string; + count: number; +}; + +/** Everything the canvas can be handed as a node. */ +export type VisualGraphNode = NexusGraphNode | SatelliteTagNode; + +/** Canonical identity of an edge, shared by merge dedup and edge spotlights. */ +export const edgeKey = (edge: { source: string; target: string; type: string; label?: string }) => + `${edge.source}|${edge.type}|${edge.target}|${edge.label ?? ''}`; + +/** + * Merges an incoming neighborhood into the accumulated graph. + * + * Existing node objects are kept by reference (not replaced): force-graph + * stores simulation coordinates on the node objects themselves, so swapping + * them would reset the layout on every expansion. + */ +export function mergeGraph(prev: NexusGraph, incoming: NexusGraph): NexusGraph { + const nodesById = new Map(prev.nodes.map((node) => [node.id, node])); + for (const node of incoming.nodes) { + if (!nodesById.has(node.id)) nodesById.set(node.id, node); + } + + const edgesByKey = new Map(prev.edges.map((edge) => [edgeKey(edge), edge])); + for (const edge of incoming.edges) { + const key = edgeKey(edge); + if (!edgesByKey.has(key)) edgesByKey.set(key, edge); + } + + return { nodes: [...nodesById.values()], edges: [...edgesByKey.values()] }; +} + +/** + * Collapses mutual FOLLOWS pairs into one FRIEND edge (canonical direction: + * lexicographically smaller endpoint first) so friendship renders as a single + * thick arrowless link instead of two overlapping arrows. + */ +export function collapseMutualFollows(edges: SocialGraphVisualEdge[]): SocialGraphVisualEdge[] { + const followPairs = new Set(); + for (const edge of edges) { + if (edge.type === 'FOLLOWS') followPairs.add(`${edge.source}>${edge.target}`); + } + + const result: SocialGraphVisualEdge[] = []; + const emittedFriends = new Set(); + for (const edge of edges) { + if (edge.type !== 'FOLLOWS') { + result.push(edge); + continue; + } + if (!followPairs.has(`${edge.target}>${edge.source}`)) { + result.push(edge); + continue; + } + const [source, target] = [edge.source, edge.target].sort(); + const key = `${source}>${target}`; + if (!emittedFriends.has(key)) { + emittedFriends.add(key); + result.push({ source, target, type: 'FRIEND' }); + } + } + return result; +} + +/** Classifies every node id relative to the focused user via FOLLOWS edges. */ +export function relationshipMap( + focusId: string, + nodeIds: string[], + edges: NexusGraphEdge[], +): Map { + const followsOut = new Set(); + const followsIn = new Set(); + for (const edge of edges) { + if (edge.type !== 'FOLLOWS') continue; + if (edge.source === focusId) followsOut.add(edge.target); + if (edge.target === focusId) followsIn.add(edge.source); + } + + const map = new Map(); + for (const id of nodeIds) { + if (id === focusId) map.set(id, 'self'); + else if (followsOut.has(id) && followsIn.has(id)) map.set(id, 'friend'); + else if (followsOut.has(id)) map.set(id, 'following'); + else if (followsIn.has(id)) map.set(id, 'follower'); + else map.set(id, 'extended'); + } + return map; +} + +/** Neighbor node ids of a node, in both edge directions. */ +export function adjacencyOf(nodeId: string, edges: Pick[]): Set { + const neighbors = new Set(); + for (const edge of edges) { + if (edge.source === nodeId) neighbors.add(edge.target); + if (edge.target === nodeId) neighbors.add(edge.source); + } + return neighbors; +} + +export type PruneAnchors = { + focusId: string; + selectedId?: string | null; + expandedIds?: Set; +}; + +/** + * Caps the graph at `budget` nodes by evicting the nodes farthest (BFS from + * the focus, undirected) first; unreachable nodes go before reachable ones. + * The focus, current selection, and already-expanded nodes are never evicted. + * Edges incident to an evicted node are dropped with it. + */ +export function pruneToBudget( + graph: NexusGraph, + anchors: PruneAnchors, + budget: number, +): { graph: NexusGraph; pruned: number; evictedIds: Set } { + if (graph.nodes.length <= budget) return { graph, pruned: 0, evictedIds: new Set() }; + + // BFS distances from the focus over the undirected edge set + const distance = new Map([[anchors.focusId, 0]]); + let frontier = [anchors.focusId]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + for (const neighbor of adjacencyOf(id, graph.edges)) { + if (!distance.has(neighbor)) { + distance.set(neighbor, (distance.get(id) ?? 0) + 1); + next.push(neighbor); + } + } + } + frontier = next; + } + + const protectedIds = new Set([anchors.focusId]); + if (anchors.selectedId) protectedIds.add(anchors.selectedId); + for (const id of anchors.expandedIds ?? []) protectedIds.add(id); + + const evictable = graph.nodes + .map((node) => node.id) + .filter((id) => !protectedIds.has(id)) + // Farthest first; unreachable (no distance) counts as infinitely far + .sort((a, b) => (distance.get(b) ?? Infinity) - (distance.get(a) ?? Infinity)); + + const toEvict = new Set(evictable.slice(0, graph.nodes.length - budget)); + const nodes = graph.nodes.filter((node) => !toEvict.has(node.id)); + const edges = graph.edges.filter((edge) => !toEvict.has(edge.source) && !toEvict.has(edge.target)); + + return { graph: { nodes, edges }, pruned: toEvict.size, evictedIds: toEvict }; +} + +/** + * Collapses parallel TAGGED edges between one node pair into a single edge + * carrying all its labels (a "5 tags" chip beats five overlapping curves). + * Hub edges out of tag nodes pass through untouched; every kept user/post + * TAGGED edge gains a `labels` array, even singletons, so the renderer has + * one shape to deal with. + */ +export function aggregateParallelEdges(edges: SocialGraphVisualEdge[]): SocialGraphVisualEdge[] { + const result: SocialGraphVisualEdge[] = []; + const groups = new Map(); + + for (const edge of edges) { + const isHub = edge.source.startsWith('tag:') || edge.target.startsWith('tag:'); + if (edge.type !== 'TAGGED' || isHub) { + result.push(edge); + continue; + } + const key = [edge.source, edge.target].sort().join('|'); + const group = groups.get(key); + if (!group) { + // Keep the true tagger-to-tagged direction; it only becomes ambiguous + // (and the canvas drops the arrowhead) once a second label joins in + const created: SocialGraphVisualEdge = { + source: edge.source, + target: edge.target, + type: 'TAGGED', + label: edge.label, + labels: edge.label ? [edge.label] : [], + ...(edge.indexed_at !== undefined ? { indexed_at: edge.indexed_at } : {}), + }; + groups.set(key, created); + result.push(created); + continue; + } + if (edge.label && !group.labels?.includes(edge.label)) group.labels?.push(edge.label); + if (edge.indexed_at !== undefined && (group.indexed_at === undefined || edge.indexed_at < group.indexed_at)) { + group.indexed_at = edge.indexed_at; + } + } + + for (const group of groups.values()) { + if ((group.labels?.length ?? 0) > 1) { + // Multi-label edges canonicalize so the pair merges regardless of direction + const [a, b] = [group.source, group.target].sort(); + group.source = a; + group.target = b; + group.labels?.sort(); + group.label = group.labels?.[0]; + } + } + return result; +} + +/** + * Time machine filter: hides edges and posts newer than `cap`, then users and + * tags left without a single visible edge (the center always survives). + * A null cap is a no-op. + */ +export function applyTimeCap( + nodes: NexusGraphNode[], + edges: NexusGraphEdge[], + cap: number | null, + centerId: string | null, +): { nodes: NexusGraphNode[]; edges: NexusGraphEdge[] } { + if (cap === null) return { nodes, edges }; + + const keptPosts = new Set(nodes.filter((n) => n.kind !== 'post' || n.indexed_at <= cap).map((n) => n.id)); + const timedEdges = edges.filter( + (e) => (e.indexed_at === undefined || e.indexed_at <= cap) && keptPosts.has(e.source) && keptPosts.has(e.target), + ); + + const withEdges = new Set(); + for (const edge of timedEdges) { + withEdges.add(edge.source); + withEdges.add(edge.target); + } + + const keptNodes = nodes.filter((node) => { + if (node.id === centerId) return true; + if (node.kind === 'post') return keptPosts.has(node.id) && withEdges.has(node.id); + return withEdges.has(node.id); + }); + const keptIds = new Set(keptNodes.map((n) => n.id)); + return { + nodes: keptNodes, + edges: timedEdges.filter((e) => keptIds.has(e.source) && keptIds.has(e.target)), + }; +} + +const DECLUTTER_STALE_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * One-button declutter: drops posts older than 30 days and extended users + * hanging off a single edge, then any edge that lost an endpoint. + */ +export function applyDeclutter( + nodes: NexusGraphNode[], + edges: NexusGraphEdge[], + relationships: Map, + nowMs: number, +): { nodes: NexusGraphNode[]; edges: NexusGraphEdge[] } { + const degree = new Map(); + for (const edge of edges) { + degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); + degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); + } + + const keptNodes = nodes.filter((node) => { + if (node.kind === 'post') return nowMs - node.indexed_at <= DECLUTTER_STALE_MS; + if (node.kind === 'user' && relationships.get(node.id) === 'extended') { + return (degree.get(node.id) ?? 0) > 1; + } + return true; + }); + const keptIds = new Set(keptNodes.map((n) => n.id)); + return { + nodes: keptNodes, + edges: edges.filter((e) => keptIds.has(e.source) && keptIds.has(e.target)), + }; +} + +/** + * Community detection over the undirected FOLLOWS/FRIEND subgraph via Louvain + * (graphology). Returns nodeId -> community index, renumbered by size with 0 + * as the largest community. + */ +export function detectCommunities( + nodeIds: string[], + edges: Pick[], +): Map { + const ids = nodeIds.filter((id) => id.startsWith('user:')); + const graph = new Graph({ type: 'undirected', multi: false }); + for (const id of ids) graph.addNode(id); + for (const edge of edges) { + if (edge.type !== 'FOLLOWS' && edge.type !== 'FRIEND') continue; + if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue; + if (!graph.hasEdge(edge.source, edge.target)) graph.addEdge(edge.source, edge.target); + } + if (graph.order === 0) return new Map(); + + const assignments = louvain(graph, { rng: () => 0.5 }); + + // Renumber communities by size, largest first + const sizes = new Map(); + for (const community of Object.values(assignments)) { + sizes.set(community, (sizes.get(community) ?? 0) + 1); + } + const order = [...sizes.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c); + const rank = new Map(order.map((c, i) => [c, i])); + return new Map(Object.entries(assignments).map(([id, c]) => [id, rank.get(c)!])); +} + +/** Most used tag label among a community's members (edges into or between them). */ +export function dominantLabel( + members: Set, + edges: Pick[], +): string | null { + const counts = new Map(); + for (const edge of edges) { + const between = members.has(edge.source) && members.has(edge.target); + const viaHub = + (edge.source.startsWith('tag:') && members.has(edge.target)) || + (edge.target.startsWith('tag:') && members.has(edge.source)); + if (!between && !viaHub) continue; + for (const l of edge.labels ?? (edge.label ? [edge.label] : [])) { + counts.set(l, (counts.get(l) ?? 0) + 1); + } + } + let best: string | null = null; + let bestCount = 0; + for (const [l, count] of counts) { + if (count > bestCount) { + best = l; + bestCount = count; + } + } + return best; +} + +/** Maps a focus-relative relationship onto the design's three visual tiers. */ +export const tierOf = (relationship: GraphRelationship | undefined): GraphTier => { + if (relationship === 'self') return 'center'; + if (relationship === 'friend' || relationship === 'following' || relationship === 'follower') return 'direct'; + return 'other'; +}; + +/** Satellite tag chips shown per user, by tier (design: top 3 / 2 / 1). */ +export const TAG_SATELLITES_BY_TIER: Record = { center: 3, direct: 2, other: 1 }; + +/** Post nodes kept per author in the default view, by tier (mirrors the tag rule). */ +export const POSTS_BY_TIER: Record = { center: 3, direct: 2, other: 1 }; + +/** + * Top profile tags by tagger count. Label ties break alphabetically so the + * selection is deterministic across recomputes and machines. + */ +export function topProfileTags>(tags: T[], n: number): T[] { + if (n <= 0) return []; + return [...tags].sort((a, b) => b.taggers_count - a.taggers_count || a.label.localeCompare(b.label)).slice(0, n); +} + +/** Deterministic [0,1) hash used to spread satellite spawn angles per label. */ +const labelUnit = (label: string): number => { + let hash = 0; + for (let i = 0; i < label.length; i++) hash = (hash * 31 + label.charCodeAt(i)) >>> 0; + return (hash % 997) / 997; +}; + +type PositionedUserNode = NexusGraphUserNode & { x?: number; y?: number }; +type PositionedSatellite = SatelliteTagNode & { x?: number; y?: number; __bornAt?: number }; + +const SATELLITE_SPAWN_RADIUS = 60; + +/** + * Derives per-user profile-tag satellites for the visible users. Chip node + * objects come from `cache` so their simulation coordinates survive + * recomputes; brand-new chips spawn beside their owner (not at the origin) + * with a birth stamp for the pulse animation. + */ +export function deriveSatellites( + users: PositionedUserNode[], + tiers: Map, + tagsMap: Map, + cache: Map, + nowMs: number, +): { nodes: SatelliteTagNode[]; edges: SocialGraphVisualEdge[] } { + const nodes: SatelliteTagNode[] = []; + const edges: SocialGraphVisualEdge[] = []; + for (const user of users) { + const tier = tiers.get(user.id) ?? 'other'; + const top = topProfileTags(tagsMap.get(user.pubky) ?? [], TAG_SATELLITES_BY_TIER[tier]); + for (const tag of top) { + const id = `ptag:${user.pubky}:${tag.label}`; + let node = cache.get(id); + if (node) { + node.count = tag.taggers_count; + } else { + node = { kind: 'profile_tag', id, pubky: user.pubky, label: tag.label, count: tag.taggers_count }; + if (user.x !== undefined && user.y !== undefined) { + const angle = labelUnit(tag.label) * 2 * Math.PI; + node.x = user.x + Math.cos(angle) * SATELLITE_SPAWN_RADIUS; + node.y = user.y + Math.sin(angle) * SATELLITE_SPAWN_RADIUS; + } + node.__bornAt = nowMs; + cache.set(id, node); + } + nodes.push(node); + edges.push({ source: user.id, target: id, type: 'HAS_TAG' }); + } + } + return { nodes, edges }; +} + +/** + * How-are-we-connected view: keeps only the path users and their own posts, + * dropping everything else outright (the design removes, not dims). Runs + * instead of the class/declutter filters so stored preferences can never + * amputate the chain. + */ +export function applyPathExclusive( + nodes: NexusGraphNode[], + edges: NexusGraphEdge[], + pathIds: Set, +): { nodes: NexusGraphNode[]; edges: NexusGraphEdge[] } { + const keptNodes = nodes.filter((node) => { + if (pathIds.has(node.id)) return true; + return node.kind === 'post' && pathIds.has(`user:${node.author_id}`); + }); + const keptIds = new Set(keptNodes.map((n) => n.id)); + return { + nodes: keptNodes, + edges: edges.filter((e) => keptIds.has(e.source) && keptIds.has(e.target)), + }; +} + +/** + * Default-view cap on post satellites per author (newest first, by tier). + * Advanced mode lifts it, and posts whose author left the visible set keep + * their existing pruning path. + */ +export function postTierCap( + nodes: NexusGraphNode[], + edges: NexusGraphEdge[], + tiers: Map, +): { nodes: NexusGraphNode[]; edges: NexusGraphEdge[] } { + const postsByAuthor = new Map(); + for (const node of nodes) { + if (node.kind !== 'post') continue; + const owner = `user:${node.author_id}`; + const list = postsByAuthor.get(owner); + if (list) list.push(node); + else postsByAuthor.set(owner, [node]); + } + + const dropped = new Set(); + for (const [owner, posts] of postsByAuthor) { + const cap = POSTS_BY_TIER[tiers.get(owner) ?? 'other']; + if (posts.length <= cap) continue; + const byRecency = [...posts].sort( + (a, b) => (b.kind === 'post' ? b.indexed_at : 0) - (a.kind === 'post' ? a.indexed_at : 0), + ); + for (const post of byRecency.slice(cap)) dropped.add(post.id); + } + if (dropped.size === 0) return { nodes, edges }; + + return { + nodes: nodes.filter((n) => !dropped.has(n.id)), + edges: edges.filter((e) => !dropped.has(e.source) && !dropped.has(e.target)), + }; +} + +/** + * Facepile candidates for the hover card, strictly from edges already on + * canvas: users following (or followed by) the target, viewer-followed first, + * capped at `cap`. FRIEND edges count in both directions. + */ +export function facepileCandidates( + targetId: string, + edges: Pick[], + meId: string | null, + direction: 'followers' | 'following', + cap = 3, +): string[] { + const related = new Set(); + const iFollow = new Set(); + for (const edge of edges) { + if (edge.type === 'FOLLOWS') { + if (direction === 'followers' && edge.target === targetId) related.add(edge.source); + if (direction === 'following' && edge.source === targetId) related.add(edge.target); + if (meId && edge.source === meId) iFollow.add(edge.target); + } else if (edge.type === 'FRIEND') { + if (edge.source === targetId) related.add(edge.target); + if (edge.target === targetId) related.add(edge.source); + if (meId && edge.source === meId) iFollow.add(edge.target); + if (meId && edge.target === meId) iFollow.add(edge.source); + } + } + related.delete(targetId); + if (meId) related.delete(meId); + return [...related] + .sort((a, b) => Number(iFollow.has(b)) - Number(iFollow.has(a)) || a.localeCompare(b)) + .slice(0, cap); +} + +/** + * People `meId` follows who follow `targetId`, from edges already on canvas. + * FRIEND edges count as follows in both directions. + */ +export function socialProof( + meId: string, + targetId: string, + edges: Pick[], +): string[] { + const iFollow = new Set(); + const followsTarget = new Set(); + for (const edge of edges) { + if (edge.type === 'FOLLOWS') { + if (edge.source === meId) iFollow.add(edge.target); + if (edge.target === targetId) followsTarget.add(edge.source); + } else if (edge.type === 'FRIEND') { + if (edge.source === meId) iFollow.add(edge.target); + if (edge.target === meId) iFollow.add(edge.source); + if (edge.source === targetId) followsTarget.add(edge.target); + if (edge.target === targetId) followsTarget.add(edge.source); + } + } + return [...iFollow].filter((id) => followsTarget.has(id) && id !== meId && id !== targetId); +} diff --git a/src/hooks/useSocialGraph/useSocialGraph.viewmodel.test.ts b/src/hooks/useSocialGraph/useSocialGraph.viewmodel.test.ts new file mode 100644 index 0000000000..a3dfa04e66 --- /dev/null +++ b/src/hooks/useSocialGraph/useSocialGraph.viewmodel.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import type { NexusGraph, NexusGraphEdge } from '@/services/nexus/graph/graph.types'; +import { + aggregateParallelEdges, + applyDeclutter, + applyTimeCap, + detectCommunities, + dominantLabel, + socialProof, +} from './useSocialGraph.utils'; + +const user = (pubky: string) => ({ + kind: 'user' as const, + id: `user:${pubky}`, + pubky, + name: pubky, + image: null, +}); + +const post = (author: string, id: string, indexed_at: number) => ({ + kind: 'post' as const, + id: `post:${author}:${id}`, + author_id: author, + post_id: id, + content: 'x', + post_kind: 'short', + is_reply: false, + indexed_at, +}); + +const follows = (a: string, b: string, indexed_at?: number): NexusGraphEdge => ({ + source: `user:${a}`, + target: `user:${b}`, + type: 'FOLLOWS', + ...(indexed_at !== undefined ? { indexed_at } : {}), +}); + +const tagged = (a: string, b: string, label: string): NexusGraphEdge => ({ + source: `user:${a}`, + target: `user:${b}`, + type: 'TAGGED', + label, +}); + +describe('aggregateParallelEdges', () => { + it('merges parallel TAGGED edges between one pair into a single labeled group', () => { + const edges = [tagged('a', 'b', 'dev'), tagged('a', 'b', 'legend'), tagged('b', 'a', 'bitcoin'), follows('a', 'b')]; + + const result = aggregateParallelEdges(edges); + + const groups = result.filter((e) => e.type === 'TAGGED'); + expect(groups).toHaveLength(1); + expect(groups[0].labels).toEqual(['bitcoin', 'dev', 'legend']); + // FOLLOWS untouched + expect(result.filter((e) => e.type === 'FOLLOWS')).toHaveLength(1); + }); + + it('keeps single TAGGED edges and hub edges as they are', () => { + const hub: NexusGraphEdge = { source: 'tag:dev', target: 'user:a', type: 'TAGGED', label: 'dev' }; + const single = tagged('a', 'b', 'dev'); + + const result = aggregateParallelEdges([hub, single]); + + expect(result).toHaveLength(2); + expect(result.find((e) => e.source === 'tag:dev')).toEqual(hub); + expect(result.find((e) => e.source === 'user:a')?.labels).toEqual(['dev']); + }); + + it('preserves the tagger-to-tagged direction on single-label edges', () => { + // b tagged a: direction must survive aggregation (only groups canonicalize) + const result = aggregateParallelEdges([tagged('b', 'a', 'dev')]); + expect(result[0]).toMatchObject({ source: 'user:b', target: 'user:a' }); + }); +}); + +describe('applyTimeCap', () => { + const graph: NexusGraph = { + nodes: [user('me'), user('early'), user('late'), post('me', 'p1', 50), post('me', 'p2', 200)], + edges: [ + follows('me', 'early', 10), + follows('me', 'late', 100), + { source: 'user:me', target: 'post:me:p1', type: 'AUTHORED' }, + { source: 'user:me', target: 'post:me:p2', type: 'AUTHORED' }, + ], + }; + + it('hides newer edges and posts, and users left without any edge', () => { + const { nodes, edges } = applyTimeCap(graph.nodes, graph.edges, 60, 'user:me'); + + const ids = nodes.map((n) => n.id); + expect(ids).toContain('user:me'); + expect(ids).toContain('user:early'); + expect(ids).not.toContain('user:late'); // its only edge is newer than the cap + expect(ids).toContain('post:me:p1'); + expect(ids).not.toContain('post:me:p2'); + expect(edges).toHaveLength(2); + }); + + it('keeps everything when the cap is null', () => { + const { nodes, edges } = applyTimeCap(graph.nodes, graph.edges, null, 'user:me'); + expect(nodes).toHaveLength(graph.nodes.length); + expect(edges).toHaveLength(graph.edges.length); + }); +}); + +describe('applyDeclutter', () => { + const NOW = 100 * 24 * 60 * 60 * 1000; + const fresh = NOW - 5 * 24 * 60 * 60 * 1000; + const stale = NOW - 60 * 24 * 60 * 60 * 1000; + + it('drops stale posts and barely-connected extended users', () => { + const nodes = [user('me'), user('friend'), user('rando'), post('me', 'new', fresh), post('me', 'old', stale)]; + const edges = [ + follows('me', 'friend'), + follows('friend', 'rando'), + { source: 'user:me', target: 'post:me:new', type: 'AUTHORED' } as NexusGraphEdge, + { source: 'user:me', target: 'post:me:old', type: 'AUTHORED' } as NexusGraphEdge, + ]; + const relationships = new Map([ + ['user:me', 'self'], + ['user:friend', 'friend'], + ['user:rando', 'extended'], + ]); + + const result = applyDeclutter(nodes, edges, relationships, NOW); + + const ids = result.nodes.map((n) => n.id); + expect(ids).toContain('user:me'); + expect(ids).toContain('user:friend'); + expect(ids).not.toContain('user:rando'); // extended with a single edge + expect(ids).toContain('post:me:new'); + expect(ids).not.toContain('post:me:old'); // stale + }); +}); + +describe('detectCommunities', () => { + it('separates two dense clusters joined by a single bridge', () => { + // Triangle a-b-c and triangle x-y-z, bridged by c-x + const edges = [ + follows('a', 'b'), + follows('b', 'c'), + follows('c', 'a'), + follows('x', 'y'), + follows('y', 'z'), + follows('z', 'x'), + follows('c', 'x'), + ]; + const ids = ['user:a', 'user:b', 'user:c', 'user:x', 'user:y', 'user:z']; + + const communities = detectCommunities(ids, edges); + + expect(communities.get('user:a')).toBe(communities.get('user:b')); + expect(communities.get('user:b')).toBe(communities.get('user:c')); + expect(communities.get('user:x')).toBe(communities.get('user:y')); + expect(communities.get('user:y')).toBe(communities.get('user:z')); + expect(communities.get('user:a')).not.toBe(communities.get('user:x')); + }); +}); + +describe('dominantLabel', () => { + it('returns the most used tag label among community members', () => { + const members = new Set(['user:a', 'user:b']); + const edges = [ + tagged('a', 'b', 'bitcoin'), + tagged('b', 'a', 'bitcoin'), + tagged('a', 'b', 'dev'), + tagged('z', 'q', 'nope'), + ]; + + expect(dominantLabel(members, edges)).toBe('bitcoin'); + }); + + it('returns null when members share no labels', () => { + expect(dominantLabel(new Set(['user:a']), [follows('a', 'b')])).toBeNull(); + }); +}); + +describe('socialProof', () => { + it('lists people I follow who follow the target', () => { + const edges: NexusGraphEdge[] = [ + follows('me', 'x'), + follows('x', 'target'), + { source: 'user:me', target: 'user:y', type: 'FRIEND' } as never, + follows('y', 'target'), + follows('me', 'z'), // z does not follow target + follows('stranger', 'target'), // I do not follow stranger + ]; + + expect(socialProof('user:me', 'user:target', edges).sort()).toEqual(['user:x', 'user:y']); + }); +}); diff --git a/src/hooks/useStreamGraph/useStreamGraph.test.tsx b/src/hooks/useStreamGraph/useStreamGraph.test.tsx new file mode 100644 index 0000000000..6203e10a59 --- /dev/null +++ b/src/hooks/useStreamGraph/useStreamGraph.test.tsx @@ -0,0 +1,79 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PostController } from '@/controllers/post/post'; +import { UserController } from '@/controllers/user/user'; +import { useGraphStore } from '@/stores/graph/graph.store'; +import { useStreamGraph } from './useStreamGraph'; + +vi.mock('@/controllers/graph/graph', () => ({ + GraphController: { fetchNeighborhood: vi.fn(), fetchPath: vi.fn() }, +})); +vi.mock('@/controllers/post/post', () => ({ + PostController: { getDetailsByIds: vi.fn(), getRelationships: vi.fn(), getTags: vi.fn() }, +})); +vi.mock('@/controllers/user/user', () => ({ + UserController: { getManyDetails: vi.fn(), getManyRelationships: vi.fn() }, +})); +vi.mock('@/molecules/Toaster/use-toast', () => ({ toast: vi.fn() })); +vi.mock('@/libs/logger/logger', () => ({ + Logger: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: () => ({ currentUserPubky: 'mepubky' }), +})); + +const AUTHOR = 'author1'; +const COMPOSITE = `${AUTHOR}:0032POST1`; + +describe('useStreamGraph', () => { + beforeEach(() => { + vi.clearAllMocks(); + useGraphStore.getState().reset(); + vi.mocked(PostController.getDetailsByIds).mockResolvedValue([ + { id: COMPOSITE, content: 'hello graph', kind: 'short', indexed_at: 100, attachments: null }, + ] as never); + vi.mocked(PostController.getRelationships).mockResolvedValue({ replied: null, reposted: null } as never); + vi.mocked(PostController.getTags).mockResolvedValue([] as never); + vi.mocked(UserController.getManyDetails).mockResolvedValue( + new Map([[AUTHOR, { name: 'Author One', image: null }]]) as never, + ); + vi.mocked(UserController.getManyRelationships).mockResolvedValue( + new Map([[AUTHOR, { following: true, followed_by: false }]]) as never, + ); + }); + + it('synthesizes author + post nodes and always seeds the viewer node', async () => { + const { result } = renderHook(() => useStreamGraph([COMPOSITE])); + + // Stream nodes plus the locally synthesized signed-in user (no edges) + await waitFor(() => expect(result.current.rawNodeCount).toBe(3)); + expect(result.current.nodes.map((n) => n.id).sort()).toEqual([ + `post:${COMPOSITE}`, + `user:${AUTHOR}`, + 'user:mepubky', + ]); + // The seed is a bare node: no neighborhood fetch, no FOLLOWS flood + expect(result.current.edges.filter((e) => e.type === 'FOLLOWS')).toHaveLength(0); + }); + + it('colors users from Dexie relationship flags read through the live query', async () => { + const { result } = renderHook(() => useStreamGraph([COMPOSITE])); + + await waitFor(() => expect(result.current.relationships.get(`user:${AUTHOR}`)).toBe('following')); + // The live query covers every user on canvas, viewer included + expect(UserController.getManyRelationships).toHaveBeenCalledWith({ + userIds: expect.arrayContaining([AUTHOR]) as string[], + }); + }); + + it('keeps every stream post (no design tier cap on the feed)', async () => { + const posts = [0, 1, 2, 3, 4].map((i) => `${AUTHOR}:0032POST${i}`); + vi.mocked(PostController.getDetailsByIds).mockResolvedValue( + posts.map((id, i) => ({ id, content: `p${i}`, kind: 'short', indexed_at: 100 + i, attachments: null })) as never, + ); + const { result } = renderHook(() => useStreamGraph(posts)); + + // All five posts of one author stay visible; the explorer would cap at 3 + await waitFor(() => expect(result.current.nodes.filter((n) => n.kind === 'post')).toHaveLength(5)); + }); +}); diff --git a/src/hooks/useStreamGraph/useStreamGraph.ts b/src/hooks/useStreamGraph/useStreamGraph.ts new file mode 100644 index 0000000000..bd2ff2797f --- /dev/null +++ b/src/hooks/useStreamGraph/useStreamGraph.ts @@ -0,0 +1,292 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { PostController } from '@/controllers/post/post'; +import { UserController } from '@/controllers/user/user'; +import { markBirths, type SimNode, useGraphCore } from '@/hooks/useGraphCore/useGraphCore'; +import { type HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { + type GraphRelationship, + type GraphTier, + mergeGraph, + relationshipMap, + type SocialGraphVisualEdge, + type VisualGraphNode, +} from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { Logger } from '@/libs/logger/logger'; +import type { Pubky } from '@/models/models.types'; +import type { NexusGraph, NexusGraphEdge, NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { type StreamPostInput, streamToGraph, tryParseCompositeId, viewerRelationships } from './useStreamGraph.utils'; + +type ViewerRelFlags = Map; +const EMPTY_RELS: ViewerRelFlags = new Map(); + +export type UseStreamGraphResult = { + nodes: VisualGraphNode[]; + /** Unfiltered node count; grows only on merges (auto-fit trigger) */ + rawNodeCount: number; + edges: SocialGraphVisualEdge[]; + relationships: Map; + /** Focus-anchored opacity tier per visible node; path mode forces all 'center' */ + opacityTiers: Map; + /** Viewer-anchored size/chip tier per visible node */ + sizeTiers: Map; + classCounts: Map; + focusId: string | null; + selectedNode: NexusGraphNode | null; + expandedIds: Set; + pathIds: string[] | null; + timeBounds: { min: number; max: number } | null; + /** Sorted raw-graph event timestamps for constant-rate playback */ + timelineStamps: number[]; + timeCap: number | null; + declutter: boolean; + hiddenClasses: Set; + isExpanding: boolean; + isTracing: boolean; + select: (id: string | null) => void; + expand: (nodeId: string, anchorId?: string) => Promise; + refreshNode: (nodeId: string) => Promise; + /** Design click behavior: focus + one-time expand pruned around the clicked user */ + recenter: (nodeId: string) => Promise; + /** Merge a tag's neighborhood in and select its hub (chip click) */ + addTag: (label: string) => Promise; + tracePath: (pubky: Pubky) => Promise; + clearPath: () => void; + toggleClass: (cls: HideableClass) => void; + toggleDeclutter: () => void; + setTimeCap: (cap: number | null) => void; +}; + +/** + * useStreamGraph + * + * The feed's graph layout: synthesizes a graph from the stream's own cached + * posts (authors, lineage, tag hubs) and merges it into an accumulating + * canvas, so pagination grows the constellation instead of replacing it. + * Expansion, path tracing, and the visual pipeline come from the shared + * graph core; relationship colors read Dexie reactively, so follows and TTL + * refreshes repaint the graph without a reload. + */ +export function useStreamGraph(postIds: string[]): UseStreamGraphResult { + const { currentUserPubky } = useAuthStore(); + const [authorRels, setAuthorRels] = useState(EMPTY_RELS); + // Click-to-center override; null = the viewer (the design's default center) + const [focusOverride, setFocusOverride] = useState(null); + const gatherNonce = useRef(0); + const seededFor = useRef(null); + + const postKey = postIds.join(','); + const meNodeId = currentUserPubky ? `user:${currentUserPubky}` : null; + + // Opacity tiers: viewer-anchored from cached relationship flags by default + // (a stream graph has no FOLLOWS edges to derive from); once a recenter + // targets another user, derive from the FOLLOWS topology their expansion + // merged in + const deriveRelationships = useCallback( + (nodeIds: string[], edges: NexusGraphEdge[]) => { + if (focusOverride && focusOverride !== meNodeId) return relationshipMap(focusOverride, nodeIds, edges); + return viewerRelationships(currentUserPubky, nodeIds, authorRels); + }, + [focusOverride, meNodeId, currentUserPubky, authorRels], + ); + + // Sizes/chip counts always anchor on the signed-in viewer (flags-based) + const deriveSizeRelationships = useCallback( + (nodeIds: string[]) => viewerRelationships(currentUserPubky, nodeIds, authorRels), + [currentUserPubky, authorRels], + ); + + const resolveAnchor = useCallback( + (graph: NexusGraph, parent: NexusGraphNode | null) => { + const meId = currentUserPubky ? `user:${currentUserPubky}` : null; + return parent?.id ?? (meId && graph.nodes.some((n) => n.id === meId) ? meId : (graph.nodes[0]?.id ?? '')); + }, + [currentUserPubky], + ); + + // The focused node (recentered user, else the viewer when present) anchors + // the time-cap exemption and default pruning + const deriveFocusId = useCallback( + (graph: NexusGraph) => { + if (focusOverride && graph.nodes.some((n) => n.id === focusOverride)) return focusOverride; + const meId = currentUserPubky ? `user:${currentUserPubky}` : null; + return meId && graph.nodes.some((n) => n.id === meId) ? meId : null; + }, + [focusOverride, currentUserPubky], + ); + + const core = useGraphCore({ + logTag: 'useStreamGraph', + focusId: deriveFocusId, + resolveAnchor, + deriveRelationships, + deriveSizeRelationships, + // The feed's posts ARE the content; never thin them to the design cap + capPostsByTier: false, + }); + const { graph, setGraph, expandedIds, expand } = core; + + // Design: "Always include and start with signed in user in this visual + // graph, even if user has no recent posts." Only the viewer's NODE is + // seeded, synthesized from local Dexie details: a full neighborhood fetch + // would flood the feed with hundreds of FOLLOWS edges (mesh glare, and it + // trips the auto-declutter threshold meant for the explorer). + useEffect(() => { + if (!currentUserPubky || seededFor.current === currentUserPubky) return; + seededFor.current = currentUserPubky; + (async () => { + try { + const details = await UserController.getManyDetails({ userIds: [currentUserPubky] }); + const me = details.get(currentUserPubky); + core.mergeNeighborhood( + { + nodes: [ + { + kind: 'user', + id: `user:${currentUserPubky}`, + pubky: currentUserPubky, + name: me?.name ?? '', + image: me?.image ?? null, + }, + ], + edges: [], + }, + null, + `user:${currentUserPubky}`, + ); + } catch (err) { + // Non-fatal: the stream synthesis still renders + Logger.error('useStreamGraph: failed to seed viewer node', err); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUserPubky]); + + /** Design click behavior: center + focus a user, expanding them once. */ + const recenter = useCallback( + async (nodeId: string) => { + const node = graph.nodes.find((n) => n.id === nodeId && n.kind === 'user'); + if (!node) return; + setFocusOverride(nodeId === meNodeId ? null : nodeId); + if (!expandedIds.has(nodeId)) await expand(nodeId, nodeId); + }, + [graph, meNodeId, expandedIds, expand], + ); + + // Gather the stream's already-cached data and merge the synthesized graph in + useEffect(() => { + const nonce = ++gatherNonce.current; + if (postIds.length === 0) return; + (async () => { + try { + const details = await PostController.getDetailsByIds({ compositeIds: postIds }); + const posts: StreamPostInput[] = await Promise.all( + postIds.map(async (compositeId, i) => { + const d = details[i]; + const [relationships, tags] = await Promise.all([ + PostController.getRelationships({ compositeId }).catch(() => null), + PostController.getTags({ compositeId }).catch(() => []), + ]); + const author = d ? tryParseCompositeId(d.id)?.pubky : undefined; + return { + compositeId, + details: d && author ? { content: d.content, kind: d.kind, indexed_at: d.indexed_at, author } : null, + repliedUri: relationships?.replied ?? null, + repostedUri: relationships?.reposted ?? null, + tagLabels: (tags ?? []).flatMap((collection) => collection.tags.map((tag) => tag.label)), + }; + }), + ); + + const authorPubkys = [...new Set(posts.filter((p) => p.details).map((p) => p.details!.author))]; + const authorDetails = await UserController.getManyDetails({ userIds: authorPubkys }); + if (nonce !== gatherNonce.current) return; + + const authors = new Map( + [...authorDetails.entries()].map(([pubky, d]) => [ + pubky as string, + { name: d.name ?? '', image: d.image ?? null }, + ]), + ); + const synthesized = streamToGraph(posts, authors); + + setGraph((prev) => { + // New nodes get a birth pulse and spawn at their author's position + markBirths(prev, synthesized, null); + const placed = new Map(prev.nodes.map((n) => [n.id, n as SimNode])); + for (const node of synthesized.nodes as SimNode[]) { + if (placed.has(node.id)) continue; + const anchor = node.kind === 'post' ? placed.get(`user:${node.author_id}`) : undefined; + if (anchor?.x !== undefined && anchor?.y !== undefined) { + node.x = anchor.x + (Math.random() - 0.5) * 8; + node.y = anchor.y + (Math.random() - 0.5) * 8; + } + } + return mergeGraph(prev, synthesized); + }); + } catch (err) { + Logger.error('useStreamGraph: failed to synthesize stream graph', err); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [postKey]); + + // Viewer relationship flags for every user in the graph, read from Dexie + // reactively: follow/unfollow and TTL refreshes repaint colors live + const userPubkys = useMemo(() => graph.nodes.flatMap((n) => (n.kind === 'user' ? [n.pubky] : [])), [graph]); + const pubkyKey = userPubkys.join(','); + const liveRels = useLiveQuery(async () => { + try { + if (userPubkys.length === 0) return EMPTY_RELS; + const rels = await UserController.getManyRelationships({ userIds: userPubkys }); + const map: ViewerRelFlags = new Map(); + for (const [pubky, rel] of rels) { + map.set(pubky, { following: Boolean(rel.following), followed_by: Boolean(rel.followed_by) }); + } + return map; + } catch (error) { + Logger.error('useStreamGraph: failed to query author relationships', { error }); + return EMPTY_RELS; + } + }, [pubkyKey]); + + useEffect(() => { + if (liveRels) setAuthorRels(liveRels); + }, [liveRels]); + + const focusId = deriveFocusId(graph); + + return { + nodes: core.nodes, + rawNodeCount: graph.nodes.length, + edges: core.edges, + relationships: core.relationships, + opacityTiers: core.opacityTiers, + sizeTiers: core.sizeTiers, + classCounts: core.classCounts, + focusId, + selectedNode: core.selectedNode, + expandedIds: core.expandedIds, + pathIds: core.pathIds, + timeBounds: core.timeBounds, + timelineStamps: core.timelineStamps, + timeCap: core.timeCap, + declutter: core.declutter, + hiddenClasses: core.hiddenClasses, + isExpanding: core.isExpanding, + isTracing: core.isTracing, + select: core.select, + expand: core.expand, + refreshNode: core.refreshNode, + recenter, + addTag: core.addTag, + tracePath: core.tracePath, + clearPath: core.clearPath, + toggleClass: core.toggleClass, + toggleDeclutter: core.toggleDeclutter, + setTimeCap: core.setTimeCap, + }; +} diff --git a/src/hooks/useStreamGraph/useStreamGraph.utils.test.ts b/src/hooks/useStreamGraph/useStreamGraph.utils.test.ts new file mode 100644 index 0000000000..94e51be34f --- /dev/null +++ b/src/hooks/useStreamGraph/useStreamGraph.utils.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { type StreamPostInput, streamToGraph, viewerRelationships } from './useStreamGraph.utils'; + +const AUTHOR_A = '1111111111111111111111111111111111111111111111111111'; +const AUTHOR_B = '2222222222222222222222222222222222222222222222222222'; +const GHOST = '3333333333333333333333333333333333333333333333333333'; + +const post = (author: string, id: string, over: Partial = {}): StreamPostInput => ({ + compositeId: `${author}:${id}`, + details: { content: `content of ${id}`, kind: 'short', indexed_at: 100, author }, + repliedUri: null, + repostedUri: null, + tagLabels: [], + ...over, +}); + +const authors = new Map([ + [AUTHOR_A, { name: 'Alice', image: 'pubky://a/img' }], + [AUTHOR_B, { name: 'Bob', image: null }], +]); + +describe('streamToGraph', () => { + it('creates author and post nodes joined by AUTHORED edges', () => { + const graph = streamToGraph([post(AUTHOR_A, 'P1'), post(AUTHOR_A, 'P2'), post(AUTHOR_B, 'P3')], authors); + + const users = graph.nodes.filter((n) => n.kind === 'user'); + const posts = graph.nodes.filter((n) => n.kind === 'post'); + expect(users.map((u) => u.kind === 'user' && u.name).sort()).toEqual(['Alice', 'Bob']); + expect(posts).toHaveLength(3); + + const authored = graph.edges.filter((e) => e.type === 'AUTHORED'); + expect(authored).toHaveLength(3); + expect(authored[0].source).toBe(`user:${AUTHOR_A}`); + // AUTHORED edges carry the post timestamp so the time machine replays the feed + expect(authored.every((e) => e.indexed_at === 100)).toBe(true); + }); + + it('links replies inside the stream and materializes ghost parents outside it', () => { + const inStream = post(AUTHOR_A, 'P1'); + const reply = post(AUTHOR_B, 'P2', { + repliedUri: `pubky://${AUTHOR_A}/pub/pubky.app/posts/P1`, + }); + const orphan = post(AUTHOR_B, 'P3', { + repliedUri: `pubky://${GHOST}/pub/pubky.app/posts/PX`, + }); + + const graph = streamToGraph([inStream, reply, orphan], authors); + + const replied = graph.edges.filter((e) => e.type === 'REPLIED'); + expect(replied).toHaveLength(2); + expect(replied[0]).toMatchObject({ + source: `post:${AUTHOR_B}:P2`, + target: `post:${AUTHOR_A}:P1`, + }); + + // The out-of-stream parent exists as a ghost post with its ghost author + expect(graph.nodes.some((n) => n.id === `post:${GHOST}:PX`)).toBe(true); + expect(graph.nodes.some((n) => n.id === `user:${GHOST}`)).toBe(true); + // Ghosts have empty content until someone selects them (panel hydrates) + const ghost = graph.nodes.find((n) => n.id === `post:${GHOST}:PX`); + expect(ghost?.kind === 'post' && ghost.content).toBe(''); + + // Reply-ness travels with the node, so the canvas can glyph it without the edge + const isReply = (id: string) => { + const node = graph.nodes.find((n) => n.id === id); + return node?.kind === 'post' ? node.is_reply : null; + }; + expect(isReply(`post:${AUTHOR_B}:P2`)).toBe(true); + expect(isReply(`post:${AUTHOR_B}:P3`)).toBe(true); + expect(isReply(`post:${AUTHOR_A}:P1`)).toBe(false); + // A ghost parent's own lineage is unknown; it draws as a post + expect(isReply(`post:${GHOST}:PX`)).toBe(false); + }); + + it('never lets a ghost shadow a real post that appears later in the stream', () => { + // The reply comes FIRST in the array, its parent later + const reply = post(AUTHOR_B, 'P2', { + repliedUri: `pubky://${AUTHOR_A}/pub/pubky.app/posts/P1`, + }); + const parent = post(AUTHOR_A, 'P1'); + + const graph = streamToGraph([reply, parent], authors); + + const parentNode = graph.nodes.find((n) => n.id === `post:${AUTHOR_A}:P1`); + expect(parentNode?.kind === 'post' && parentNode.content).toBe('content of P1'); + }); + + it('links reposts like replies', () => { + const original = post(AUTHOR_A, 'P1'); + const repost = post(AUTHOR_B, 'P2', { + repostedUri: `pubky://${AUTHOR_A}/pub/pubky.app/posts/P1`, + }); + + const graph = streamToGraph([original, repost], authors); + + expect(graph.edges.filter((e) => e.type === 'REPOSTED')).toHaveLength(1); + }); + + it('promotes the hottest labels to tag hubs with labeled edges', () => { + const graph = streamToGraph( + [ + post(AUTHOR_A, 'P1', { tagLabels: ['bitcoin', 'dev'] }), + post(AUTHOR_B, 'P2', { tagLabels: ['bitcoin'] }), + post(AUTHOR_B, 'P3', { tagLabels: ['bitcoin', 'art'] }), + ], + authors, + { maxTagHubs: 2 }, + ); + + const tags = graph.nodes.filter((n) => n.kind === 'tag'); + expect(tags).toHaveLength(2); + const bitcoin = tags.find((t) => t.kind === 'tag' && t.label === 'bitcoin'); + expect(bitcoin?.kind === 'tag' && bitcoin.count).toBe(3); + + const tagged = graph.edges.filter((e) => e.type === 'TAGGED' && e.label === 'bitcoin'); + expect(tagged).toHaveLength(3); + expect(tagged.every((e) => e.source === 'tag:bitcoin')).toBe(true); + }); + + it('skips posts without cached details and dedupes shared nodes', () => { + const graph = streamToGraph( + [post(AUTHOR_A, 'P1'), { ...post(AUTHOR_A, 'P2'), details: null }, post(AUTHOR_A, 'P3')], + authors, + ); + + expect(graph.nodes.filter((n) => n.kind === 'post')).toHaveLength(2); + expect(graph.nodes.filter((n) => n.kind === 'user')).toHaveLength(1); + }); +}); + +describe('viewerRelationships', () => { + it('classifies authors against the viewer from cached relationship flags', () => { + const rels = new Map([ + [AUTHOR_A, { following: true, followed_by: true }], + [AUTHOR_B, { following: true, followed_by: false }], + ]); + + const map = viewerRelationships('me', [`user:me`, `user:${AUTHOR_A}`, `user:${AUTHOR_B}`, `user:${GHOST}`], rels); + + expect(map.get('user:me')).toBe('self'); + expect(map.get(`user:${AUTHOR_A}`)).toBe('friend'); + expect(map.get(`user:${AUTHOR_B}`)).toBe('following'); + expect(map.get(`user:${GHOST}`)).toBe('extended'); + }); + + it('marks everyone extended when signed out', () => { + const map = viewerRelationships(null, [`user:${AUTHOR_A}`], new Map()); + expect(map.get(`user:${AUTHOR_A}`)).toBe('extended'); + }); +}); diff --git a/src/hooks/useStreamGraph/useStreamGraph.utils.ts b/src/hooks/useStreamGraph/useStreamGraph.utils.ts new file mode 100644 index 0000000000..ac72dc3a64 --- /dev/null +++ b/src/hooks/useStreamGraph/useStreamGraph.utils.ts @@ -0,0 +1,158 @@ +import type { GraphRelationship } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { CompositeIdResult } from '@/models/models.types'; +import { CompositeIdDomain, type Pubky } from '@/models/models.types'; +import { buildCompositeIdFromPubkyUri, parseCompositeId } from '@/models/models.utils'; +import type { NexusGraph, NexusGraphEdge, NexusGraphNode } from '@/services/nexus/graph/graph.types'; + +/** parseCompositeId that degrades to null: one corrupt cached id must skip + * one node, not abort the whole stream synthesis. */ +export function tryParseCompositeId(compositeId: string): CompositeIdResult | null { + try { + return parseCompositeId(compositeId); + } catch { + return null; + } +} + +/** One stream post, as read from the local cache. */ +export type StreamPostInput = { + /** Composite "authorId:postId" */ + compositeId: string; + details: { content: string; kind: string; indexed_at: number; author: Pubky } | null; + repliedUri: string | null; + repostedUri: string | null; + tagLabels: string[]; +}; + +export type StreamAuthorInput = { name: string; image: string | null }; + +const SNIPPET_LENGTH = 100; +const DEFAULT_TAG_HUBS = 8; + +/** + * Synthesizes a graph from a feed: the stream's posts hang off their author + * nodes, reply/repost lineage becomes visible edges (with ghost parents for + * targets outside the stream, hydrated by the panel on selection), and the + * hottest labels become tag hubs. Pure transform of data the feed already + * paid for: no requests. + */ +export function streamToGraph( + posts: StreamPostInput[], + authors: Map, + options: { maxTagHubs?: number } = {}, +): NexusGraph { + const nodes = new Map(); + const edges: NexusGraphEdge[] = []; + + const addUser = (pubky: string) => { + const id = `user:${pubky}`; + if (!nodes.has(id)) { + const author = authors.get(pubky); + nodes.set(id, { kind: 'user', id, pubky, name: author?.name ?? '', image: author?.image ?? null }); + } + return id; + }; + + const addPost = ( + author: string, + postId: string, + content: string, + kind: string, + isReply: boolean, + indexedAt: number, + ) => { + const id = `post:${author}:${postId}`; + if (!nodes.has(id)) { + nodes.set(id, { + kind: 'post', + id, + author_id: author, + post_id: postId, + content: content.slice(0, SNIPPET_LENGTH), + post_kind: kind, + is_reply: isReply, + indexed_at: indexedAt, + }); + edges.push({ source: addUser(author), target: id, type: 'AUTHORED', indexed_at: indexedAt }); + } + return id; + }; + + // A lineage target outside the stream still deserves a node: empty content + // marks it as a ghost, and selecting it hydrates the real post in the panel. + const addLineage = (from: string, uri: string | null, type: 'REPLIED' | 'REPOSTED', indexedAt: number) => { + const compositeId = uri ? buildCompositeIdFromPubkyUri({ uri, domain: CompositeIdDomain.POSTS }) : null; + if (!compositeId) return; + const { pubky: parentAuthor, id: parentPostId } = parseCompositeId(compositeId); + const target = addPost(parentAuthor, parentPostId, '', 'short', false, indexedAt); + edges.push({ source: from, target, type, indexed_at: indexedAt }); + }; + + const labelCounts = new Map(); + const labelTargets = new Map(); + + // Pass 1: register every real post first, so a lineage target that appears + // later in the stream is never shadowed by an empty ghost + for (const post of posts) { + if (!post.details) continue; + const { author, content, kind, indexed_at } = post.details; + const parsed = tryParseCompositeId(post.compositeId); + if (!parsed) continue; + addPost(author, parsed.id, content, kind, post.repliedUri !== null, indexed_at); + } + + // Pass 2: lineage (ghosts only for true out-of-stream targets) and tags + for (const post of posts) { + if (!post.details) continue; + const { author, indexed_at } = post.details; + const parsed = tryParseCompositeId(post.compositeId); + if (!parsed) continue; + const postGid = `post:${author}:${parsed.id}`; + + addLineage(postGid, post.repliedUri, 'REPLIED', indexed_at); + addLineage(postGid, post.repostedUri, 'REPOSTED', indexed_at); + + for (const label of new Set(post.tagLabels)) { + labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); + if (!labelTargets.has(label)) labelTargets.set(label, []); + labelTargets.get(label)!.push({ target: postGid, indexedAt: indexed_at }); + } + } + + const hubs = [...labelCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, options.maxTagHubs ?? DEFAULT_TAG_HUBS); + for (const [label, count] of hubs) { + const id = `tag:${label}`; + nodes.set(id, { kind: 'tag', id, label, count }); + for (const { target, indexedAt } of labelTargets.get(label) ?? []) { + edges.push({ source: id, target, type: 'TAGGED', label, indexed_at: indexedAt }); + } + } + + return { nodes: [...nodes.values()], edges }; +} + +/** + * Colors stream authors against the signed-in viewer from cached relationship + * flags (there are no FOLLOWS edges in a stream graph to derive them from). + */ +export function viewerRelationships( + viewerPubky: string | null, + nodeIds: string[], + relationships: Map, +): Map { + const map = new Map(); + const meId = viewerPubky ? `user:${viewerPubky}` : null; + for (const id of nodeIds) { + if (!id.startsWith('user:')) continue; + if (id === meId) { + map.set(id, 'self'); + continue; + } + const rel = relationships.get(id.slice('user:'.length)); + if (rel?.following && rel?.followed_by) map.set(id, 'friend'); + else if (rel?.following) map.set(id, 'following'); + else if (rel?.followed_by) map.set(id, 'follower'); + else map.set(id, 'extended'); + } + return map; +} diff --git a/src/hooks/useTrackedPoint/useTrackedPoint.ts b/src/hooks/useTrackedPoint/useTrackedPoint.ts new file mode 100644 index 0000000000..a709d62478 --- /dev/null +++ b/src/hooks/useTrackedPoint/useTrackedPoint.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Re-samples a canvas-space point every animation frame while active, so + * overlays spawn next to their node and track pan, zoom, and drags. Holds the + * last point through momentary null samples. + */ +export function useTrackedPoint( + compute: (() => { x: number; y: number } | null) | null, +): { x: number; y: number } | null { + const [point, setPoint] = useState<{ x: number; y: number } | null>(null); + useEffect(() => { + if (!compute) { + setPoint(null); + return; + } + let raf = 0; + const tick = () => { + const next = compute(); + if (next) { + setPoint((prev) => (prev && Math.abs(prev.x - next.x) < 0.5 && Math.abs(prev.y - next.y) < 0.5 ? prev : next)); + } + raf = requestAnimationFrame(tick); + }; + tick(); + return () => { + cancelAnimationFrame(raf); + setPoint(null); + }; + }, [compute]); + return point; +} diff --git a/src/libs/utils/utils.test.ts b/src/libs/utils/utils.test.ts index 160b56e816..bec3a9fd81 100644 --- a/src/libs/utils/utils.test.ts +++ b/src/libs/utils/utils.test.ts @@ -5,12 +5,13 @@ import { PUBKY_INVALID_BAD_CHAR, PUBKY_INVALID_TOO_LONG, } from '@/test-utils/pubky'; -import { asInvalid } from '@/test-utils/type-assertions'; +import { asInvalid, asOpaque } from '@/test-utils/type-assertions'; import { canSubmitPost, clearCookies, cn, copyToClipboard, + cssColorToHex, daysAgo, extractInitials, formatInviteCode, @@ -553,6 +554,44 @@ describe('Utils', () => { }); }); + describe('cssColorToHex', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null when the 2D canvas is unavailable (jsdom default)', () => { + // jsdom's canvas stub has no 2D context, so normalization is impossible + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + expect(cssColorToHex('oklch(0.9 0.3 120)')).toBeNull(); + }); + + it('should return null for empty input', () => { + expect(cssColorToHex('')).toBeNull(); + }); + + it('should return the hex serialization the scratch canvas produces', () => { + const scratch = { fillStyle: '' }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(asOpaque(scratch)); + // The canvas spec serializes opaque colors back as #rrggbb + Object.defineProperty(scratch, 'fillStyle', { + get: () => '#c8ff00', + set: () => {}, + }); + expect(cssColorToHex('oklch(0.92 0.24 122)')).toBe('#c8ff00'); + }); + + it('should return null when the serialization is not an opaque hex color', () => { + const scratch = { fillStyle: '' }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(asOpaque(scratch)); + // Translucent colors serialize as rgba(...), which callers cannot use + Object.defineProperty(scratch, 'fillStyle', { + get: () => 'rgba(200, 255, 0, 0.5)', + set: () => {}, + }); + expect(cssColorToHex('rgba(200, 255, 0, 0.5)')).toBeNull(); + }); + }); + describe('extractInitials', () => { it('should extract initials from full name', () => { expect(extractInitials({ name: 'John Doe' })).toBe('JD'); diff --git a/src/libs/utils/utils.ts b/src/libs/utils/utils.ts index cdd32cec8b..b00ae2ab90 100644 --- a/src/libs/utils/utils.ts +++ b/src/libs/utils/utils.ts @@ -206,6 +206,28 @@ export function hexToRgba(hex: string, alpha: number) { return `rgba(${r}, ${g}, ${b}, ${alpha})`; } +/** + * Normalizes any valid CSS color (oklch(), rgb(), named colors, ...) to a + * `#rrggbb` hex string via a scratch 2D canvas: assigning fillStyle and + * reading it back serializes opaque colors as hex per the canvas spec. + * + * Returns null when normalization is unavailable (SSR, jsdom's stub canvas) + * or when the value does not serialize to an opaque hex color (parse + * failure, out-of-gamut serialization), so callers can keep their fallback. + * + * @param color - Any CSS color string + * @returns Hex color string (e.g., '#c8ff00') or null + */ +export function cssColorToHex(color: string): string | null { + if (!color || typeof document === 'undefined') return null; + const scratch = document.createElement('canvas').getContext?.('2d'); + if (!scratch) return null; + scratch.fillStyle = '#000000'; + scratch.fillStyle = color; + const normalized = scratch.fillStyle; + return typeof normalized === 'string' && /^#[0-9a-f]{6}$/i.test(normalized) ? normalized : null; +} + export function extractInitials({ name, maxLength = 2 }: ExtractInitialsProps) { if (!name || typeof name !== 'string') return '';