diff --git a/.gitignore b/.gitignore index 4d97b6dc44..ea0aceab58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Dependencies node_modules/ +/client/node_modules +/server/node_modules # Build output dist/ diff --git a/AGENTS.md b/AGENTS.md index da0e8b6e48..ad210b143d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,11 @@ Client- and server-specific conventions live in nested memory files that load wh `server/lib/navManifest.js` is the single source of truth for navigation: `NAV_COMMANDS` + `resolveNavCommand()`, consumed by both the `⌘K` palette and the voice agent's `ui_navigate` tool. **Adding a `` without a `NAV_COMMANDS` entry leaves the page unreachable from `⌘K` and un-navigable by voice.** Invoke the `portos-add-page` skill for the entry shape, palette-action wiring, and the fail-fast guards. -**Optional features gate navigation, not routes.** `server/lib/instanceFeatureRegistry.js` declares the optional per-install features (`post`, `datadog`, `jira`) the user toggles in **Settings > Features**; `server/services/instanceFeatures.js` resolves each one as stored override → auto-detection of the integration it fronts → `defaultEnabled`. A nav entry tagged `feature: ''` (or living in a section listed in navManifest's `SECTION_FEATURE` map) drops out of `⌘K` and the sidebar while that feature is off — **the `` keeps working, so bookmarks, direct links, and voice `ui_navigate` still resolve**. The gate is applied CLIENT-side (`useInstanceFeatures` + `client/src/lib/navFeatures.js`), not by filtering the manifest response: `⌘K` and the voice widget each fetch `/api/palette/manifest` once per session and it is HTTP-cached, so a server-side filter would both defeat that cache and still show hidden pages until a reload. Tag the sidebar row in `client/src/components/Layout.jsx` with the same id; `navManifest.test.js` scrapes both lists and fails when they drift, when a tag names an unregistered feature, or when a `SECTION_FEATURE` key stops matching a live section. +**Optional features gate navigation, not routes.** `server/lib/instanceFeatureRegistry.js` declares the optional per-install features (`post`, `datadog`, `jira`) the user toggles in **Settings > Features**; `server/services/instanceFeatures.js` resolves each one as stored override → auto-detection of the integration it fronts → `defaultEnabled`. A nav entry tagged `feature: ''` (or living in a section listed in navManifest's `SECTION_FEATURE` map) drops out of `⌘K` and the sidebar while that feature is off — **the `` keeps working, so bookmarks, direct links, and voice `ui_navigate` still resolve**. The gate is applied CLIENT-side (`useInstanceFeatures` + `client/src/lib/navFeatures.js`), not by filtering the manifest response: `⌘K` and the voice widget each fetch `/api/palette/manifest` once per session and it is HTTP-cached, so a server-side filter would both defeat that cache and still show hidden pages until a reload. A sidebar row still needs its own `NAV_PRESENTATION` entry in `client/src/components/Layout.jsx` (path → icon); `feature`, `section` and `label` are inherited from `NAV_COMMANDS`, so the path is the one thing declared twice — the icon lives only in `NAV_PRESENTATION` — and a manifest tag alone yields no sidebar row — `NAV_PRESENTATION`'s keys are the set the sidebar iterates. `Layout.test.jsx` pins that every Settings, Digital Twin and Messages sub-tab path has a `NAV_PRESENTATION` entry, and that each entry stays presentation-only (an icon, no `to`/`label`/`section`/`feature`) keyed to a live manifest path. `navManifest.test.js` fails when a tag names an unregistered feature, or when a `SECTION_FEATURE` key stops matching a live section. + +**Feature groups bucket related features under one toggle, additively.** `INSTANCE_FEATURE_GROUPS` in `server/lib/instanceFeatureRegistry.js` declares the groups (today: `comms`, holding FaceTime Audio, iMessage, Signal, X, Stacker News and Beeper); a feature joins one by carrying `group: ''` on its descriptor, which is the whole edit. `resolveOne` in `server/services/instanceFeatures.js` resolves a grouped feature as **its own stored override → the group flag → the detector → `defaultEnabled`**: an explicit per-feature override always wins, and only a feature left on "inherit" answers to its group (group off hides it, group on hands it straight back to its normal resolution). Setting an override back to inherit deletes the stored key rather than writing a third sentinel, so it reads exactly like a feature nobody ever touched. **A group's own `enabled` defaults to `true` when no group state is stored** — that default is the parity guarantee: an install with no `instanceFeatureGroups` in settings resolves every member exactly as it did before the group existed, so registering a group is never a silent hide and needs no settings migration. Malformed group settings fail toward `false`, matching the per-feature override's own posture. An ungrouped feature is completely unaffected. + +**A feature toggle that arms background work must reconcile that work at toggle time, not only at boot.** A subsystem gated on "feature on AND credential present" and started once in `services/bootstrap.js` is silently wrong the moment either half of that gate moves at runtime: on a live install, storing a Beeper credential left the realtime transport down and no sweep registered for 48 minutes, until a restart — and the mirror-image gap left a socket relaying on a token a disconnect had just revoked. Give the subsystem one idempotent `reconcile…()` that reads the gate and moves everything to match it, and call it from every path that can move the gate (each credential write, the feature toggle, the group toggle, disconnect). Make repeat calls no-ops rather than re-registrations — re-`schedule()`ing an existing event resets `nextRunAt` a full interval out, so an unrelated toggle would keep pushing the next run away — serialize overlapping calls on one tail, and log transitions only, so an install that has never enabled the feature still narrates nothing. `server/services/beeperArming.js` is the worked example. ### Slashdo Commands (`lib/slashdo`) diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 40b5261e0b..749d388889 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -215,6 +215,7 @@ export const NAV_PRESENTATION = { '/cos/jobs': { icon: Bot }, '/cos/tasks': { icon: FileText }, '/cos/workflow': { icon: ChartGantt }, + '/messages/beeper': { icon: MessageCircle }, '/messages/config': { icon: Settings }, '/messages/contacts': { icon: Users }, '/messages/drafts': { icon: FilePen }, diff --git a/client/src/components/Layout.test.jsx b/client/src/components/Layout.test.jsx index 78c1a3f12c..b5475507c9 100644 --- a/client/src/components/Layout.test.jsx +++ b/client/src/components/Layout.test.jsx @@ -65,6 +65,7 @@ const allFeaturesOn = () => [ { id: 'gsd', label: 'GSD', enabled: true }, { id: 'openclaw', label: 'OpenClaw', enabled: true }, { id: 'health', label: 'Health tracking', enabled: true }, + { id: 'beeper', label: 'Beeper', enabled: true }, ]; const featureMock = vi.hoisted(() => ({ features: null })); @@ -148,7 +149,7 @@ describe('Layout — manifest-derived sidebar structure', () => { expect(NAV_PRESENTATION[p], `missing NAV_PRESENTATION for digital twin tab ${p}`).toBeDefined(); } - const messageTabs = ['inbox', 'drafts', 'imessage', 'signal', 'contacts', 'sync', 'config']; + const messageTabs = ['inbox', 'drafts', 'imessage', 'signal', 'beeper', 'contacts', 'sync', 'config']; for (const tab of messageTabs) { const p = `/messages/${tab}`; expect(NAV_PRESENTATION[p], `missing NAV_PRESENTATION for message tab ${p}`).toBeDefined(); @@ -330,6 +331,37 @@ describe('Layout — instance feature gating', () => { }); }); +describe('Layout — Comms section (Beeper)', () => { + // #30 / real-browser pass: the Beeper row never rendered in the sidebar Comms + // section, feature on or off, because NAV_PRESENTATION had no '/messages/beeper' + // key. navRowForPath() throws the other way around — for a NAV_PRESENTATION + // path with no matching NAV_COMMANDS entry — so a path missing from + // NAV_PRESENTATION itself never reaches navRowForPath at all (presentedNavRows + // only maps over NAV_PRESENTATION's own keys), and the row was silently absent + // rather than a crash. + it('lists Beeper in the Comms section alongside the other messaging rows when the feature is on', async () => { + await renderLayout('/messages/inbox'); + + const beeper = screen.getByRole('link', { name: 'Beeper' }); + expect(beeper).toHaveAttribute('href', '/messages/beeper'); + // Same section as its siblings, not a stray top-level row. + expect(screen.getByRole('link', { name: 'Signal' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'iMessage' })).toBeTruthy(); + }); + + it('drops only the Beeper row when the beeper feature is off, keeping the rest of Comms', async () => { + featureMock.features = allFeaturesOn() + .map((f) => (f.id === 'beeper' ? { ...f, enabled: false } : f)); + + await renderLayout('/messages/inbox'); + + expect(screen.queryByRole('link', { name: 'Beeper' })).toBeNull(); + expect(screen.getByRole('link', { name: 'Signal' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'iMessage' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Inbox' })).toBeTruthy(); + }); +}); + describe('Layout — System Resources location state', () => { it('keeps Dev Tools expanded and System Resources active on every subtab', async () => { await renderLayout('/system-resources/storage'); diff --git a/client/src/components/agents/tabs/WorldTab.jsx b/client/src/components/agents/tabs/WorldTab.jsx index 2fb91f5fa8..98b6094578 100644 --- a/client/src/components/agents/tabs/WorldTab.jsx +++ b/client/src/components/agents/tabs/WorldTab.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import toast from '../../ui/Toast'; import { FormField } from '../../ui/FormField'; +import ConnectionStatusDot from '../../ui/ConnectionStatusDot'; import * as api from '../../../services/api'; import BrailleSpinner from '../../BrailleSpinner'; import socket from '../../../services/socket'; @@ -390,13 +391,6 @@ export default function WorldTab({ agentId }) { // leaving stale phantoms. const displayNearby = presence !== null ? presence : nearby; - const statusDotColor = { - connected: 'bg-port-success', - connecting: 'bg-port-warning animate-pulse', - reconnecting: 'bg-port-warning animate-pulse', - disconnected: 'bg-gray-600' - }[connectionStatus] || 'bg-gray-600'; - // Dynamic param fields for add-to-queue form const renderQueueParamFields = () => { switch (newActionType) { @@ -447,11 +441,7 @@ export default function WorldTab({ agentId }) {
{/* Connection Banner */}
-
- - WebSocket: - {connectionStatus} -
+
{connectionStatus === 'disconnected' ? ( + +
+ ); +} diff --git a/client/src/components/messages/beeper/BeeperCreatePersonForm.test.jsx b/client/src/components/messages/beeper/BeeperCreatePersonForm.test.jsx new file mode 100644 index 0000000000..e347c9572b --- /dev/null +++ b/client/src/components/messages/beeper/BeeperCreatePersonForm.test.jsx @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import BeeperCreatePersonForm from './BeeperCreatePersonForm'; + +/** + * The confirm-and-rename form "Create new…" opens instead of posting + * immediately (fork issue #97 part A). Wiring from `BeeperThread` into this + * component is covered in `BeeperThread.test.jsx`; these pin the form's own + * contract in isolation. + */ + +const PARTICIPANT = { sourceUserId: 'user-1', displayName: 'Sam Example', handle: '+15550100' }; + +const renderForm = (overrides = {}) => { + const props = { + participant: PARTICIPANT, + onCreate: vi.fn(), + onCancel: vi.fn(), + ...overrides, + }; + const utils = render(); + return { ...utils, props }; +}; + +afterEach(cleanup); + +describe('BeeperCreatePersonForm — prefill and fields', () => { + it('prefills the name from the participant\'s display name, editable', () => { + renderForm(); + const nameInput = screen.getByLabelText('Name'); + expect(nameInput).toHaveValue('Sam Example'); + + fireEvent.change(nameInput, { target: { value: 'Corrected Name' } }); + expect(nameInput).toHaveValue('Corrected Name'); + }); + + it('falls back to the handle when there is no display name', () => { + renderForm({ participant: { sourceUserId: 'user-2', displayName: '', handle: '@example_handle' } }); + expect(screen.getByLabelText('Name')).toHaveValue('@example_handle'); + }); + + it('defaults the ring to tribe and offers every RINGS option', () => { + renderForm(); + const ringSelect = screen.getByLabelText('Ring'); + expect(ringSelect).toHaveValue('tribe'); + expect(screen.getByRole('option', { name: 'Support' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Core' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Tribe' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Village' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'External' })).toBeInTheDocument(); + }); + + it('starts with an empty, optional relationship field', () => { + renderForm(); + expect(screen.getByLabelText('Relationship')).toHaveValue(''); + }); +}); + +describe('BeeperCreatePersonForm — Create', () => { + it('is disabled while the name is blank', () => { + renderForm({ participant: { sourceUserId: 'user-3', displayName: '', handle: '' } }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('calls onCreate with the trimmed name, ring and relationship, and only on Create', () => { + const onCreate = vi.fn(); + renderForm({ onCreate }); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Corrected Name ' } }); + fireEvent.change(screen.getByLabelText('Ring'), { target: { value: 'core' } }); + fireEvent.change(screen.getByLabelText('Relationship'), { target: { value: ' Neighbor ' } }); + expect(onCreate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + expect(onCreate).toHaveBeenCalledWith({ name: 'Corrected Name', ring: 'core', relationship: 'Neighbor' }); + }); + + it('is disabled while a link is in flight, and never calls onCreate from a click', () => { + const onCreate = vi.fn(); + renderForm({ onCreate, disabled: true }); + + const createButton = screen.getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + fireEvent.click(createButton); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('submits on Enter from the name field', () => { + const onCreate = vi.fn(); + renderForm({ onCreate }); + + fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' }); + + expect(onCreate).toHaveBeenCalledWith({ name: 'Sam Example', ring: 'tribe', relationship: '' }); + }); + + it('does not submit on Enter while the name is blank', () => { + const onCreate = vi.fn(); + renderForm({ onCreate, participant: { sourceUserId: 'user-4', displayName: '', handle: '' } }); + + fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' }); + + expect(onCreate).not.toHaveBeenCalled(); + }); +}); + +describe('BeeperCreatePersonForm — Cancel', () => { + it('calls onCancel on click', () => { + const onCancel = vi.fn(); + renderForm({ onCancel }); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('calls onCancel on Escape from any field', () => { + const onCancel = vi.fn(); + renderForm({ onCancel }); + + fireEvent.keyDown(screen.getByLabelText('Relationship'), { key: 'Escape' }); + + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/messages/beeper/BeeperNetworkLogo.jsx b/client/src/components/messages/beeper/BeeperNetworkLogo.jsx new file mode 100644 index 0000000000..6244407bf9 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperNetworkLogo.jsx @@ -0,0 +1,233 @@ +/** + * Network marks for the Beeper chat surface (#35), ported from the #9 + * prototype's `beeperNetworkLogos.jsx`. + * + * Inline SVG because PortOS pulls no external brand assets, and simplified + * because these are read at 14–26px in the rail and as ~15px badges on a row, + * where silhouette and colour carry recognition rather than detail. + * + * **The map is a rendering hint, never a roster.** The rail and the badges are + * driven by whatever networks the mirror actually holds — #9 records that the + * development machine's nine networks are an outlier (today's free tier caps a + * new account at five, and most installs have one), so a network PortOS has + * never heard of must still render. That is what `Fallback` is for, and it is + * why nothing in this file is ever used to decide WHICH networks exist. + */ + +const Wrap = ({ size, rounded, bg, children, title }) => ( + + {children} + +); + +const svg = (children, extra = {}) => ( + {children} +); + +const MARKS = { + whatsapp: ({ size }) => ( + + {svg( + , + )} + + ), + googlemessages: ({ size }) => ( + + {svg( + , + )} + + ), + discord: ({ size }) => ( + + {svg( + , + )} + + ), + facebook: ({ size }) => ( + + {svg( + , + )} + + ), + // Signal's real mark is a dotted/dashed ring, not a speech bubble — drawn as + // a stroked circle with a dash pattern so it stays unmistakable from the + // solid Google Messages bubble at rail size (#84). + signal: ({ size }) => ( + + {svg( + , + )} + + ), + // Beeper's own mark, for chats Beeper attributes to itself rather than a + // bridged network (#84) — a four-point sparkle keeps the silhouette + // distinct from every rounded/circular network bubble above. + beeper: ({ size }) => ( + + {svg( + , + )} + + ), + instagram: ({ size }) => ( + + {svg( + <> + + + + , + )} + + ), + telegram: ({ size }) => ( + + {svg()} + + ), + slack: ({ size }) => ( + + {svg( + <> + + + + + , + )} + + ), + x: ({ size }) => ( + + {svg()} + + ), +}; + +// Display names for the ids above. A network absent from this map falls back +// to its raw id rather than to a guess. +const LABELS = { + whatsapp: 'WhatsApp', + googlemessages: 'Google Messages', + discord: 'Discord', + facebook: 'Messenger', + signal: 'Signal', + instagram: 'Instagram', + telegram: 'Telegram', + slack: 'Slack', + x: 'X', + beeper: 'Beeper', +}; + +/** Any network PortOS has no mark for still renders: initial on a neutral chip. */ +const Fallback = ({ size, label }) => ( + + {(label || '?')[0].toUpperCase()} + +); + +// Beeper's Facebook/Messenger bridge reports several distinct ids for the +// same one `facebook` mark and label depending on bridge generation and +// login mode (#84): the legacy bridge, its Go rewrite, and the +// Messenger-mode login all land on the same network. Keyed post-normalize +// (lowercase, punctuation stripped), so "Facebook Go" and "facebook-go" both +// match `facebookgo`. +const NETWORK_ALIASES = { + facebookgo: 'facebook', + messenger: 'facebook', + messengergo: 'facebook', +}; + +// The live Facebook-bridge display string has never been observed directly +// (no fixture in this repo, none in any captured log, live mirror off +// limits), so rather than guess one more exact spelling for `NETWORK_ALIASES` +// above, this ordered contains-rule against the normalized id is what makes +// the mapping robust to whichever spelling ("Facebook Messenger", +// "Messenger (Go)", ...) the bridge actually emits — checked only after the +// exact map so a future precise alias still wins. +const NETWORK_ALIAS_RULES = [ + [/facebook|messenger/, 'facebook'], +]; + +// Beeper reports a network as a lowercase id; normalize defensively so a +// bridge that reports "WhatsApp" or "google-messages" still finds its mark +// instead of silently degrading to the initial chip, then fold known aliases +// onto the one id each has a mark/label for. +const normalize = (network) => { + const raw = String(network || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + if (NETWORK_ALIASES[raw]) return NETWORK_ALIASES[raw]; + const rule = NETWORK_ALIAS_RULES.find(([pattern]) => pattern.test(raw)); + return rule ? rule[1] : raw; +}; + +/** A human label for a network id, for the composer, the header and titles. */ +export const networkLabel = (network) => LABELS[normalize(network)] || network || 'Unknown network'; + +// A network id PortOS has no mark for is expected (#9: the roster is +// whatever the mirror holds, never a hardcoded list) but should still be +// visible to a developer diagnosing why a network fell back to a letter +// chip. Log it once per normalized id, dev-only, so a rail full of one +// unrecognized network doesn't spam the console on every render. +const loggedUnknownNetworks = new Set(); + +function logUnknownNetworkOnce(rawNetwork, normalized) { + if (!import.meta.env.DEV) return; + if (!normalized || loggedUnknownNetworks.has(normalized)) return; + loggedUnknownNetworks.add(normalized); + console.warn(`⚠️ Beeper network "${rawNetwork}" (normalized "${normalized}") has no rail mark — falling back to a letter chip`); +} + +export default function NetworkLogo({ network, label, size = 16 }) { + const normalized = normalize(network); + const Mark = MARKS[normalized]; + if (!Mark) { + logUnknownNetworkOnce(network, normalized); + return ; + } + return ; +} diff --git a/client/src/components/messages/beeper/BeeperNetworkLogo.test.jsx b/client/src/components/messages/beeper/BeeperNetworkLogo.test.jsx new file mode 100644 index 0000000000..c1f70c3caa --- /dev/null +++ b/client/src/components/messages/beeper/BeeperNetworkLogo.test.jsx @@ -0,0 +1,149 @@ +import { + afterEach, describe, expect, it, vi, +} from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import NetworkLogo, { networkLabel } from './BeeperNetworkLogo'; + +/** + * #84: the Google Messages and Signal marks were both solid blue speech + * bubbles, indistinguishable at rail size; Facebook and Beeper fell back to + * grey letter chips because the ids Beeper actually emits for those two + * ("facebookgo"/"messenger" for Facebook, and Beeper's own network) never + * normalized onto a key `MARKS` held a mark for. + * + * These tests pin: distinct silhouettes for Google Messages vs. Signal, a + * Beeper mark, every Facebook/Messenger alias resolving to the one + * `facebook` mark and label, the letter-chip fallback for a truly unknown + * id, and the once-per-id dev-only console log that makes that fallback + * visible without spamming. + */ + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('BeeperNetworkLogo — Google Messages vs. Signal', () => { + it('renders Google Messages as a filled speech-bubble path', () => { + render(); + const mark = screen.getByRole('img', { name: 'Google Messages' }); + expect(mark.querySelector('path')).toBeTruthy(); + expect(mark.querySelector('circle')).toBeNull(); + }); + + it('renders Signal as a dashed/dotted ring, not a speech bubble', () => { + render(); + const mark = screen.getByRole('img', { name: 'Signal' }); + const ring = mark.querySelector('circle'); + expect(ring).toBeTruthy(); + // A dotted/dashed circle, not a solid one — the silhouette that actually + // distinguishes it from Google Messages' bubble. + expect(ring).toHaveAttribute('stroke-dasharray'); + expect(ring).toHaveAttribute('fill', 'none'); + expect(mark.querySelector('path')).toBeNull(); + }); +}); + +describe('BeeperNetworkLogo — Beeper mark', () => { + const beeperIds = ['beeper', 'Beeper']; + + it.each(beeperIds)('renders a dedicated Beeper mark instead of falling back for %s', (rawId) => { + render(); + const mark = screen.getByRole('img', { name: 'Beeper' }); + expect(mark.querySelector('path')).toBeTruthy(); + // Not the neutral grey fallback chip's single-letter span. + expect(mark.querySelector('span')).toBeNull(); + }); + + it('exposes "Beeper" through networkLabel', () => { + expect(networkLabel('beeper')).toBe('Beeper'); + }); +}); + +describe('BeeperNetworkLogo — Facebook/Messenger id normalisation', () => { + const aliases = [ + 'facebook', + 'facebookgo', + 'messenger', + 'messengergo', + 'Facebook Go', + 'MESSENGER', + // Beeper reports a display name, not a stable id, and the live + // Facebook-bridge string has never been observed (#84) — these pin the + // contains-rule fallback (`NETWORK_ALIAS_RULES`) that catches whichever + // spelling the bridge actually emits, none of which hit the exact map. + 'Facebook Messenger', + 'facebook-messenger', + 'Facebook (Go)', + 'Messenger (Go)', + ]; + + it.each(aliases)('resolves %s to the Messenger mark', (rawId) => { + render(); + const mark = screen.getByRole('img', { name: 'Messenger' }); + expect(mark.querySelector('path')).toBeTruthy(); + expect(mark.querySelector('span')).toBeNull(); + }); + + it('labels every alias "Messenger" via networkLabel', () => { + for (const rawId of aliases) { + expect(networkLabel(rawId)).toBe('Messenger'); + } + }); +}); + +describe('BeeperNetworkLogo — unknown network fallback', () => { + it('renders the letter-chip fallback for a network with no mark', () => { + render(); + const chip = screen.getByRole('img', { name: 'threema-unmapped-1' }); + expect(chip.textContent).toBe('T'); + }); + + it('prefers an explicit label prop over the raw id on the fallback chip', () => { + render(); + const chip = screen.getByRole('img', { name: 'Threema' }); + expect(chip.textContent).toBe('T'); + }); +}); + +describe('BeeperNetworkLogo — dev-only once-per-id unknown network log', () => { + it('logs an unknown id once, even across repeated renders of the same id', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + render(); + render(); + + const hits = warn.mock.calls.filter(([msg]) => msg.includes('unmapped-dev-log-a')); + expect(hits).toHaveLength(1); + }); + + it('logs a different unknown id independently of one already logged', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + render(); + + const bHits = warn.mock.calls.filter(([msg]) => msg.includes('unmapped-dev-log-b')); + const cHits = warn.mock.calls.filter(([msg]) => msg.includes('unmapped-dev-log-c')); + expect(bHits).toHaveLength(1); + expect(cHits).toHaveLength(1); + }); + + it('does not log for a network that resolves to a known mark', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + render(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('stays silent outside development', () => { + const originalDev = import.meta.env.DEV; + import.meta.env.DEV = false; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render(); + expect(warn).not.toHaveBeenCalled(); + } finally { + import.meta.env.DEV = originalDev; + } + }); +}); diff --git a/client/src/components/messages/beeper/BeeperOutboxBreakerBanner.jsx b/client/src/components/messages/beeper/BeeperOutboxBreakerBanner.jsx new file mode 100644 index 0000000000..c040f2a796 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperOutboxBreakerBanner.jsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Loader2, ShieldAlert } from 'lucide-react'; +import Banner from '../../ui/Banner'; +import toast from '../../ui/Toast'; +import { clearOutboxBreaker } from '../../../services/api'; + +/** + * The runaway breaker, on the settings card and nowhere else (#36, decided on + * #8 decision 4 and #12's two-surfaces rule). + * + * The breaker trips when sends arrive faster than a human produces them — a + * software loop, not a busy conversation. It blocks every further send until a + * person clears it here: there is no timed recovery anywhere in the send path, + * because a breaker that resets itself is a delay rather than a breaker. + * + * It renders where actionable Beeper faults already render, never as a global + * banner: a user who is not sending anything has nothing to act on. + * + * @param {object} props + * @param {object|null} props.breaker `status.outbox.breaker` from GET /api/beeper/status. + * @param {() => void} [props.onCleared] refetch hook for the parent's status. + */ +export default function BeeperOutboxBreakerBanner({ breaker, onCleared }) { + const [clearing, setClearing] = useState(false); + if (!breaker?.tripped) return null; + + const handleClear = async () => { + setClearing(true); + const result = await clearOutboxBreaker({ silent: true }).catch((err) => { + toast.error(err?.message || 'Could not clear the send breaker'); + return null; + }); + setClearing(false); + if (!result) return; + toast.success('Beeper sending re-enabled'); + onCleared?.(); + }; + + return ( + + {clearing ? : null} + {clearing ? 'Clearing…' : 'Clear breaker'} + + )} + > + The runaway breaker tripped ({breaker.reason || 'unexpected send rate'}). No message has been sent since, + and nothing is retried automatically. Check what was sending before clearing this. + + ); +} diff --git a/client/src/components/messages/beeper/BeeperPersonPicker.jsx b/client/src/components/messages/beeper/BeeperPersonPicker.jsx new file mode 100644 index 0000000000..09ca3302d2 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperPersonPicker.jsx @@ -0,0 +1,210 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Search, UserPlus } from 'lucide-react'; +import usePopoverPosition, { VIEWPORT_PADDING } from '../../../hooks/usePopoverPosition.js'; + +// Matches the list's old `w-48` — kept as a JS constant now that width is set +// via `usePopoverPosition` instead of a Tailwind width utility. +const LIST_WIDTH = 192; + +/** + * Search-first Tribe-person picker for the Beeper participant-linking path + * (#98 part B). It replaces the bare full-roster `` this replaces. "Create new…" is + * always the LAST row, below every match, and calls the exact same + * `onCreateNew` callback the old "New" button called — #97 changes what that + * callback DOES (a confirm-and-rename form instead of an immediate create), + * not this wiring. + * + * `autoFocus` (#97 part B) is only ever passed `true` from "Change" on an + * already-linked participant row, so re-pointing a link opens straight into + * a focused, ready-to-type input rather than requiring an extra click. + * + * The results list is portaled to `document.body` and fixed-positioned via + * `usePopoverPosition` (#105): `BeeperThread.jsx` renders this inside a + * `max-h-40 overflow-y-auto` participants roster, and an absolutely + * positioned child cannot escape an `overflow: auto` ancestor — the list was + * extending that roster's own scroll area instead of floating over it. This + * is a combobox, not a menu: focus never leaves the input while the list is + * open, so closing is still driven by `onBlur` (`closeList`) exactly as + * before, and every pointer target inside the portaled list (not just each + * row) calls `preventDefault` on `mousedown` so clicking anywhere in it — + * padding, the "No matches" row, the scrollbar — counts as inside the picker + * rather than blurring the input out from under a would-be selection. + */ +export default function BeeperPersonPicker({ + id, + label, + people, + onSelectPerson, + onCreateNew, + disabled = false, + placeholder = 'Link to…', + autoFocus = false, +}) { + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const listboxId = useId(); + const optionId = (index) => `${listboxId}-option-${index}`; + const wrapperRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 200); + return () => clearTimeout(timer); + }, [query]); + + const matches = useMemo(() => { + const needle = debouncedQuery.trim().toLowerCase(); + const list = needle + ? (people || []).filter((person) => (person.name || '').toLowerCase().includes(needle)) + : (people || []); + // A ceiling, not a hint that more exist — keeps a very large roster from + // rendering an unbounded results list. + return list.slice(0, 50); + }, [people, debouncedQuery]); + + // "Create new…" is always present and always LAST; the row count for + // keyboard purposes is every match plus that one trailing row. + const createNewIndex = matches.length; + const rowCount = matches.length + 1; + + // Right-aligned below the input (flipping above only when there's no room + // below), re-measured whenever the match count changes the list's height — + // typing can grow or shrink it between "No matches" and a full page of + // rows — so an above/below flip made while the list was short doesn't + // paint stale once it grows. + const { popoverRef: listRef, style: listStyle } = usePopoverPosition({ + open, + width: LIST_WIDTH, + minWidth: LIST_WIDTH, + gap: 4, + position: 'below', + anchorRef: wrapperRef, + contentDeps: [matches.length], + }); + + const closeList = () => { setOpen(false); setActiveIndex(0); }; + + const selectRow = (index) => { + if (index === createNewIndex) onCreateNew(); + else if (matches[index]) onSelectPerson(matches[index].id); + setQuery(''); + closeList(); + }; + + const handleKeyDown = (event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!open) { setOpen(true); return; } + setActiveIndex((index) => (index + 1) % rowCount); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!open) { setOpen(true); return; } + setActiveIndex((index) => (index - 1 + rowCount) % rowCount); + } else if (event.key === 'Enter') { + if (!open) return; + event.preventDefault(); + selectRow(activeIndex); + } else if (event.key === 'Escape' && open) { + event.preventDefault(); + closeList(); + } + }; + + return ( +
+ {label && } +
+
+ {open && createPortal( +
    event.preventDefault()} + className="fixed z-[100] max-h-48 overflow-y-auto rounded border border-port-border bg-port-card py-1 shadow-lg" + style={{ + left: listStyle?.left ?? `${VIEWPORT_PADDING}px`, + top: listStyle?.top ?? `${VIEWPORT_PADDING}px`, + width: listStyle?.width ?? `${LIST_WIDTH}px`, + visibility: listStyle ? 'visible' : 'hidden', + }} + > + {matches.length === 0 && ( +
  • No matches
  • + )} + {matches.map((person, index) => ( +
  • { event.preventDefault(); selectRow(index); }} + className={`cursor-pointer truncate px-2 py-1 text-[11px] ${ + index === activeIndex ? 'bg-port-accent/20 text-white' : 'text-gray-200' + }`} + > + {person.name} +
  • + ))} +
  • { event.preventDefault(); selectRow(createNewIndex); }} + className={`flex items-center gap-1 border-t border-port-border/60 px-2 py-1 text-[11px] ${ + createNewIndex === activeIndex ? 'bg-port-accent/20 text-white' : 'text-gray-300' + }`} + > +
  • +
, + document.body, + )} +
+ ); +} diff --git a/client/src/components/messages/beeper/BeeperPersonPicker.test.jsx b/client/src/components/messages/beeper/BeeperPersonPicker.test.jsx new file mode 100644 index 0000000000..24d942ea1f --- /dev/null +++ b/client/src/components/messages/beeper/BeeperPersonPicker.test.jsx @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + cleanup, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import BeeperPersonPicker from './BeeperPersonPicker'; + +/** + * The search-first Tribe-person picker (#98 part B) that replaces the bare + * full-roster ` setForm((prev) => ({ ...prev, enabled: e.target.checked }))} + className="w-4 h-4 accent-port-accent" + /> + Enable scheduled Beeper sync + + +
+
+ + setForm((prev) => ({ ...prev, intervalMinutes: e.target.value }))} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm" + /> +
+
+ + setForm((prev) => ({ ...prev, attachmentBudgetGb: e.target.value }))} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm" + /> +
+
+ + setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm font-mono" + /> +
+
+ + {/* Loopback-only by default (SEC-2) — a non-loopback base URL would + carry the Beeper access token off this machine on every request. */} + + +
+ +
+
+
+ + {/* The outbound runaway breaker (#36) — an actionable fault, so it renders + on this settings surface with the other actionable faults rather than + on the chat surface or as a global banner. Absent entirely unless it + has actually tripped. `onBreakerCleared` (from `BeeperTab`) refreshes + the page-level snapshot the composer reads, so Send re-enables the + moment this clears rather than waiting on the composer's own next + status fetch. */} + { loadStatus(); onBreakerCleared?.(); }} + /> + + + + {statusLoading ? ( + + ) : ( + + )} + + ); +} + +// The two connect paths, side by side rather than one behind the other (#11 +// decision 3): OAuth is the quick path, pasting is the durable one, because +// Beeper's own UI can mint a token that never expires and nothing in the OAuth +// surface accepts a lifetime. The pasted value goes straight to the vaulted +// write path — it is never echoed back, never stored in settings, and never +// read back into this field. +function BeeperConnectPanel({ connect, submitLabel = 'Connect Beeper' }) { + return ( +
+
+ +

+ Opens Beeper's approval screen and asks for read and send access. Tokens issued this way expire. +

+
+ +
+ +

+ Beeper's own settings can mint a token that never expires — the one credential the approval + flow above cannot produce. PortOS stores it encrypted and never shows it again. +

+
+ connect.onTokenChange(e.target.value)} + placeholder="Access token" + className="flex-1 min-w-0 px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm font-mono" + /> + +
+
+
+ ); +} + +// Inline two-step confirmation — no window.confirm (client/src/AGENTS.md). +// Disconnecting revokes the credential where the authorization server supports +// it and always deletes the local copy, so it is worth a deliberate second +// click but not a modal. +function DisconnectButton({ connect }) { + if (!connect.confirmingDisconnect) { + return ( + + ); + } + return ( +
+ Forget this Beeper credential? + + +
+ ); +} + +// Shown whenever a stored token is inside the expiry warning window, +// regardless of whether Beeper Desktop is currently reachable — an expired +// token is exactly as actionable on an unreachable install as on a +// connected one, so this renders in both the `reachable === false` and +// `reachable === true` branches below. +function TokenExpiryNotice({ status }) { + if (!status?.tokenExpiringSoon) return null; + const days = status.tokenExpiresInDays; + const label = Number.isFinite(days) && days <= 0 + ? 'Token has expired — reconnect to keep syncing.' + : `Token expires in ${days} day(s) — reconnect soon.`; + return ( +

+ + {label} +

+ ); +} + +// The granted-scopes line (fork issue #78): sits beside `TokenExpiryNotice` +// for the same reason — a read-only grant is exactly the kind of thing that +// should be visible on the card BEFORE a send fails, not discovered from the +// error it causes. `tokenScopes` is always an array (`[]` when unknown), the +// absent-vs-empty rule applied to a source rather than a value: a pasted +// token (#11 decision 3) never carries scopes back from Beeper's own paste +// UI, so `[]` there is honest and gets said out loud; `[]` from anywhere else +// (the legacy plaintext path) has nothing worth claiming, so this renders +// nothing rather than a bogus "no scopes granted" line. +function TokenScopesNotice({ status }) { + const scopes = Array.isArray(status?.tokenScopes) ? status.tokenScopes : []; + if (scopes.length === 0) { + if (status?.tokenSource !== 'pasted') return null; + return ( +

Scopes unknown (pasted token).

+ ); + } + const readOnly = !scopes.includes('write'); + return ( +
+

Scopes: {scopes.join(', ')}

+ {readOnly && ( +

+ + Read-only grant — sending will fail. +

+ )} +
+ ); +} + +// The transport liveness row (#33 decision 4): a Moltworld-shape dot, and — on +// the same card, never in a global banner — the one `app.state` value a human +// has to act on. Rendered in EVERY branch where a token is configured, not only +// the reachable one: the HTTP probe failing is exactly when the socket's own +// liveness and its `needs-login` remedy are worth reading. `initializing` is deliberately absent from the actionable set: +// it was measured lying for 105 continuous seconds on a fully working install, +// so surfacing it would train the user to ignore this line. +const APP_STATE_REMEDY = { + 'needs-login': 'Beeper Desktop needs you to sign in again.', + 'needs-verification': 'Beeper Desktop needs this device verified.', + 'needs-secrets': 'Beeper Desktop is missing its encryption secrets.', + 'needs-cross-signing-setup': 'Beeper Desktop needs cross-signing set up.', +}; + +// The transport stood down because Beeper answered the upgrade with 401/403. +// Reconnecting could only ever produce the same answer, so the fix is a human's. +const TOKEN_REJECTED_REMEDY = 'Beeper Desktop rejected the stored token — reconnect Beeper.'; + +// `showRemedy` exists for the one card that IS the remedy: the expired-token +// branch below already says "reconnect Beeper" in its heading, its body and its +// button, and a token that expired is exactly the token the transport's own +// 401 stand-down reports as `authRejected` — so the dot still belongs there +// (it corroborates that the socket is down for that reason and not looping), +// while a fourth copy of the same instruction does not. +function BeeperRealtimeRow({ realtime, showRemedy = true }) { + // `null` = the transport has not reported yet. Never rendered as offline. + if (!realtime?.state) return null; + const remedy = !showRemedy ? null : (realtime.authRejected + ? TOKEN_REJECTED_REMEDY + : (realtime.appStateActionable ? APP_STATE_REMEDY[realtime.appState] : null)); + return ( +
+ + {remedy && ( +

+ + {remedy} +

+ )} +
+ ); +} + +/** + * The sweep-visibility row (#80), rendered in EVERY reachability state + * alongside `BeeperRealtimeRow` — the drawer card's own copy of the same + * "Syncing… N of M accounts" / "Last synced HH:MM" strip the chat surface's + * list header shows, so a user who opened settings instead of the chat + * surface still sees whether ingestion is doing anything. `null` (no sweep + * has ever run on this install) renders nothing, same absent-vs-never rule + * `BeeperRealtimeRow` follows for a transport that has not reported yet. + */ +function SweepStatusRow({ sweep }) { + if (!sweep) return null; + if (sweep.running) { + const total = Number.isFinite(sweep.accountsTotal) ? sweep.accountsTotal : null; + const done = Number.isFinite(sweep.accountsDone) ? sweep.accountsDone : 0; + return ( +

+ + {total === null ? 'Syncing…' : `Syncing… ${done} of ${total} account${total === 1 ? '' : 's'}`} +

+ ); + } + if (!sweep.finishedAt) return null; + return ( +

+ Last synced {formatClockTime(sweep.finishedAt, { seconds: false })} +

+ ); +} + +/** + * The mirrored account roster (#30), rendered in EVERY reachability state. + * + * These rows come from `beeper_accounts` — PortOS's own mirror — not from a + * live call, which is the whole reason #27 stores them: the card is supposed to + * render with Beeper Desktop closed. Hiding the roster whenever the probe fails + * threw away information the install already had and made an unreachable app + * look like an empty one, so reachability is stated on its own line above and + * the roster stands beside it. + * + * Read-only by design: it never offers to add a network. Joined by `accountId`, + * never by `network`. + * + * `accounts: null` (paired with `error`) is a FAILED mirror read, distinct + * from `accounts: []` (a successful read that legitimately found none yet) — + * the absent-vs-empty sentinel (root AGENTS.md). Collapsing the two would show + * "No accounts mirrored yet" for a DB hiccup, which reads as a healthy, + * disconnected-feeling install rather than the unknown state it actually is. + */ +function AccountRoster({ accounts, error }) { + if (error) { + return ( +
+

Mirrored accounts

+

{error}

+
+ ); + } + const rows = Array.isArray(accounts) ? accounts : []; + return ( +
+

Mirrored accounts

+ {rows.length === 0 ? ( +

No accounts mirrored yet.

+ ) : ( +
    + {rows.map((account) => ( +
  • + {account.displayName || account.accountId} + {account.network || '—'} +
  • + ))} +
+ )} +
+ ); +} + +// Every state the status card can be in, decided at fork issue #11 and +// carried into #30's Acceptance criteria. `reachable` is read with strict +// equality throughout (`=== false` / `=== true` / `=== null`) — never +// truthiness — so the absent-vs-empty sentinel (`null` = not yet probed) +// can never fall through to the "offline" branch. A failed status fetch is +// handled by the `error` branch immediately below, before any of this ever +// runs, so a broken GET can never collapse into "no token configured". +function BeeperStatusCard({ + status, realtime, error, connect, checking, onCheck, checkDisabled, onRetryStatus, retryingStatus, +}) { + if (error) { + return ( +
+
+ +

Could not read Beeper status

+
+

{error}

+ +
+ ); + } + + if (!status?.tokenConfigured) { + return ( +
+
+ +

Connect Beeper

+
+

+ Beeper is a local desktop app that bridges WhatsApp, Discord, Telegram, and other networks into one + API on this machine — PortOS talks to it over loopback, never over the network. +

+ +
+ ); + } + + // An expired credential is its own state, not a generic API failure: there is + // no refresh grant anywhere in Beeper's OAuth metadata, so the only way + // forward is connecting again. Checked BEFORE reachability so a user whose + // token lapsed while Beeper Desktop happens to be closed still gets the + // action that fixes it rather than "unreachable". + if (status.tokenExpired) { + return ( +
+
+ +

Beeper token expired

+
+

+ Beeper issues no refresh grant, so an expired token is reconnected rather than renewed. +

+
+ +
+ +
+
+ ); + } + + if (status.reachable === false) { + return ( +
+
+ +

Beeper Desktop unreachable

+
+

{status.lastProbeError || 'Could not reach Beeper Desktop.'}

+

Checked against {status.baseUrl}.

+ + +
+
+ {/* The mirror still knows which accounts exist even with Beeper closed. */} +
+
+ + +
+
+ ); + } + + if (status.reachable === true) { + // Fork issue #61, decision 7: a probe timeout shortly after Beeper + // Desktop proved itself alive (a real API call, or a live socket ping) + // stays on THIS card — `reachable` never flips to false for it — with an + // inline note naming the latency, rather than jumping to the unreachable/ + // actionable-fault card below. The empty-state ("Connect Beeper") and + // Retry-button branches never see `probeState` at all, so neither can flip + // on a slow probe. + const isSlow = status.probeState === 'slow'; + return ( +
+
+ +

Beeper Desktop connected

+ {status.appVersion && v{status.appVersion}} +
+ {isSlow && ( +

+ + Slow to respond{Number.isFinite(status.probeLatencyMs) ? ` (${status.probeLatencyMs}ms)` : ''} — Beeper Desktop is up but answered the last check slowly. +

+ )} + + + + + +
+ + +
+
+ ); + } + + // reachable === null: a token is configured but the probe never ran (a + // transient gap between saving settings and the status refresh landing). + // Neutral, never rendered as offline. + return ( +
+
+ +

Checking Beeper Desktop…

+
+ + + +
+ ); +} + +/** + * The attachment byte mirror's own card (#37): what is on disk against the + * budget, and the ONE place a bulk backfill can be started. + * + * The backfill is gated behind a consent modal that names the count and the + * byte size first. That is the root AGENTS.md no-unbidden-work policy applied + * to bytes rather than to LLM calls, and it is the same split as + * `meatspacePostDrillCache` / `CacheFillConsentModal`: the incremental + * fetch-on-view needs no prompt because the user opened the thread, while a + * from-zero batch of thousands of files does. + * + * "Mirror all" gates on the SAVED budget, not the form input: the server reads + * `settings.beeper.attachmentBudgetGb` when it decides where to stop, so + * running with an unsaved number would silently use the old one. + */ +function AttachmentMirrorCard({ budgetGb, settingsDirty }) { + const [summary, setSummary] = useState(null); + const [summaryError, setSummaryError] = useState(null); + const [loading, setLoading] = useState(true); + const [consentOpen, setConsentOpen] = useState(false); + const [running, setRunning] = useState(false); + const mountedRef = useMounted(); + + const loadSummary = useCallback(async () => { + const [result, error] = await getBeeperAttachmentSummary({ silent: true }) + .then((value) => [value, null]) + .catch((err) => [null, err]); + if (!mountedRef.current) return; + setSummary(result); + // "The request failed" and "the mirror is empty" are different answers, and + // only one of them means the numbers below are trustworthy. + setSummaryError(error ? (error?.message || 'Could not read the attachment mirror') : null); + setLoading(false); + }, []); + + useEffect(() => { loadSummary(); }, [loadSummary]); + + const handleBackfill = async () => { + setConsentOpen(false); + setRunning(true); + const result = await backfillBeeperAttachments({}, { silent: true }).catch((err) => { + toast.error(err?.message || 'Attachment backfill failed'); + return null; + }); + if (!mountedRef.current) return; + setRunning(false); + if (result) { + toast.success( + `Mirrored ${result.fetched} attachment(s)${result.failed ? `, ${result.failed} unavailable` : ''}` + + `${result.stoppedForBudget ? ' — stopped at the disk budget' : ''}`, + ); + } + loadSummary(); + }; + + if (loading) return ; + + const budgetBytes = summary?.budgetBytes || 0; + const usedBytes = summary?.usedBytes || 0; + const usedPercent = budgetBytes > 0 ? Math.min(100, Math.round((usedBytes / budgetBytes) * 100)) : 0; + const pending = summary?.pendingCount || 0; + + return ( +
+
+ +

Attachment mirror

+
+

+ Attachment bytes are downloaded when you first open the thread that shows them, kept under the + {' '}{budgetGb} GB budget above, and evicted least-recently-viewed first — never a file Beeper can no + longer re-supply, and never one you locked. Photos and files stay on this machine. +

+ + {summaryError ? ( +

{summaryError}

+ ) : ( + <> +
+
+ {formatBytes(usedBytes)} of {formatBytes(budgetBytes)} + {summary?.storedFiles || 0} file(s) mirrored +
+
+
+
+
+ +
+ + + + +
+ +
+ + +
+ + )} + + setConsentOpen(false)} + onConfirm={handleBackfill} + /> +
+ ); +} + +function Stat({ label, value }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * Names the cost before the transfer starts: how many attachments, how many + * bytes, and — separately — how many the bridge never reported a size for, so + * the total is never quietly presented as complete when it isn't. + */ +function BackfillConsentModal({ open, summary, onCancel, onConfirm }) { + if (!open || !summary) return null; + const unknown = summary.pendingUnknownCount || 0; + return ( + +
+
+ +

Mirror all attachments?

+
+

+ PortOS will download {summary.pendingCount} attachment(s) + {' '}from Beeper Desktop — about {formatBytes(summary.pendingBytes)} + {unknown > 0 && <> plus {unknown} whose size Beeper did not report}. + {' '}It stops when the mirror reaches its {formatBytes(summary.budgetBytes)} budget, skips anything over + {' '}{formatBytes(summary.maxBytes)}, and runs one file at a time so Beeper Desktop stays usable. +

+

+ You do not need this to read attachments: opening a thread mirrors what it shows. This is for having + them all on disk in advance. +

+
+ + +
+
+
+ ); +} diff --git a/client/src/components/messages/beeper/BeeperSettingsPanel.test.jsx b/client/src/components/messages/beeper/BeeperSettingsPanel.test.jsx new file mode 100644 index 0000000000..943981f721 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperSettingsPanel.test.jsx @@ -0,0 +1,632 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const api = vi.hoisted(() => ({ + getSettings: vi.fn(), + updateSettings: vi.fn(), + getBeeperStatus: vi.fn(), + checkBeeperConnection: vi.fn(), + startBeeperOAuth: vi.fn(), + saveBeeperToken: vi.fn(), + disconnectBeeper: vi.fn(), + getBeeperAttachmentSummary: vi.fn(), + backfillBeeperAttachments: vi.fn(), +})); +const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() })); + +vi.mock('../../../services/api', () => api); +vi.mock('../../ui/Toast', () => ({ default: toast })); + +const BeeperSettingsPanel = (await import('./BeeperSettingsPanel')).default; + +const BASE_SETTINGS = { beeper: { enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5 } }; + +// No router: the panel takes `realtime` as a prop and reads nothing off the +// URL — the OAuth outcome flag is the page shell's job, covered by the page's +// own suite. +const renderPanel = (props = {}) => render(); + +const BASE_ATTACHMENT_SUMMARY = { + budgetBytes: 5 * 1024 * 1024 * 1024, + usedBytes: 1024 * 1024, + storedFiles: 2, + pendingCount: 0, + pendingBytes: 0, + pendingUnknownCount: 0, + overCapCount: 0, + unavailableCount: 0, + keptCount: 0, + totalCount: 2, + maxBytes: 32 * 1024 * 1024, +}; + +beforeEach(() => { + vi.clearAllMocks(); + api.getSettings.mockResolvedValue(BASE_SETTINGS); + api.getBeeperAttachmentSummary.mockResolvedValue(BASE_ATTACHMENT_SUMMARY); +}); + +// The three states decided at fork issue #11 and carried into #30's +// Acceptance criteria, plus the defensive fourth (absent-vs-empty sentinel). +describe('BeeperSettingsPanel — status card states', () => { + it('offers both connect paths and nothing else when no token is configured', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: false, reachable: null, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByRole('heading', { name: 'Connect Beeper' })).toBeInTheDocument(); + // Both paths are first-class (#11 decision 3), so both are on screen at once. + expect(screen.getByRole('button', { name: 'Connect Beeper' })).not.toBeDisabled(); + expect(screen.getByLabelText('Or paste an access token')).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + expect(screen.queryByText('Beeper Desktop connected')).toBeNull(); + expect(screen.queryByText('Checking Beeper Desktop…')).toBeNull(); + }); + + it('renders the actionable-fault card with a Retry when a token is present but unreachable', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: false, lastProbeError: 'Beeper request failed: connection refused', baseUrl: 'http://127.0.0.1:23373', accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop unreachable')).toBeInTheDocument(); + expect(screen.getByText('Beeper request failed: connection refused')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument(); + }); + + it('renders the connected state with an empty roster when the mirror holds no accounts', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, appVersion: '4.3.73', accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByTestId('beeper-roster-empty')).toBeInTheDocument(); + }); + + // Fork issue #78: the granted scopes render beside the expiry line, and a + // grant that includes `write` gets no read-only warning. + it('renders the granted scopes on the connected card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + tokenScopes: ['read', 'write'], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByText('Scopes: read, write')).toBeInTheDocument(); + expect(screen.queryByText(/read-only grant/i)).toBeNull(); + }); + + // A grant missing `write` is exactly the read-only-token case #78 exists to + // surface BEFORE a send fails, not after. + it('warns that sending will fail when the grant is missing the write scope', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + tokenScopes: ['read'], + }); + renderPanel(); + + expect(await screen.findByText('Scopes: read')).toBeInTheDocument(); + expect(screen.getByText('Read-only grant — sending will fail.')).toBeInTheDocument(); + }); + + // A pasted token (#11 decision 3) never carries scopes back from Beeper's + // own paste UI, so an empty array there is said out loud rather than + // rendered as if nothing were known. + it('says scopes are unknown for a pasted token with no scopes', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'pasted', reachable: true, lastProbeError: null, accounts: [], + tokenScopes: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByText('Scopes unknown (pasted token).')).toBeInTheDocument(); + }); + + // An empty scopes array from anywhere else (the legacy plaintext path) has + // nothing worth claiming — this must not render a bogus "no scopes" line. + it('renders nothing for an empty scopes list that is not a pasted token', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'legacy-settings', reachable: true, lastProbeError: null, accounts: [], + tokenScopes: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.queryByText(/Scopes/)).toBeNull(); + }); + + // The drawer card's own copy of the list header's sweep-visibility strip + // (#80) — same status payload, different surface. + it('shows a running sweep on the drawer card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + sweep: { + running: true, startedAt: '2026-09-05T10:00:00.000Z', finishedAt: null, reason: 'scheduler', + accountsDone: 3, accountsTotal: 9, chats: 40, messages: 812, + }, + }); + renderPanel(); + + expect(await screen.findByText('Syncing… 3 of 9 accounts')).toBeInTheDocument(); + }); + + it('shows the last-synced time on the drawer card once idle', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + sweep: { + running: false, startedAt: '2026-09-05T10:00:00.000Z', finishedAt: '2026-09-05T10:04:00.000Z', reason: 'manual', + accountsDone: 9, accountsTotal: 9, chats: 210, messages: 4032, + }, + }); + renderPanel(); + + expect(await screen.findByText(/Last synced \d{1,2}:\d{2}/)).toBeInTheDocument(); + }); + + // The mirrored roster comes from `beeper_accounts`, not from a live call — + // which is why #27 stores it. Hiding it whenever the probe fails threw away + // what the install already knew and made an unreachable app look like an + // empty one, so reachability is stated on its own line and the roster stands + // beside it in every reachability state. + it('keeps the mirrored roster on screen while Beeper Desktop is unreachable', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: false, + lastProbeError: 'Beeper request failed: connection refused', + baseUrl: 'http://127.0.0.1:23373', + accounts: [{ accountId: 'acc1', displayName: 'Example WhatsApp', network: 'whatsapp' }], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop unreachable')).toBeInTheDocument(); + expect(screen.getByText('Example WhatsApp')).toBeInTheDocument(); + expect(screen.getByText('whatsapp')).toBeInTheDocument(); + }); + + it('keeps the mirrored roster on screen while the probe has not run yet', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: null, + lastProbeError: null, + accounts: [{ accountId: 'acc1', displayName: 'Example WhatsApp', network: 'whatsapp' }], + }); + renderPanel(); + + expect(await screen.findByText('Checking Beeper Desktop…')).toBeInTheDocument(); + expect(screen.getByText('Example WhatsApp')).toBeInTheDocument(); + }); + + it('renders the connected state with the account roster when accounts are mirrored', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [ + { accountId: 'acc1', displayName: 'Example WhatsApp', network: 'whatsapp' }, + ], + }); + renderPanel(); + + expect(await screen.findByText('Example WhatsApp')).toBeInTheDocument(); + expect(screen.getByText('whatsapp')).toBeInTheDocument(); + }); + + it('renders the transport liveness dot from the status payload, with its actionable app.state remedy (#33)', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: true, + lastProbeError: null, + accounts: [], + realtime: { + state: 'reconnecting', lastEventAt: null, lastPingAt: null, appState: 'needs-login', appStateActionable: true, + }, + }); + renderPanel(); + + await screen.findByText('Beeper Desktop connected'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'reconnecting'); + expect(screen.getByText('Beeper Desktop needs you to sign in again.')).toBeInTheDocument(); + }); + + it('renders the liveness row while Beeper Desktop is unreachable — the probe failing is when it matters', async () => { + // The HTTP probe and the WebSocket are different transports: hiding the dot + // and its remedy inside the reachable branch hid them exactly when a human + // needed them. + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: false, + lastProbeError: 'Beeper request failed: connection refused', + baseUrl: 'http://127.0.0.1:23373', + accounts: [], + realtime: { + state: 'connecting', lastEventAt: null, lastPingAt: null, appState: 'needs-login', appStateActionable: true, + }, + }); + renderPanel(); + + await screen.findByText('Beeper Desktop unreachable'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'connecting'); + expect(screen.getByText('Beeper Desktop needs you to sign in again.')).toBeInTheDocument(); + }); + + it('names the remedy when Beeper rejected the stored token (#33)', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: null, + lastProbeError: null, + accounts: [], + realtime: { + state: 'down', lastEventAt: null, lastPingAt: null, appState: null, appStateActionable: false, authRejected: true, + }, + }); + renderPanel(); + + await screen.findByText('Checking Beeper Desktop…'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'down'); + expect(screen.getByText('Beeper Desktop rejected the stored token — reconnect Beeper.')).toBeInTheDocument(); + }); + + // #31's expired-token card and #33's 401 stand-down describe the same + // credential from two transports, so they have to read as one story: the dot + // corroborates that the socket is down for that reason (and not looping), + // while the "reconnect Beeper" instruction is said once, by the card. + it('shows the transport down on the expired-token card without repeating its remedy', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: null, + lastProbeError: null, + accounts: [], + tokenExpired: true, + tokenExpiresAt: '2020-01-01T00:00:00.000Z', + realtime: { + state: 'down', lastEventAt: null, lastPingAt: null, appState: null, appStateActionable: false, authRejected: true, + }, + }); + renderPanel(); + + await screen.findByText('Beeper token expired'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'down'); + expect(screen.getByRole('button', { name: 'Reconnect Beeper' })).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop rejected the stored token — reconnect Beeper.')).toBeNull(); + }); + + it('renders no liveness row at all when the transport has never reported', async () => { + // `realtime` absent is not-yet-known, never "offline" — the same + // absent-vs-empty rule the `reachable` tri-state follows. + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + }); + renderPanel(); + + await screen.findByText('Beeper Desktop connected'); + expect(screen.queryByTestId('connection-status-dot')).toBeNull(); + }); + + // The absent-vs-empty sentinel (#30 Acceptance): reachable:null must never + // render as offline, even in the (normally unreachable) case where a token + // is configured but the probe never ran. + it('never renders reachable:null as offline', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: null, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Checking Beeper Desktop…')).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + }); + + // The absent-vs-empty rule (root AGENTS.md line 233): a status fetch that + // itself fails must never collapse into "no token configured" — an + // install with a working token whose status request errors would + // otherwise be silently told to connect. + it('never renders "Connect Beeper" when the status fetch itself rejects', async () => { + api.getBeeperStatus.mockRejectedValue(new Error('network down')); + renderPanel(); + + expect(await screen.findByText('Could not read Beeper status')).toBeInTheDocument(); + expect(screen.getByText('network down')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Connect Beeper' })).toBeNull(); + expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument(); + }); + + // Fork issue #61, decision 7: a probe timeout shortly after a real Beeper + // success stays on the connected card with a latency note, never the + // unreachable/actionable-fault card, and never touches the empty-state + // ("Connect Beeper") or the Retry-button branch. + it('renders the slow probe state on the connected card, with its latency, instead of the unreachable card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, probeState: 'slow', probeLatencyMs: 3000, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByText(/Slow to respond \(3000ms\)/)).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + // The connected card's action is "Recheck", never the unreachable card's + // "Retry" — a slow probe must not borrow that copy or its gating. + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull(); + expect(screen.getByRole('button', { name: /Recheck/ })).toBeInTheDocument(); + }); + + it('renders the ordinary connected card with no slow note for probeState:ok', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, probeState: 'ok', probeLatencyMs: 8, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.queryByText(/Slow to respond/)).toBeNull(); + }); + + // A failed accounts read is a different, unknown state from a legitimately + // empty roster — collapsing them would show "No accounts mirrored yet" for + // a DB hiccup. + it('shows the account roster as unknown, not empty, when the mirror read failed', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: null, accountsError: 'Could not read the mirrored account roster', + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByTestId('beeper-roster-unknown')).toHaveTextContent('Could not read the mirrored account roster'); + expect(screen.queryByTestId('beeper-roster-empty')).toBeNull(); + }); +}); + +/** + * Audit cluster 08 (A11Y-6): both inline error paragraphs on this panel + * carried no role, and the fetches behind them are silent, so a + * screen-reader user got no signal that the status read or the account + * roster read had failed. + */ +describe('BeeperSettingsPanel — inline errors are announced', () => { + it('exposes the status-fetch failure with role="alert"', async () => { + api.getBeeperStatus.mockRejectedValue(new Error('network down')); + renderPanel(); + + expect(await screen.findByText('network down')).toHaveAttribute('role', 'alert'); + }); + + it('exposes the account-roster failure with role="alert"', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: null, accountsError: 'Could not read the mirrored account roster', + }); + renderPanel(); + + expect(await screen.findByTestId('beeper-roster-unknown')).toHaveAttribute('role', 'alert'); + }); +}); + +describe('BeeperSettingsPanel — settings', () => { + it('saves the complete settings slice and disables Save until dirty', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [] }); + api.updateSettings.mockResolvedValue({ + beeper: { + enabled: true, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: false, + }, + }); + renderPanel(); + + // Exact, not /Save/: the connect card's "Save token" is on screen too. + const saveButton = await screen.findByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + + fireEvent.click(screen.getByLabelText('Enable scheduled Beeper sync')); + expect(saveButton).not.toBeDisabled(); + + fireEvent.click(saveButton); + await waitFor(() => expect(api.updateSettings).toHaveBeenCalledWith({ + beeper: { + enabled: true, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: false, + }, + })); + expect(toast.success).toHaveBeenCalled(); + }); + + // SEC-2: the opt-in is off by default and never inferred from the baseUrl + // text — it has to be an explicit, separate checkbox flip. + it('carries the non-loopback opt-in as its own explicit field', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [] }); + api.updateSettings.mockResolvedValue({ + beeper: { + enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: true, + }, + }); + renderPanel(); + + const checkbox = await screen.findByLabelText(/Allow a non-loopback base URL/); + expect(checkbox).not.toBeChecked(); + + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalledWith({ + beeper: { + enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: true, + }, + })); + }); + + it('disables Retry while the form has unsaved edits, per the save-gating convention', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: false, lastProbeError: 'refused', baseUrl: 'http://127.0.0.1:23373', accounts: [], + }); + renderPanel(); + + await screen.findByText('Beeper Desktop unreachable'); + const retryButton = screen.getByRole('button', { name: /Retry/ }); + expect(retryButton).not.toBeDisabled(); + + fireEvent.click(screen.getByLabelText('Enable scheduled Beeper sync')); + expect(retryButton).toBeDisabled(); + }); + + // The regression: a failed settings GET used to fall through to DEFAULTS + // silently, so the form rendered as though it had read this install's real + // config — and the next Save PUT those defaults over whatever was actually + // stored. The card must show the failure instead of the (wrong) form. + it('shows the load-failed card instead of the form when settings fail to load, and never offers Save', async () => { + api.getSettings.mockRejectedValue(new Error('network error')); + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [] }); + renderPanel(); + + expect(await screen.findByText('Could not load Beeper settings')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + expect(screen.queryByLabelText('Enable scheduled Beeper sync')).toBeNull(); + }); +}); + +describe('BeeperSettingsPanel — the connect flow (#31)', () => { + beforeEach(() => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: false, reachable: null, lastProbeError: null, accounts: [], + }); + }); + + it('opens the authorization URL the server minted, rather than building one client-side', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + api.startBeeperOAuth.mockResolvedValue({ authorizationUrl: 'http://127.0.0.1:23373/oauth/authorize?state=s' }); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Beeper' })); + await waitFor(() => expect(open).toHaveBeenCalledWith('http://127.0.0.1:23373/oauth/authorize?state=s', '_blank', 'noopener')); + expect(api.startBeeperOAuth).toHaveBeenCalledWith({ silent: true }); + open.mockRestore(); + }); + + // Write paths never auto-retry (the connect exchange burns a single-use + // code): one call, one toast, and no second attempt. + it('reports a failed connect once and does not retry', async () => { + api.startBeeperOAuth.mockRejectedValue(new Error('Beeper authorization-server metadata unavailable (404)')); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Beeper' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Beeper authorization-server metadata unavailable (404)')); + expect(api.startBeeperOAuth).toHaveBeenCalledTimes(1); + }); + + it('posts a pasted token, clears the field, and refreshes status', async () => { + api.saveBeeperToken.mockResolvedValue({ tokenConfigured: true, tokenExpiresAt: null, tokenSource: 'pasted' }); + renderPanel(); + + const input = await screen.findByLabelText('Or paste an access token'); + expect(input).toHaveAttribute('type', 'password'); + fireEvent.change(input, { target: { value: 'example-beeper-token' } }); + fireEvent.click(screen.getByRole('button', { name: /Save token/ })); + + await waitFor(() => expect(api.saveBeeperToken).toHaveBeenCalledWith('example-beeper-token', { silent: true })); + await waitFor(() => expect(input).toHaveValue('')); + expect(api.getBeeperStatus).toHaveBeenCalledTimes(2); + }); + + it('keeps Save token disabled until something is typed', async () => { + renderPanel(); + const save = await screen.findByRole('button', { name: /Save token/ }); + expect(save).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Or paste an access token'), { target: { value: 'example-beeper-token' } }); + expect(save).not.toBeDisabled(); + }); + +}); + +// An expired credential is its own state: Beeper issues no refresh grant, so +// the only action that helps is connecting again — never a generic error. +describe('BeeperSettingsPanel — expired token', () => { + it('renders the reconnect path rather than the unreachable or connected card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'oauth', tokenExpired: true, tokenExpiringSoon: true, + tokenExpiresAt: '2026-01-01T00:00:00.000Z', tokenExpiresInDays: -3, + reachable: false, lastProbeError: 'connection refused', accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper token expired')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reconnect Beeper' })).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + }); +}); + +describe('BeeperSettingsPanel — disconnect', () => { + beforeEach(() => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'pasted', reachable: true, lastProbeError: null, accounts: [], + }); + }); + + // No window.confirm anywhere in the client — the confirmation is inline. + it('confirms inline before disconnecting', async () => { + api.disconnectBeeper.mockResolvedValue({ deleted: true, tokenConfigured: false }); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: /Disconnect/ })); + expect(screen.getByText('Forget this Beeper credential?')).toBeInTheDocument(); + expect(api.disconnectBeeper).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Yes, disconnect/ })); + await waitFor(() => expect(api.disconnectBeeper).toHaveBeenCalledWith({ silent: true })); + await waitFor(() => expect(api.getBeeperStatus).toHaveBeenCalledTimes(2)); + }); + + it('cancels without calling the API', async () => { + renderPanel(); + fireEvent.click(await screen.findByRole('button', { name: /Disconnect/ })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByText('Forget this Beeper credential?')).toBeNull(); + expect(api.disconnectBeeper).not.toHaveBeenCalled(); + }); +}); + +// The attachment mirror card (#37). The bulk backfill is the one path here +// that moves gigabytes, so what is pinned is that it cannot start without a +// consent step that states the cost. +describe('BeeperSettingsPanel — attachment mirror', () => { + beforeEach(() => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'pasted', reachable: true, lastProbeError: null, accounts: [], + }); + }); + + it('renders the disk picture without starting anything', async () => { + renderPanel(); + expect(await screen.findByText('Attachment mirror')).toBeInTheDocument(); + expect(screen.getByText(/of 5 GB/)).toBeInTheDocument(); + expect(api.backfillBeeperAttachments).not.toHaveBeenCalled(); + }); + + it('names the count and the byte size before the backfill runs, and only then runs it', async () => { + api.getBeeperAttachmentSummary.mockResolvedValue({ + ...BASE_ATTACHMENT_SUMMARY, pendingCount: 12, pendingBytes: 4 * 1024 * 1024, pendingUnknownCount: 3, + }); + api.backfillBeeperAttachments.mockResolvedValue({ fetched: 12, failed: 0, bytes: 4194304, stoppedForBudget: false }); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: /Mirror all attachments/i })); + // The modal states BOTH numbers, and the unknown-size tail separately + // rather than folding it into the total as zero. + expect(await screen.findByText('Mirror all attachments?')).toBeInTheDocument(); + expect(screen.getAllByText(/12 attachment\(s\)/).length).toBeGreaterThan(0); + expect(screen.getByText(/4 MB/)).toBeInTheDocument(); + expect(screen.getByText(/3 whose size Beeper did not report/)).toBeInTheDocument(); + expect(api.backfillBeeperAttachments).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Mirror 12 attachment/i })); + await waitFor(() => expect(api.backfillBeeperAttachments).toHaveBeenCalledTimes(1)); + }); + + it('cancels the consent modal without transferring anything', async () => { + api.getBeeperAttachmentSummary.mockResolvedValue({ ...BASE_ATTACHMENT_SUMMARY, pendingCount: 4, pendingBytes: 2048 }); + renderPanel(); + fireEvent.click(await screen.findByRole('button', { name: /Mirror all attachments/i })); + fireEvent.click(await screen.findByRole('button', { name: /^Cancel$/ })); + await waitFor(() => expect(screen.queryByText(/Mirror all attachments\?/)).not.toBeInTheDocument()); + expect(api.backfillBeeperAttachments).not.toHaveBeenCalled(); + }); + + it('reports a failed summary read instead of rendering zeros as the truth', async () => { + api.getBeeperAttachmentSummary.mockRejectedValue(new Error('Database unavailable')); + renderPanel(); + expect(await screen.findByText('Database unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Mirror all attachments/i })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/messages/beeper/BeeperThread.jsx b/client/src/components/messages/beeper/BeeperThread.jsx new file mode 100644 index 0000000000..30189f0df6 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperThread.jsx @@ -0,0 +1,955 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowLeft, Archive, BellOff, ChevronDown, Loader2, Plus, RefreshCw, Send, Trash2, UserPlus, Users, +} from 'lucide-react'; +import NetworkLogo, { networkLabel } from './BeeperNetworkLogo'; +import BeeperAttachment from './BeeperAttachment'; +import BeeperPersonPicker from './BeeperPersonPicker'; +import BeeperCreatePersonForm from './BeeperCreatePersonForm'; +import InlineConfirmRow from '../../ui/InlineConfirmRow'; +import { decodeHtmlEntities, parseMessageBody } from '../../../lib/beeperMessageBody'; +import { formatBytes } from '../../../utils/formatters'; + +/** + * Thread + composer + the inline Tribe-linking action, for the Beeper chat + * surface (#35). Structure ported from the #9 prototype (`BeeperSurface.jsx`): + * date-separator pills, sender avatars, deleted-message placeholders, and a + * composer that names the network it would send on. + * + * Two things this deliberately does NOT do: + * + * - **It does not treat an empty thread as an error or as loading.** History + * depth varies enormously per network (#3), so a mirrored conversation with + * no messages is frequently the true answer. It says that, rather than + * spinning forever or rendering a fault. + * - **It does not name the transport.** #9 wants "… on Google Messages (RCS)"; + * the mirror carries `network` but not `transport` (#27), so the label stops + * at the network rather than inventing one. + * + * Sending itself (#53, wired on the durable outbox from #36) is a thin layer + * over `onSend`/`confirmAndSend`/`cancelConfirmation`/`retryOutboxEntry`/ + * `dismissOutboxEntry`, which the surface supplies from `useBeeperOutbox`. + * This component owns none of the send lifecycle — it only renders what the + * hook reports: the pending/failed/stalled rows in `outboxEntries` (filtered on + * STATE, so a settled `sent` entry shows once as the real mirrored message and + * never as a second bubble — including when its message has aged out of the + * loaded page), and the inline first-contact question when `confirmation` is + * set. Nothing here ever retries a send that touched the + * wire on its own; a `failed` row's "Retry" composes a NEW outbox entry with + * the same text, exactly like typing it again, because Beeper has no + * idempotency key and a client-driven resend of the same row would risk a + * duplicate real message. A stalled `approved` row (PR #60 blocker 1 — a + * send refused before the server could even claim the row, most often + * `OUTBOX_BREAKER_OPEN`) is the opposite case: nothing touched the wire, so + * its "Retry" re-dispatches the SAME row, and it also gets a "Dismiss" to + * give up on it — the original bug was this state having neither. + * + * Direction comes from the mirrored `isSender`, never from comparing + * `senderId` against the local user — `accounts[].user.id` differs from + * `senderID` on every network (#2), so there is nothing to compare against. + * Own messages sit right-aligned with no sender name and no avatar, the + * reference interface's shape. + */ + +/** + * One message body. + * + * Two shapes arrive from Beeper and both are handled here. A PLAIN body is a + * text node with its entities decoded (#59: an ampersand was rendering as the + * five-character `&`, because `normalizeMessageRow` stores what the source + * sent and some bridges send entity-encoded text). An HTML body — 26% of + * messages on a real install, Discord and Matrix — is parsed into an + * allowlisted block/span model by `lib/beeperMessageBody.js` and rendered as + * React elements, since rendering it as a text node showed the tags literally. + * + * Nothing here ever reaches `dangerouslySetInnerHTML`: every branch produces + * elements and text nodes, so a tag outside the allowlist cannot execute, load + * or style anything. + */ +function MessageBody({ body }) { + const blocks = parseMessageBody(body); + if (blocks === null) return

{decodeHtmlEntities(body)}

; + return blocks.map((block, blockIndex) => { + const spans = block.spans.map((span, spanIndex) => { + const key = `${blockIndex}-${spanIndex}`; + let node = span.text; + if (span.bold) node = {node}; + if (span.italic) node = {node}; + if (span.href) { + node = ( + + {node} + + ); + } + return {node}; + }); + return block.type === 'quote' + ? ( +
+ {spans} +
+ ) + :

{spans}

; + }); +} + +const dayLabel = (iso) => { + if (!iso) return 'Unknown date'; + const date = new Date(iso); + const days = Math.floor((Date.now() - date.getTime()) / 86400000); + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return date.toLocaleDateString([], { weekday: 'long' }); + return date.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' }); +}; + +const clockTime = (iso) => (iso ? new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''); + +const initials = (name) => String(name || '?') + .replace(/^[#+@]/, '') + .split(/\s+/) + .slice(0, 2) + .map((word) => word[0]) + .filter(Boolean) + .join('') + .toUpperCase() || '?'; + +function Avatar({ name, size = 40 }) { + return ( + + ); +} + +/** + * A participant row with the inline "Link to Tribe person" action from #34. + * Three shapes in one control, all local to this row's own `mode` state: + * + * - **`create`** (#97 part A) — the picker's "Create new…" no longer posts + * on click; it opens `BeeperCreatePersonForm` in its place, a + * confirm-and-rename step. Only that form's own Create button calls + * `onCreateAndLink`; Cancel returns to the picker. + * - **picker** — either the row's natural unlinked state, or a linked row + * that clicked "Change" (#97 part B). Choosing a result IS the link + * action (unchanged from #98); on a linked row a "Cancel" link (absent + * when the row was never linked — there is nothing to cancel back to) + * returns to the "Linked · …" display without linking anyone. + * - **linked view** — `tribePersonId` present and `mode !== 'picker'`: + * states who it is (a link straight to the person's Tribe page, #98 part + * C) plus "Change" (reopens the picker, pre-focused) and "Unlink" + * (`onUnlink`, #97 part B). + * + * `mode` resets to the natural view whenever the participant's OWN linked + * state changes out from under it — a link/unlink completing, or any other + * cause of a thread refetch — so a stale open form/picker never survives a + * refresh showing a DIFFERENT link state than the one the row was left open + * on. + */ +function ParticipantRow({ + participant, people, linking, onLink, onCreateAndLink, onUnlink, onOpenPerson, +}) { + const pickerId = `beeper-link-${participant.sourceUserId}`; + const name = participant.displayName || participant.handle || participant.sourceUserId; + const linkedId = participant.tribePersonId || null; + const [mode, setMode] = useState('view'); + + const prevLinkedRef = useRef(linkedId); + useEffect(() => { + if (prevLinkedRef.current !== linkedId) { + prevLinkedRef.current = linkedId; + setMode('view'); + } + }, [linkedId]); + + if (mode === 'create') { + return ( +
  • + onCreateAndLink(participant, fields)} + onCancel={() => setMode('view')} + /> +
  • + ); + } + + const showPicker = !linkedId || mode === 'picker'; + if (showPicker) { + return ( +
  • + + {name} + { onLink(participant, personId); setMode('view'); }} + onCreateNew={() => setMode('create')} + /> + {linkedId && ( + + )} + {linking && } +
  • + ); + } + + return ( +
  • + + {name} + {participant.tribePersonName ? ( + + ) : ( + Linked + )} + + + {linking && } +
  • + ); +} + +/** + * The exact sentence shown for a send the server found stranded in `sending` at + * boot (`SEND_INTERRUPTED`, written by `reconcileOutboxOnBoot` in + * `server/services/beeperOutbox.js`, which owns the identical literal — the two + * bundles cannot share a module, so they share a test instead). + * + * It deliberately does not claim a delivery verdict. The POST was in flight + * when the process died, so whether it landed is unknowable from here; the copy + * points at the chat, because looking is the only thing that actually answers + * it, and Retry composes a new message rather than resending that one. + */ +const SEND_INTERRUPTED_COPY = 'Delivery unconfirmed: PortOS restarted mid-send. Check the chat before retrying.'; + +/** The outbox states that still have something to say above the composer. */ +const RENDERED_OUTBOX_STATES = new Set(['approved', 'sending', 'awaiting-confirmation', 'failed']); + +/** + * The counterpart participant in a 1:1 chat (never called for a group chat — + * see `TitleTribeChip` below, which branches on `conversation.isGroup` first). + * + * There is no per-participant self/is-sender marker anywhere in the mirrored + * schema to filter by: `shapeParticipant` (`server/services/beeperConversations.js`) + * carries `sourceUserId`/`displayName`/`handle`/`tribePersonId`/`tribePersonName` + * only, and `isSender` exists solely on MESSAGES, never on a participant row + * (see the `MessageBody`/`OutboxRow` docs above). No account user id is + * exposed to the client either. What IS true, straight from how the roster is + * built (`normalizeParticipants` in `server/services/beeperSync.js` maps + * `chat.participants.items` verbatim, and Beeper's own `items` for a `single` + * chat never includes the local account's own user — see the 1:1 fixtures in + * `beeperSync.test.js`, always exactly one item): a 1:1 conversation's roster + * IS the counterpart, in full. So the rule here is simply "the first — and by + * construction, only — participant"; if a 1:1 conversation's roster somehow + * carried more than one row (a shape Beeper's own contract for a `single` + * chat does not produce), the first is still used as the best-effort answer + * rather than declining to render a chip at all. + */ +const oneToOneCounterpart = (conversation) => (conversation?.participants || [])[0] || null; + +/** + * The Tribe link beside the thread title (#98 part A). Whether this reads as + * a 1:1 chat or a group chat comes from the mirrored `conversation.isGroup` + * (`beeperSync.js`'s `normalizeChat` sets it straight from Beeper's own + * `chat.type === 'group'`), never from participant count — the participant + * subset is truncated (`hasMoreParticipants`) and can legitimately read back + * as a single row for a group Beeper only handed one member over. + * + * A group chat's affordance is honest about being a subset, not a total: + * Beeper's `Chat` payload carries no total-member count independent of the + * (truncated) roster PortOS stores, so the count shown is `participants.length` + * with a trailing `+` when `hasMoreParticipants` is set — the same qualifier + * the People drawer's own heading already uses just below. + */ +function TitleTribeChip({ conversation, onOpenParticipants, onOpenPerson }) { + if (conversation.isGroup) { + const count = (conversation.participants || []).length; + const label = `${count}${conversation.hasMoreParticipants ? '+' : ''} ${count === 1 ? 'person' : 'people'}`; + return ( + + ); + } + + const counterpart = oneToOneCounterpart(conversation); + if (counterpart?.tribePersonId) { + return ( + + ); + } + + return ( + + ); +} + +/** + * One outbox row — a send that has not yet been confirmed by the mirror, or + * one that failed. Always outbound (right-aligned, no avatar), matching the + * bubble a mirrored `isSender` message renders, so a pending send does not + * visually jump when it swaps for the real thing. + * + * Only ONE of the four outcomes below spins, and the spinner is the exception + * rather than the default. Every state that will not change on its own — a + * failure, an interrupted send, an unconfirmed one, a refused one — resolves to + * a terminal line that says what happened, because a spinner for a state + * nothing can ever advance is a lie the user cannot dismiss, and it survives + * every reload. + * + * `approved` is normally in flight for the moment between the create and + * send requests (`sending` is true). If it is STILL `approved` once nothing + * is actively sending — and it is not the row a first-contact confirmation + * is currently asking about — the send was refused before the server could + * even claim the row, most often `OUTBOX_BREAKER_OPEN` (#36). Left alone + * that renders as a permanent "Sending…" phantom that survives reload (PR + * #60 blocker 1), so it gets the same "stalled" treatment as `failed`: a + * reason, a Retry, and — since nothing here was ever posted to Beeper — a + * Dismiss that gives up on it outright. + * + * The two Retries are not the same action, and the breaker splits them (PR + * #60 blocker 2). A `failed` row's Retry composes a NEW entry through the + * composer's own send path, which the breaker blocks exactly as it blocks + * Send — so with the breaker tripped that button is disabled and carries the + * same reason as the Send button, rather than staying live and doing nothing. + * A `stalled` row's Retry re-dispatches the existing row: the server decides, + * and its 429 toasts, so it stays enabled. + */ +function OutboxRow({ + entry, sending, isConfirming, onRetry, onDismiss, breakerTripped, breakerReason, +}) { + const failed = entry.state === 'failed'; + const interrupted = failed && entry.errorCode === 'SEND_INTERRUPTED'; + // Recorded by the server's 30s fallback when it could not find the message it + // had just sent. The row stays `awaiting-confirmation` on purpose — the send + // may well have been delivered, and marking it failed would invite the one + // mistake that cannot be taken back — so this reads as "sent, unconfirmed", + // carries the reason the server recorded, and offers NO Retry. It never + // changes on its own, so it must never spin. + const unresolved = !failed && entry.errorCode === 'CONFIRMATION_UNRESOLVED'; + const stalled = entry.state === 'approved' && !sending && !isConfirming; + const blocked = failed || stalled; + const confirming = entry.state === 'awaiting-confirmation' || entry.state === 'sent'; + const retryBlocked = breakerTripped && !stalled; + const outcome = blocked ? (failed ? 'failed' : 'stalled') : (unresolved ? 'unconfirmed' : 'pending'); + // An interrupted send gets the copy verbatim and nothing else: prefixing + // "Not delivered" would assert a verdict the crash destroyed the evidence for. + let blockedReason = 'Not sent — the send was refused'; + if (interrupted) blockedReason = SEND_INTERRUPTED_COPY; + else if (failed) blockedReason = `Not delivered${entry.errorMessage ? ` — ${entry.errorMessage}` : ''}`; + return ( +
    +
    +

    {entry.body}

    + + {blocked && ( + <> + {blockedReason} + + {stalled && ( + + )} + + )} + {unresolved && ( + + {`Sent, unconfirmed${entry.errorMessage ? ` — ${entry.errorMessage}` : ''}`} + + )} + {!blocked && !unresolved && ( + + + {confirming ? 'Confirming…' : 'Sending…'} + + )} + +
    +
    + ); +} + +export default function BeeperThread({ + conversation, + messages, + loading, + error, + hasMore, + loadingMore, + onLoadMore, + draft, + onDraftChange, + outboxEntries = [], + sending = false, + confirmation = null, + onSend, + confirmAndSend, + cancelConfirmation, + retryOutboxEntry, + dismissOutboxEntry, + breaker = null, + people, + linkingId, + onLinkParticipant, + onCreateAndLinkParticipant, + onUnlinkParticipant, + onOpenTribePerson, + onBack, + onRetry, + onArchive, + onLowPriority, + onPurge, + purging, + onAttachmentUpdated, + writePending, +}) { + const [peopleOpen, setPeopleOpen] = useState(false); + const [purgeOpen, setPurgeOpen] = useState(false); + const [purgeConfirmation, setPurgeConfirmation] = useState(''); + const bottomRef = useRef(null); + + // Newest-first from the API (the order a chat surface pages in); oldest-first + // for display. Reversing here rather than server-side keeps the cursor + // semantics honest: the API never pretends the oldest message is one call away. + const ordered = useMemo(() => [...messages].reverse(), [messages]); + const senderName = useMemo(() => { + const map = new Map(); + for (const participant of conversation?.participants || []) { + map.set(participant.sourceUserId, participant.tribePersonName || participant.displayName || participant.handle || ''); + } + return map; + }, [conversation?.participants]); + + // Outbox rows still worth showing: anything the mirror has not caught up + // with yet, decided on the entry's own STATE rather than on whether its + // message happens to be in the page currently loaded. `sent` is settled — + // the mirrored message IS the record of it — and `GET /outbox` returns up to + // 50 entries in every state, so a `sent` entry whose message had aged out of + // the newest page used to render forever as a spinning bubble with old text. + // `draft` is excluded for the same reason it has no writer: it is not a send. + // `entries` arrives newest-first (the server's own order, preserved through + // every client-side prepend); reversed to read oldest-first like `ordered`. + const visibleOutbox = useMemo(() => { + const mirroredIds = new Set(messages.map((message) => message.id)); + return [...outboxEntries] + .filter((entry) => RENDERED_OUTBOX_STATES.has(entry.state)) + // The tiebreak, not the rule: an `awaiting-confirmation` row the sweep + // already mirrored would otherwise show twice, once as each. + .filter((entry) => !(entry.messageId && mirroredIds.has(entry.messageId))) + .reverse(); + }, [outboxEntries, messages]); + + const trimmedDraft = draft.trim(); + const breakerTripped = Boolean(breaker?.tripped); + const canSend = trimmedDraft.length > 0 && !sending && !breakerTripped; + const sendDisabledReason = breakerTripped + ? `Beeper sending is blocked by the runaway breaker (${breaker?.reason || 'unexpected send rate'}) — clear it in Beeper settings.` + : (trimmedDraft.length === 0 ? 'Type a message to send' : undefined); + + const handleSendClick = () => { if (canSend) onSend(draft); }; + // Bare Enter stays a newline (the textarea is multi-line); ⌘/Ctrl+Enter is + // the send shortcut, matching the reference interface and #53's spec. + const handleComposerKeyDown = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + handleSendClick(); + } + }; + // A failed row's only recovery: compose the SAME text again as a brand new + // outbox entry. Never a resend of the failed row — see the file docstring. + // A stalled `approved` row (PR #60 blocker 1) is the opposite case: nothing + // ever reached Beeper for it, so retrying re-sends the SAME row instead — + // composing a new one on every click would just manufacture more phantoms + // while the breaker stays tripped. + // + // `clearsDraft: false` on the failed-row path: that send is the OLD row's + // text, not what is in the composer. Clearing on its success would throw + // away a message typed while the failed row sat above it — and drop it from + // storage too, since the surface's `setDraft('')` deletes the persisted + // entry. Only the composer's own Send clears the composer. + const handleRetry = (entry) => { + if (entry.state === 'approved') { retryOutboxEntry?.(entry); return; } + if (!breakerTripped) onSend(entry.body, { clearsDraft: false }); + }; + const handleDismiss = (entry) => { dismissOutboxEntry?.(entry); }; + + // Scrolls to the bottom only when what is actually AT the bottom changed — + // the newest mirrored message, or (once one exists) the newest pending + // send — or the conversation itself changed. Tracked in a ref rather than + // read off `ordered.length`/`visibleOutbox.length` as dependencies (the + // original bug, PERF-8/A11Y-2): "Load earlier messages" only grows `ordered` + // by prepending OLDER messages, which changes `ordered.length` on every + // click without moving `ordered[ordered.length - 1]` — the newest message — + // at all, so keying on the length alone yanked the reader back to the + // bottom on every page-in of history. `ordered` is oldest-first (see + // above), so its newest entry is the LAST one; outbox rows render after it + // and are the true bottom while a send is still pending. + const latestBottomRef = useRef(null); + useEffect(() => { + const newestMessageId = ordered.length ? ordered[ordered.length - 1].id : null; + const newestOutboxId = visibleOutbox.length ? visibleOutbox[visibleOutbox.length - 1].id : null; + const bottomKey = `${conversation?.id ?? ''}:${newestOutboxId ?? ''}:${newestMessageId ?? ''}`; + if (latestBottomRef.current === bottomKey) return; + latestBottomRef.current = bottomKey; + bottomRef.current?.scrollIntoView({ block: 'end' }); + }, [conversation?.id, ordered, visibleOutbox]); + + // A typed confirmation must never survive the conversation it was typed for: + // switching threads with the panel open would otherwise leave a primed Purge + // button pointing at a different chat. + // + // Keyed on a CHANGE of id, not on the id as a dependency: a refetch that + // momentarily resolves `conversation` to null (a reload, a failed poll) would + // otherwise fire this twice and silently close a panel the user is typing + // into — the value is discarded on a real switch, never on a re-render. + const purgeConversationRef = useRef(null); + useEffect(() => { + const id = conversation?.id || null; + if (!id || purgeConversationRef.current === id) return; + purgeConversationRef.current = id; + setPurgeOpen(false); + setPurgeConfirmation(''); + }, [conversation?.id]); + + // Order matters, and this is the whole reason these three are separate + // branches: `loading` and `error` are both reachable with NO conversation — + // a cold deep link whose detail fetch fails (503, 500, offline) leaves + // `conversation` null and `error` set. Answering that with "Pick a + // conversation" renders a named URL as if nothing were selected, with no + // error and no way back: every fetch behind this passes `{ silent: true }`, + // so there is no toast either. "Pick a conversation" is only correct when + // nothing is selected, nothing is in flight, and nothing went wrong. + if (!conversation && error) { + return ( +
    +

    Could not open this conversation

    +

    {error}

    +
    + {onRetry && ( + + )} + +
    +
    + ); + } + + if (!conversation && loading) { + return ( +
    + Loading conversation… +
    + ); + } + + if (!conversation) { + return ( +
    + Pick a conversation +
    + ); + } + + let lastDay = null; + + return ( +
    +
    + + +
    +

    + {conversation.title || 'Untitled conversation'} + {conversation.isMuted && } + setPeopleOpen((open) => !open)} + onOpenPerson={onOpenTribePerson} + /> +

    +

    {networkLabel(conversation.network)}

    +
    +
    + + + + {onPurge && ( + + )} +
    +
    + + {peopleOpen && ( +
    + {/* Beeper truncates a participant list at 20 (list) / 100 (single + GET) with no participants endpoint and no cursor, so this is a + subset by construction — saying so beats implying a roster. */} +

    + Participants{conversation.hasMoreParticipants ? ' (partial — Beeper truncates long rosters)' : ''} +

    + {(conversation.participants || []).length === 0 ? ( +

    No participants mirrored yet.

    + ) : ( +
      + {conversation.participants.map((participant) => ( + + ))} +
    + )} +
    + )} + + {/* The purge confirmation is TYPED, in-drawer, and names both the + conversation and the bytes it is about to free (#13) — not a + `window.confirm`, which the client conventions forbid and which could + not state either fact. It is also explicit that this is a LOCAL + purge: Beeper still has the chat, and the next sweep re-mirrors it. */} + {purgeOpen && onPurge && ( +
    +

    Purge this mirror

    +

    + Deletes PortOS’s copy of {conversation.title || 'this conversation'} + {' '}— its messages, participants and{' '} + {formatBytes(conversation.attachmentBytes || 0)} of mirrored attachment bytes + {conversation.attachmentFiles ? ` across ${conversation.attachmentFiles} file(s)` : ''}. +

    +

    + Beeper itself is untouched: the chat stays on its network, and the next sync will mirror it again. +

    +
    + + setPurgeConfirmation(event.target.value)} + autoComplete="off" + className="w-28 rounded border border-port-border bg-port-bg px-2 py-1 text-xs text-white" + /> + + +
    +
    + )} + +
    + {hasMore && ( +
    + +
    + )} + + {error &&

    {error}

    } + + {loading && ordered.length === 0 && ( +

    Loading messages…

    + )} + + {/* An empty thread is a legitimate steady state, not a spinner and not + an error — history depth varies enormously per network. Gated on + `visibleOutbox` too: the very first message to a brand-new, + genuinely-empty conversation is itself a pending outbox row, and + that is not "no messages mirrored yet" either. */} + {!loading && !error && ordered.length === 0 && visibleOutbox.length === 0 && ( +
    +

    No messages mirrored yet

    +

    + How much history a bridge hands over varies enormously between networks — some backfill years, + some only what has arrived since you connected. An empty thread here is often correct rather than broken. +

    +
    + )} + + {ordered.map((message) => { + const day = dayLabel(message.sentAt); + const showDay = day !== lastDay; + lastDay = day; + const name = senderName.get(message.senderId) || message.senderId || 'Unknown sender'; + const out = message.isSender === true; + return ( +
    + {showDay && ( +

    + {day} +

    + )} +
    + {!out && } +
    + {!out &&

    {name}

    } + {message.unsentAt ? ( +

    + This message was unsent +

    + ) : ( + + )} + {/* Bytes arrive on first view through the mirror route, not + with the message payload — see BeeperAttachment. */} + {(message.attachments || []).map((attachment) => ( + onAttachmentUpdated(message.id, updated) + : undefined} + /> + ))} + + {clockTime(message.sentAt)} + {message.editedAt && · edited} + +
    +
    +
    + ); + })} + + {/* Pending/failed sends. Not yet in `messages` — that arrives only + once the mirror has caught up (#53, on the outbox from #36). */} + {visibleOutbox.map((entry) => ( + + ))} + +
    +
    + + {/* First-contact confirmation (#8 decision 5, wired on #53): PortOS has + never completed a send to this conversation, so the server refused + and asked. Inline, never `window.confirm` — client conventions. */} + {confirmation && ( + + )} + +
    + +
    + + +