From 207c811c918684089fa929fc99a9d6509b2ba449 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:40:20 +0100 Subject: [PATCH 1/9] fix(web-console): name the peer in the conversation header and message request The header joined raw device-ids and the message request named the requester by device-id, bypassing the naming convention every other peer-bearing surface uses. --- .../web-console/src/components/RoomPanel.tsx | 8 ++++-- .../web-console/test/RoomPanel.unit.test.tsx | 26 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index 4d8266a3..8ce5d7b1 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -4,6 +4,7 @@ import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; import { SubmitRow } from "web-ui-primitives"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; +import { PeerName } from "./PeerName.js"; import { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -41,7 +42,9 @@ export function RoomPanel({ return ( - {view.participants.join(", ")} + + {participantLabel(view, (hex) => names.labelOf(hex).primary)} + {routeLabel(view.via)} @@ -51,7 +54,8 @@ export function RoomPanel({ - {view.pendingJoinRequest.requesterHex} wants to message you + {" "} + wants to message you + )} + + {intro.visible && }
@@ -419,6 +435,10 @@ export function App({ {failure.reason}. Messages go through the hub instead. ))} + ) : ( - +
device @@ -180,11 +181,13 @@ export function ConnectionPanel({ {directory.map((entry) => ( - + - {entry.advert.addresses.join(", ")} - + + {entry.advert.addresses.join(", ")} + + {formatAgo( @@ -193,7 +196,7 @@ export function ConnectionPanel({ - +
+
device @@ -56,11 +57,13 @@ export function DiscoveredPeersPanel({ {peers.map((peer) => ( - + - {peer.addresses.join(", ")} - + + {peer.addresses.join(", ")} + + {peer.via.device !== undefined && ( )} @@ -68,7 +71,7 @@ export function DiscoveredPeersPanel({ over {peer.via.address} - + + + ); +} diff --git a/ts/packages/web-console/src/components/SearchPanel.tsx b/ts/packages/web-console/src/components/SearchPanel.tsx new file mode 100644 index 00000000..c1656ea6 --- /dev/null +++ b/ts/packages/web-console/src/components/SearchPanel.tsx @@ -0,0 +1,87 @@ +// Search box and results over every conversation's messages and notices. A result names its conversation, says whether it is a message or a notice, highlights what matched, and opens that conversation when clicked. The matching itself is search.ts; this only renders it. + +import { useState } from "react"; +import { + Badge, + Group, + Highlight, + NavLink, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { participantLabel } from "../conversations.js"; +import type { ConversationView } from "../conversations.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { queryTerms, searchConversations } from "../search.js"; + +export interface SearchPanelProps { + conversations: readonly ConversationView[]; + onSelect: (roomPath: string) => void; +} + +export function SearchPanel({ + conversations, + onSelect, +}: Readonly): React.JSX.Element | null { + const names = usePeerNames(); + const [query, setQuery] = useState(""); + if (conversations.length === 0) { + return null; + } + const results = searchConversations(conversations, query); + const searching = queryTerms(query).length > 0; + const conversationOf = new Map( + conversations.map((conversation) => [conversation.roomPath, conversation]), + ); + return ( + + { + setQuery(event.currentTarget.value); + }} + /> + {searching && results.length === 0 && ( + + Nothing matches. + + )} + {results.map((result) => { + const conversation = conversationOf.get(result.roomPath); + return ( + + {result.text} + + } + description={ + + + {result.kind} + + + {conversation === undefined + ? result.roomPath + : participantLabel( + conversation, + (hex) => names.labelOf(hex).primary, + )} + {" · "} + {new Date(result.at).toLocaleString()} + + + } + onClick={() => { + onSelect(result.roomPath); + }} + /> + ); + })} + + ); +} diff --git a/ts/packages/web-console/src/hooks/use-intro.ts b/ts/packages/web-console/src/hooks/use-intro.ts new file mode 100644 index 00000000..54546e62 --- /dev/null +++ b/ts/packages/web-console/src/hooks/use-intro.ts @@ -0,0 +1,42 @@ +// Whether the first-run intro is on screen. It opens on a device that has never dismissed it, stays closed until the stored answer has loaded so a returning user never sees it flash, and can be reopened. + +import { useCallback, useEffect, useState } from "react"; +import type { PreferencesStore } from "../preferences-store.js"; + +export interface Intro { + /** True once the stored answer has loaded and the intro is wanted. */ + visible: boolean; + /** True once the stored answer has loaded, so the control that reopens the intro is not offered before then. */ + ready: boolean; + dismiss: () => void; + show: () => void; +} + +export function useIntro(store: Readonly): Intro { + const [dismissed, setDismissed] = useState(undefined); + + useEffect(() => { + void store.introDismissed().then(setDismissed); + }, [store]); + + const remember = useCallback( + (value: boolean): void => { + setDismissed(value); + void store.setIntroDismissed(value); + }, + [store], + ); + const dismiss = useCallback(() => { + remember(true); + }, [remember]); + const show = useCallback(() => { + remember(false); + }, [remember]); + + return { + visible: dismissed === false, + ready: dismissed !== undefined, + dismiss, + show, + }; +} diff --git a/ts/packages/web-console/src/main.tsx b/ts/packages/web-console/src/main.tsx index 7be9dbb0..cb70f61d 100644 --- a/ts/packages/web-console/src/main.tsx +++ b/ts/packages/web-console/src/main.tsx @@ -7,6 +7,7 @@ import { MantineProvider } from "@mantine/core"; import { createIndexedDbStorage } from "./adapters/indexeddb-storage.js"; import { createPersistedWebCryptoIdentity } from "./adapters/web-crypto-identity.js"; import { createCertificateMemory } from "./certificate-memory.js"; +import { createPreferencesStore } from "./preferences-store.js"; import { createNameStore } from "./name-store.js"; import { createMessageStore } from "./message-store.js"; import { App } from "./App.js"; @@ -22,6 +23,7 @@ const certificateMemory = createCertificateMemory( await createIndexedDbStorage(), ); const nameStore = createNameStore(await createIndexedDbStorage()); +const preferences = createPreferencesStore(await createIndexedDbStorage()); const address = defaultHubAddress(import.meta.env.DEV, window.location); const container = document.getElementById("root"); @@ -38,6 +40,7 @@ createRoot(container).render( messageStore={messageStore} certificateMemory={certificateMemory} nameStore={nameStore} + preferences={preferences} defaultAddress={address} /> diff --git a/ts/packages/web-console/src/preferences-store.ts b/ts/packages/web-console/src/preferences-store.ts new file mode 100644 index 00000000..f1ef29d1 --- /dev/null +++ b/ts/packages/web-console/src/preferences-store.ts @@ -0,0 +1,29 @@ +// Persistence for the console's own interface preferences over the portable KeyValueStorage port: today, whether the first-run intro has been dismissed. + +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; + +const INTRO_DISMISSED_KEY = "preferences/intro-dismissed"; +const FLAG_SET = new Uint8Array([1]); + +export interface PreferencesStore { + introDismissed: () => Promise; + /** Records that the intro was dismissed, or (with false) that it should be shown again. */ + setIntroDismissed: (dismissed: boolean) => Promise; +} + +export function createPreferencesStore( + storage: Readonly, +): PreferencesStore { + return { + async introDismissed() { + return (await storage.get(INTRO_DISMISSED_KEY)) !== undefined; + }, + async setIntroDismissed(dismissed) { + if (dismissed) { + await storage.set(INTRO_DISMISSED_KEY, new Uint8Array(FLAG_SET)); + } else { + await storage.delete(INTRO_DISMISSED_KEY); + } + }, + }; +} diff --git a/ts/packages/web-console/src/search.ts b/ts/packages/web-console/src/search.ts new file mode 100644 index 00000000..a46fb8c6 --- /dev/null +++ b/ts/packages/web-console/src/search.ts @@ -0,0 +1,78 @@ +// Search across every conversation's messages and notices. The conversation state already holds the message history restored from the IndexedDB message store and every message that has arrived since, plus the notices each conversation's board has read, so searching it covers stored and live content alike with no second scan of storage. Pure, so matching and ordering can be tested without a UI. + +import type { ConversationView } from "./conversations.js"; + +/** The most results returned. The list is rendered whole, so an unbounded one would make a one-letter query lay out the entire history. */ +export const MAX_SEARCH_RESULTS = 50; + +export interface SearchResult { + /** Unique across results: the room path, the kind and the position within that conversation's list. */ + key: string; + roomPath: string; + kind: "message" | "notice"; + /** The text that matched. */ + text: string; + /** When it was sent or posted, in milliseconds since the epoch. */ + at: number; + /** For a message, whether this console sent it. A notice has no direction. */ + direction: "sent" | "received" | undefined; +} + +/** The lowercase, non-empty whitespace-separated terms of a query. */ +export function queryTerms(query: string): string[] { + return query + .toLowerCase() + .split(/\s+/) + .filter((term) => term !== ""); +} + +function matches(text: string, terms: readonly string[]): boolean { + const haystack = text.toLowerCase(); + return terms.every((term) => haystack.includes(term)); +} + +/** + * The messages and readable notices containing every term of `query` (case-insensitively, in any order), newest first, at most MAX_SEARCH_RESULTS. A notice this console cannot decrypt has no text and never matches. A blank query matches nothing. + */ +export function searchConversations( + conversations: readonly ConversationView[], + query: string, +): SearchResult[] { + const terms = queryTerms(query); + if (terms.length === 0) { + return []; + } + const decoder = new TextDecoder(); + const results: SearchResult[] = []; + for (const conversation of conversations) { + conversation.messages.forEach((message, index) => { + if (matches(message.text, terms)) { + results.push({ + key: `${conversation.roomPath}/message/${String(index)}`, + roomPath: conversation.roomPath, + kind: "message", + text: message.text, + at: message.sentAt, + direction: message.direction, + }); + } + }); + conversation.notices.forEach((notice, index) => { + if (notice.plaintext === undefined || notice.claims === undefined) { + return; + } + const text = decoder.decode(notice.plaintext); + if (matches(text, terms)) { + results.push({ + key: `${conversation.roomPath}/notice/${String(index)}`, + roomPath: conversation.roomPath, + kind: "notice", + text, + at: notice.claims["posted-at"], + direction: undefined, + }); + } + }); + } + return results.sort((a, b) => b.at - a.at).slice(0, MAX_SEARCH_RESULTS); +} diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index a58dfaab..976c68a9 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -32,6 +32,7 @@ import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address"; import { signPeerAdvert } from "wire-mesh-core/domain/peer-advert"; import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createPreferencesStore } from "../src/preferences-store.js"; import { createNameStore } from "../src/name-store.js"; import { selfAssertedName, @@ -78,6 +79,13 @@ class TrackedFakeWebSocket extends FakeWebSocket { } } +/** Storage holding the answer of a device whose user has already dismissed the intro. */ +function dismissedPreferencesStorage(): KeyValueStorage { + const storage = createMemoryStorage(); + void createPreferencesStore(storage).setIntroDismissed(true); + return storage; +} + function renderApp( options: Readonly<{ discoverLocalNode?: () => Promise; @@ -85,6 +93,8 @@ function renderApp( messageStore?: MessageStore; nameStorage?: KeyValueStorage; certificateMemory?: CertificateMemory; + /** The storage the intro preference is kept in. Defaults to one where the intro was already dismissed, so tests not about it see the console as a returning user does. */ + preferencesStorage?: KeyValueStorage; }> = {}, ): ReturnType { const { @@ -93,6 +103,7 @@ function renderApp( messageStore = fakeMessageStore(), nameStorage = createMemoryStorage(), certificateMemory = createCertificateMemory(createMemoryStorage()), + preferencesStorage = dismissedPreferencesStorage(), } = options; return render( @@ -102,6 +113,7 @@ function renderApp( messageStore={messageStore} certificateMemory={certificateMemory} nameStore={createNameStore(nameStorage)} + preferences={createPreferencesStore(preferencesStorage)} {...(discoverLocalNode === undefined ? {} : { discoverLocalNode })} {...(defaultAddress === undefined ? {} : { defaultAddress })} /> @@ -655,4 +667,78 @@ describe("App", () => { within(panel).getByText("over ws://hub.example:8787"), ).toBeInTheDocument(); }); + + describe("first-run intro", () => { + it("shows on a device that has never dismissed it, and explains why discovered peers wait", async () => { + renderApp({ preferencesStorage: createMemoryStorage() }); + + const intro = await screen.findByTestId("onboarding-intro"); + expect( + within(intro).getByText(/Discovered peers wait for you/), + ).toBeInTheDocument(); + }); + + it("is dismissed by Got it, stays dismissed on the next visit, and comes back from the header", async () => { + const preferencesStorage = createMemoryStorage(); + const first = renderApp({ preferencesStorage }); + fireEvent.click(await screen.findByRole("button", { name: "Got it" })); + + expect(screen.queryByTestId("onboarding-intro")).toBeNull(); + first.unmount(); + + renderApp({ preferencesStorage }); + fireEvent.click( + await screen.findByRole("button", { name: "How this works" }), + ); + + expect(screen.getByTestId("onboarding-intro")).toBeInTheDocument(); + }); + + it("does not show to a returning user", async () => { + renderApp(); + + await screen.findByRole("button", { name: "How this works" }); + expect(screen.queryByTestId("onboarding-intro")).toBeNull(); + }); + }); + + it("finds a stored message by search and opens its conversation", async () => { + const peerHex = "2".repeat(DEVICE_ID_HEX_LENGTH); + const store = fakeMessageStore(); + await store.append( + dmRoomPath(deviceIdToHex(appIdentity.deviceId), peerHex), + { + direction: "received", + text: "meet by the harbour", + messageId: new Uint8Array([1]), + sentAt: 1000, + }, + ); + renderApp({ messageStore: store }); + + fireEvent.change( + await screen.findByLabelText("Search conversations and notices"), + { target: { value: "harbour" } }, + ); + + const panel = await screen.findByTestId("search-panel"); + expect( + within(panel).getByText("harbour", { selector: "mark" }), + ).toBeInTheDocument(); + }); + + it("labels every directory cell with its column so the table can stack on a phone", async () => { + renderApp(); + const rootSocket = await connectRoot(); + rootSocket.emitMessage(arrayBuffer(messageFromFrame(gossipedFrame))); + + const panel = await screen.findByTestId("discovered-peers"); + const cells = [...panel.querySelectorAll("tbody td")]; + expect(cells.map((cell) => cell.getAttribute("data-label"))).toEqual([ + "device", + "addresses", + "gossiped by", + "actions", + ]); + }); }); diff --git a/ts/packages/web-console/test/SearchPanel.unit.test.tsx b/ts/packages/web-console/test/SearchPanel.unit.test.tsx new file mode 100644 index 00000000..fd713def --- /dev/null +++ b/ts/packages/web-console/test/SearchPanel.unit.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SearchPanel } from "../src/components/SearchPanel.js"; +import type { ConversationView } from "../src/conversations.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const PEER = "2".repeat(DEVICE_ID_HEX_LENGTH); + +function conversation(text: string): ConversationView { + return { + roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${PEER}`, + participants: [PEER], + status: "connected", + via: "direct", + messages: [ + { + direction: "received", + text, + messageId: new Uint8Array([1]), + sentAt: 1, + }, + ], + notices: [], + outgoing: [], + pendingJoinRequest: undefined, + unread: 0, + }; +} + +function renderSearch( + conversations: readonly ConversationView[], + onSelect: (roomPath: string) => void = () => undefined, + store = memoryNameStore(), +): void { + render( + + + , + ); +} + +function search(query: string): void { + fireEvent.change(screen.getByLabelText("Search conversations and notices"), { + target: { value: query }, + }); +} + +describe("SearchPanel", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders nothing while there are no conversations to search", () => { + renderSearch([]); + + expect(screen.queryByTestId("search-panel")).not.toBeInTheDocument(); + }); + + it("lists no results for a blank query", () => { + renderSearch([conversation("lunch at the harbour")]); + + expect(screen.queryByText(/lunch/)).toBeNull(); + expect(screen.queryByText("Nothing matches.")).toBeNull(); + }); + + it("shows a matching message with its kind and the peer's petname, and opens its conversation on click", async () => { + const store = memoryNameStore(); + await store.setPetname(PEER, "Ada"); + const onSelect = vi.fn<(roomPath: string) => void>(); + const view = conversation("lunch at the harbour"); + renderSearch([view], onSelect, store); + + search("harbour"); + + const result = await screen.findByText("harbour", { selector: "mark" }); + expect(screen.getByText("message")).toBeInTheDocument(); + expect(screen.getByText(/^Ada/)).toBeInTheDocument(); + + fireEvent.click(result); + + expect(onSelect).toHaveBeenCalledWith(view.roomPath); + }); + + it("says when nothing matches", () => { + renderSearch([conversation("lunch at the harbour")]); + + search("zebra"); + + expect(screen.getByText("Nothing matches.")).toBeInTheDocument(); + }); +}); diff --git a/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts new file mode 100644 index 00000000..ecc1499a --- /dev/null +++ b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts @@ -0,0 +1,74 @@ +// A real-browser check of the first-run intro and the phone layout, against a real relay and two independent console instances: a device's intro appears once and stays dismissed after a reload, and on a phone-width viewport the peer directory, which has a real row because a second console is connected, stacks into a card that fits the screen, where the same table on a desktop-width viewport keeps its columns. + +import { type Page, chromium, expect, test } from "@playwright/test"; +import { RELAY_ADDRESS, VITE_PORT } from "../../playwright.config.js"; + +const PHONE_VIEWPORT = { width: 360, height: 640 }; +const DIRECTORY_TIMEOUT_MS = 15_000; +const TEST_TIMEOUT_MS = 60_000; + +/** Whether the page fits its viewport horizontally: a table too wide for a phone widens the document past it. */ +async function fitsViewportWidth(page: Readonly): Promise { + return page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ); +} + +/** How the browser lays out a directory cell: a table cell on a wide screen, a block once the row has stacked into a card. */ +async function directoryCellDisplay(page: Readonly): Promise { + return page + .locator('td[data-label="device"]') + .first() + .evaluate((cell) => getComputedStyle(cell).display); +} + +test("the intro shows once per device, and the directory stacks into cards on a phone screen", async () => { + test.setTimeout(TEST_TIMEOUT_MS); + const appUrl = `http://localhost:${String(VITE_PORT)}/`; + const phoneBrowser = await chromium.launch(); + const peerBrowser = await chromium.launch(); + try { + const phone = await ( + await phoneBrowser.newContext({ viewport: PHONE_VIEWPORT }) + ).newPage(); + phone.on("pageerror", (error) => { + throw error; + }); + const peer = await (await peerBrowser.newContext()).newPage(); + peer.on("pageerror", (error) => { + throw error; + }); + + await phone.goto(appUrl); + await expect(phone.getByTestId("onboarding-intro")).toBeVisible(); + await phone.getByRole("button", { name: "Got it" }).click(); + await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); + await phone.reload(); + await expect( + phone.getByRole("button", { name: "How this works" }), + ).toBeVisible(); + await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); + + await peer.goto(appUrl); + for (const page of [phone, peer]) { + await page.getByLabel("Node").fill(RELAY_ADDRESS); + await page.getByRole("button", { name: "Connect" }).click(); + } + + const messageButton = phone.getByRole("button", { name: "Message" }); + await expect(messageButton).toBeVisible({ timeout: DIRECTORY_TIMEOUT_MS }); + expect(await fitsViewportWidth(phone)).toBe(true); + expect(await directoryCellDisplay(phone)).toBe("block"); + await expect(peer.getByRole("button", { name: "Message" })).toBeVisible({ + timeout: DIRECTORY_TIMEOUT_MS, + }); + expect(await directoryCellDisplay(peer)).toBe("table-cell"); + const box = await messageButton.boundingBox(); + expect(box).not.toBeNull(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual( + PHONE_VIEWPORT.width, + ); + } finally { + await Promise.all([phoneBrowser.close(), peerBrowser.close()]); + } +}); diff --git a/ts/packages/web-console/test/preferences-store.unit.test.ts b/ts/packages/web-console/test/preferences-store.unit.test.ts new file mode 100644 index 00000000..a9aa734d --- /dev/null +++ b/ts/packages/web-console/test/preferences-store.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createPreferencesStore } from "../src/preferences-store.js"; + +describe("preferences store", () => { + it("has not dismissed the intro on a fresh device", async () => { + expect( + await createPreferencesStore(createMemoryStorage()).introDismissed(), + ).toBe(false); + }); + + it("remembers a dismissal across store instances over the same storage, and forgets it when reset", async () => { + const storage = createMemoryStorage(); + await createPreferencesStore(storage).setIntroDismissed(true); + + expect(await createPreferencesStore(storage).introDismissed()).toBe(true); + + await createPreferencesStore(storage).setIntroDismissed(false); + + expect(await createPreferencesStore(storage).introDismissed()).toBe(false); + }); +}); diff --git a/ts/packages/web-console/test/search.unit.test.ts b/ts/packages/web-console/test/search.unit.test.ts new file mode 100644 index 00000000..41a0c176 --- /dev/null +++ b/ts/packages/web-console/test/search.unit.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import type { ConversationView } from "../src/conversations.js"; +import type { StoredMessage } from "../src/message-store.js"; +import { deviceIdFromFillHex } from "./hex.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; +import { + MAX_SEARCH_RESULTS, + queryTerms, + searchConversations, +} from "../src/search.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const OLDEST = 10; +const MIDDLE = 20; +const NEWEST = 30; +const THIRD_MESSAGE = 3; + +function message( + text: string, + sentAt: number, + direction: StoredMessage["direction"] = "received", +): StoredMessage { + return { direction, text, messageId: new Uint8Array([sentAt]), sentAt }; +} + +function conversation( + digit: string, + overrides: Partial = {}, +): ConversationView { + const peer = digit.repeat(DEVICE_ID_HEX_LENGTH); + return { + roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${peer}`, + participants: [peer], + status: "connected", + via: "direct", + messages: [], + notices: [], + outgoing: [], + pendingJoinRequest: undefined, + unread: 0, + ...overrides, + }; +} + +/** A verified notice entry whose claims are well-formed but carry filler for everything the search does not read. */ +function notice( + text: string, + postedAt: number, +): ConversationView["notices"][number] { + const content = new TextEncoder().encode(text); + return { + verified: true, + contentType: "text/plain", + plaintext: content, + claims: { + room: conversation("2").roomPath, + poster: deviceIdFromFillHex("33"), + "poster-key": syntheticAdvertProof()["identity-key"], + token: [new Uint8Array(), {}, null, new Uint8Array()], + "notice-id": new Uint8Array(), + "posted-at": postedAt, + "content-type": "text/plain", + content, + }, + }; +} + +describe("queryTerms", () => { + it("lowercases and splits on whitespace, dropping blanks", () => { + expect(queryTerms(" Hello WORLD ")).toEqual(["hello", "world"]); + expect(queryTerms(" ")).toEqual([]); + }); +}); + +describe("searchConversations", () => { + it("finds messages containing every term, in any case and order", () => { + const results = searchConversations( + [ + conversation("2", { + messages: [ + message("Lunch at the harbour", 1), + message("harbour closed", 2), + message("nothing here", THIRD_MESSAGE), + ], + }), + ], + "HARBOUR lunch", + ); + + expect(results.map((result) => result.text)).toEqual([ + "Lunch at the harbour", + ]); + }); + + it("searches messages and readable notices together, newest first, and says which is which", () => { + const first = conversation("2", { + messages: [message("deploy notes", OLDEST)], + notices: [notice("deploy window agreed", NEWEST)], + }); + const second = conversation("3", { + messages: [message("deploy done", MIDDLE)], + }); + + const results = searchConversations([first, second], "deploy"); + + expect( + results.map((result) => [result.kind, result.text, result.roomPath]), + ).toEqual([ + ["notice", "deploy window agreed", first.roomPath], + ["message", "deploy done", second.roomPath], + ["message", "deploy notes", first.roomPath], + ]); + }); + + it("reports whether a matching message was sent or received", () => { + const [sent] = searchConversations( + [conversation("2", { messages: [message("ping", 1, "sent")] })], + "ping", + ); + + expect(sent?.direction).toBe("sent"); + }); + + it("never matches a notice this console cannot decrypt, nor a blank query", () => { + const unreadable: ConversationView["notices"][number] = { verified: true }; + const conversations = [ + conversation("2", { + messages: [message("hello", 1)], + notices: [unreadable], + }), + ]; + + expect(searchConversations(conversations, "hello")).toHaveLength(1); + expect(searchConversations(conversations, " ")).toEqual([]); + }); + + it("gives each result a distinct key and caps how many it returns", () => { + const messages = Array.from( + { length: MAX_SEARCH_RESULTS + 1 }, + (_, index) => message("match", index), + ); + + const results = searchConversations( + [conversation("2", { messages })], + "match", + ); + + expect(results).toHaveLength(MAX_SEARCH_RESULTS); + expect(new Set(results.map((result) => result.key)).size).toBe( + MAX_SEARCH_RESULTS, + ); + }); +}); From 046bb13eed2fcd4c6cd936249425684ff5ac542c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:40:21 +0100 Subject: [PATCH 3/9] docs(web-console): describe naming, trust, activity, first run and search --- ts/packages/web-console/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ts/packages/web-console/README.md b/ts/packages/web-console/README.md index 7936bc03..f8a10233 100644 --- a/ts/packages/web-console/README.md +++ b/ts/packages/web-console/README.md @@ -8,8 +8,11 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c - **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes. - **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for. - **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark. -- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, snapshot time), latest advert per device winning. -- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button. +- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, last seen), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`). +- **Naming** — a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself, else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way. +- **Trust** — an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently. A connected node that announces certificates unlike the remembered ones is reported rather than overwritten silently. Each discovered-peer row names the connection whose directory gossiped it. +- **Activity and health** — the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when a peer was last seen. +- **First run and search** — a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to. ## How it maps onto core's ports From fea59c1d602b160ad1088ddc0c206793d509d840 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:33:06 +0100 Subject: [PATCH 4/9] docs(web-console): describe certificate pinning and naming as they behave, without dash separators --- ts/packages/web-console/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ts/packages/web-console/README.md b/ts/packages/web-console/README.md index f8a10233..2da17708 100644 --- a/ts/packages/web-console/README.md +++ b/ts/packages/web-console/README.md @@ -8,11 +8,11 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c - **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes. - **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for. - **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark. -- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, last seen), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`). -- **Naming** — a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself, else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way. -- **Trust** — an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently. A connected node that announces certificates unlike the remembered ones is reported rather than overwritten silently. Each discovered-peer row names the connection whose directory gossiped it. -- **Activity and health** — the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when a peer was last seen. -- **First run and search** — a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to. +- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, last seen), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`). +- **Naming**: a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself (always shown with the short id beside it, since a stranger chooses the name and two peers can claim the same one), else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way. +- **Trust**: an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently, dialled with only the presented certificates that are already remembered. A connected node that announces certificates unlike the remembered ones is reported and then pinned: the announcement arrived on a session this console had already pinned, so it is applied, with a dismissable notice. Each discovered-peer row names the connection whose directory gossiped it, and the device the gossip claimed for that connection's address is labelled as a claim, since nothing on the connection authenticates it. +- **Activity and health**: the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when a peer was last seen. +- **First run and search**: a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to. ## How it maps onto core's ports From 23eaa6f18a1e1aca573ebfc529f5a221818f68c9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:33:06 +0100 Subject: [PATCH 5/9] fix(web-console): report a first-run intro setting that cannot be read or saved --- ts/packages/web-console/src/App.tsx | 8 ++ .../web-console/src/hooks/use-intro.ts | 21 +++- .../web-console/test/use-intro.unit.test.tsx | 95 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 ts/packages/web-console/test/use-intro.unit.test.tsx diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index ac582921..6b29ac5b 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -381,6 +381,14 @@ export function App({ )} + {intro.error !== undefined && ( + + {intro.error} + + )} {intro.visible && } diff --git a/ts/packages/web-console/src/hooks/use-intro.ts b/ts/packages/web-console/src/hooks/use-intro.ts index 54546e62..4ec96755 100644 --- a/ts/packages/web-console/src/hooks/use-intro.ts +++ b/ts/packages/web-console/src/hooks/use-intro.ts @@ -8,21 +8,37 @@ export interface Intro { visible: boolean; /** True once the stored answer has loaded, so the control that reopens the intro is not offered before then. */ ready: boolean; + /** Why the stored answer could not be read or saved, for the person to see: the intro then follows this session's choice only, and reappears next load if the dismissal was not saved. */ + error: string | undefined; dismiss: () => void; show: () => void; } +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export function useIntro(store: Readonly): Intro { const [dismissed, setDismissed] = useState(undefined); + const [error, setError] = useState(undefined); useEffect(() => { - void store.introDismissed().then(setDismissed); + store.introDismissed().then(setDismissed, (reason: unknown) => { + setError(describe(reason)); + }); }, [store]); const remember = useCallback( (value: boolean): void => { setDismissed(value); - void store.setIntroDismissed(value); + store.setIntroDismissed(value).then( + () => { + setError(undefined); + }, + (reason: unknown) => { + setError(describe(reason)); + }, + ); }, [store], ); @@ -36,6 +52,7 @@ export function useIntro(store: Readonly): Intro { return { visible: dismissed === false, ready: dismissed !== undefined, + error, dismiss, show, }; diff --git a/ts/packages/web-console/test/use-intro.unit.test.tsx b/ts/packages/web-console/test/use-intro.unit.test.tsx new file mode 100644 index 00000000..525bbfbf --- /dev/null +++ b/ts/packages/web-console/test/use-intro.unit.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { useIntro } from "../src/hooks/use-intro.js"; +import { + createPreferencesStore, + type PreferencesStore, +} from "../src/preferences-store.js"; + +const STORAGE_FAILURE = "the origin's storage quota is exhausted"; + +function store( + overrides: Readonly> = {}, +): PreferencesStore { + return { ...createPreferencesStore(createMemoryStorage()), ...overrides }; +} + +describe("useIntro", () => { + afterEach(() => { + cleanup(); + }); + + it("opens on a device that has never dismissed it, with nothing to report", async () => { + const { result } = renderHook(() => useIntro(store())); + + await waitFor(() => { + expect(result.current.visible).toBe(true); + }); + expect(result.current.error).toBeUndefined(); + }); + + it("reports why the stored answer could not be read, and stays closed and not ready", async () => { + const failing = store({ + introDismissed: async () => Promise.reject(new Error(STORAGE_FAILURE)), + }); + const { result } = renderHook(() => useIntro(failing)); + + await waitFor(() => { + expect(result.current.error).toBe(STORAGE_FAILURE); + }); + expect(result.current.visible).toBe(false); + expect(result.current.ready).toBe(false); + }); + + it("reports a dismissal that could not be saved while still closing the intro for this session", async () => { + const failing = store({ + setIntroDismissed: async () => Promise.reject(new Error(STORAGE_FAILURE)), + }); + const { result } = renderHook(() => useIntro(failing)); + await waitFor(() => { + expect(result.current.visible).toBe(true); + }); + + act(() => { + result.current.dismiss(); + }); + + await waitFor(() => { + expect(result.current.error).toBe(STORAGE_FAILURE); + }); + expect(result.current.visible).toBe(false); + }); + + it("clears the report once a later save succeeds", async () => { + let failing = true; + const flaky = store({ + setIntroDismissed: async () => + failing + ? Promise.reject(new Error(STORAGE_FAILURE)) + : Promise.resolve(), + }); + const { result } = renderHook(() => useIntro(flaky)); + await waitFor(() => { + expect(result.current.ready).toBe(true); + }); + act(() => { + result.current.dismiss(); + }); + await waitFor(() => { + expect(result.current.error).toBe(STORAGE_FAILURE); + }); + + failing = false; + act(() => { + result.current.show(); + }); + + await waitFor(() => { + expect(result.current.error).toBeUndefined(); + }); + expect(result.current.visible).toBe(true); + }); +}); From 184a22df261e3e45727bac3b5575816254ee6e42 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:33:07 +0100 Subject: [PATCH 6/9] test(web-console): assert the message request names the requester with its short id --- .../web-console/test/RoomPanel.unit.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/ts/packages/web-console/test/RoomPanel.unit.test.tsx b/ts/packages/web-console/test/RoomPanel.unit.test.tsx index 8addfb12..4c1bbe95 100644 --- a/ts/packages/web-console/test/RoomPanel.unit.test.tsx +++ b/ts/packages/web-console/test/RoomPanel.unit.test.tsx @@ -9,10 +9,15 @@ import { screen, waitFor, } from "@testing-library/react"; +import { useEffect } from "react"; import { RoomPanel } from "../src/components/RoomPanel.js"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; +import { selfNameExtension, shortId } from "../src/peer-names.js"; import type { ConversationView } from "../src/conversations.js"; import { WithNames, memoryNameStore } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; +import { deviceIdFromFillHex } from "./hex.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -202,4 +207,50 @@ describe("RoomPanel", () => { ); expect(screen.queryByText(peer)).toBeNull(); }); + + it("shows the requester's short id beside a name it chose for itself, so a stranger cannot pass as someone else", async () => { + const requester = "ab".repeat(DEVICE_ID_HEX_LENGTH / 2); + + /** Feeds the naming context a directory in which the requester asserts the name "Support", the way a connection panel does. */ + function AssertsName(): null { + const { observeDirectory } = usePeerNames(); + useEffect(() => { + observeDirectory([ + { + device: deviceIdFromFillHex("ab"), + advert: { + device: deviceIdFromFillHex("ab"), + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...selfNameExtension("Support"), + }, + }, + ]); + }, [observeDirectory]); + return null; + } + + render( + + + Promise.resolve()} + onPostNotice={async () => Promise.resolve()} + onRetry={() => undefined} + onDiscard={() => undefined} + /> + , + ); + + await waitFor(() => { + expect(screen.getByText(/wants to message you/)).toHaveTextContent( + `Support (${shortId(requester)}) wants to message you`, + ); + }); + }); }); From 217c0fce93dec06ed88fda922d42583c4c415239 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:33:07 +0100 Subject: [PATCH 7/9] fix(web-console): keep table roles on the stacked phone layout --- ts/packages/web-console/src/App.css.ts | 2 +- .../src/components/ConnectionPanel.tsx | 90 ++++++-------- .../src/components/DiscoveredPeersPanel.tsx | 113 +++++++++--------- .../src/components/StackedTable.tsx | 56 +++++++++ .../test/StackedTable.unit.test.tsx | 65 ++++++++++ 5 files changed, 215 insertions(+), 111 deletions(-) create mode 100644 ts/packages/web-console/src/components/StackedTable.tsx create mode 100644 ts/packages/web-console/test/StackedTable.unit.test.tsx diff --git a/ts/packages/web-console/src/App.css.ts b/ts/packages/web-console/src/App.css.ts index f959dfe7..94646cf9 100644 --- a/ts/packages/web-console/src/App.css.ts +++ b/ts/packages/web-console/src/App.css.ts @@ -12,7 +12,7 @@ export const appShell = style({ /** Mantine's `xs` breakpoint (the same 36em vite.config.ts hands the PostCSS pipeline), below which a peer table no longer fits a row of columns. */ const PHONE_MEDIA = "screen and (max-width: 36em)"; -/** A table whose rows become cards on a phone: the header row is hidden and each cell shows its column's name (its `data-label`) above its value. */ +/** A table whose rows become cards on a phone: the header row is hidden and each cell shows its column's name (its `data-label`) above its value. Use it through `StackedTable`, which also states each element's ARIA role, because changing `display` on table elements makes Safari drop their table semantics. */ export const stackedTable = style({}); globalStyle(`${stackedTable} thead`, { diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index b8ac36b6..8e75e816 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,14 +1,6 @@ // One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session. -import { - Alert, - Button, - Card, - Group, - Table, - Text, - Tooltip, -} from "@mantine/core"; +import { Alert, Button, Card, Group, Text, Tooltip } from "@mantine/core"; import type { MeshSession, ReconnectPolicy, @@ -27,10 +19,11 @@ import { formatAgo } from "../format-duration.js"; import { presentedCertificates } from "../certificate-trust.js"; import { toDialAddress } from "../dial-address.js"; import type { NodeCertificateChange } from "../hooks/use-certificate-trust.js"; -import { stackedTable } from "../App.css.js"; import { ActivityLog } from "./ActivityLog.js"; import { ConnectionHealth } from "./ConnectionHealth.js"; import { PeerLabel } from "./PeerLabel.js"; +import { StackedTable } from "./StackedTable.js"; +import type { StackedColumn } from "./StackedTable.js"; const HEX_RADIX = 16; const MS_PER_SECOND = 1000; @@ -92,6 +85,13 @@ export interface ConnectionPanelProps { /** How often elapsed times and the reconnect countdown refresh. */ const CLOCK_TICK_MS = 1000; +const DIRECTORY_COLUMNS: readonly StackedColumn[] = [ + { label: "device" }, + { label: "addresses" }, + { label: "last seen" }, + { label: "actions", headerless: true }, +]; + export function ConnectionPanel({ address, session, @@ -198,47 +198,35 @@ export function ConnectionPanel({ No gossip received yet. ) : ( -
- - - device - addresses - last seen - - - - - {directory.map((entry) => ( - - - - - - {entry.advert.addresses.join(", ")} - - - - - {formatAgo( - now - entry.advert["snapshot-seconds"] * MS_PER_SECOND, - )} - - - - - - - - ))} - -
+ ({ + key: deviceHex(entry.device), + cells: [ + , + entry.advert.addresses.join(", "), + + + {formatAgo( + now - entry.advert["snapshot-seconds"] * MS_PER_SECOND, + )} + + , + , + ], + }))} + /> )} diff --git a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx index 8cbc8980..91bf68d2 100644 --- a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx +++ b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx @@ -1,10 +1,12 @@ // A confirmation gate for gossip-driven peer discovery (wire-mesh#187): each row is one device this console's own gossip-expansion has surfaced from a connected node's directory but not yet dialled. Dialling a gossiped address unconditionally would be the same class of risk as a webpage probing a user's internal network (Private Network Access attacks) -- a lying peer in the directory could name an address that isn't a wire-mesh node at all -- so this console never auto-connects to one; every row waits here until this console's own user explicitly connects or dismisses it. -import { Button, Card, Group, Table, Text } from "@mantine/core"; +import { Fragment } from "react"; +import { Button, Card, Group, Text } from "@mantine/core"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceHex } from "./ConnectionPanel.js"; -import { stackedTable } from "../App.css.js"; import { PeerLabel } from "./PeerLabel.js"; +import { StackedTable } from "./StackedTable.js"; +import type { StackedColumn } from "./StackedTable.js"; /** Where a discovered peer was learned of: the connection whose directory gossiped it, and, when that connection was itself reached by gossip, the device the gossip claimed lives at its address. */ export interface DiscoveredVia { @@ -21,6 +23,13 @@ export interface DiscoveredPeerRow { via: DiscoveredVia; } +const COLUMNS: readonly StackedColumn[] = [ + { label: "device" }, + { label: "addresses" }, + { label: "gossiped by" }, + { label: "actions", headerless: true }, +]; + export interface DiscoveredPeersPanelProps { peers: readonly DiscoveredPeerRow[]; onConnect: (key: string) => void; @@ -45,63 +54,49 @@ export function DiscoveredPeersPanel({ to, which is the only thing vouching for it: the address it lists may not be a wire-mesh node at all. Nothing is dialled until you connect. - - - - device - addresses - gossiped by - - - - - {peers.map((peer) => ( - - - - - - {peer.addresses.join(", ")} - - - {peer.via.claimedDevice !== undefined && ( - <> - - claimed to be - - - - )} - - over {peer.via.address} - - - - - - - - - - ))} - -
+ ({ + key: peer.key, + cells: [ + , + peer.addresses.join(", "), + + {peer.via.claimedDevice !== undefined && ( + <> + + claimed to be + + + + )} + + over {peer.via.address} + + , + + + + , + ], + }))} + /> ); } diff --git a/ts/packages/web-console/src/components/StackedTable.tsx b/ts/packages/web-console/src/components/StackedTable.tsx new file mode 100644 index 00000000..1961569a --- /dev/null +++ b/ts/packages/web-console/src/components/StackedTable.tsx @@ -0,0 +1,56 @@ +// A table whose rows become labelled cards on a phone (see `stackedTable` in App.css.ts). Switching `display` on table elements makes Safari and VoiceOver drop their table semantics, so every element carries its ARIA role explicitly, which keeps the table a table to assistive technology in both layouts. + +import { Table } from "@mantine/core"; +import { stackedTable } from "../App.css.js"; + +export interface StackedColumn { + /** The column's name: its header text, and what each of its cells shows above its value on a phone. */ + label: string; + /** True for a column with no header text of its own, such as a row's action buttons. */ + headerless?: boolean; +} + +export interface StackedRow { + key: string; + /** One node per column, in column order. */ + cells: readonly React.ReactNode[]; +} + +export interface StackedTableProps { + columns: readonly StackedColumn[]; + rows: readonly StackedRow[]; +} + +export function StackedTable({ + columns, + rows, +}: Readonly): React.JSX.Element { + return ( + + + + {columns.map((column) => ( + + {column.headerless === true ? null : column.label} + + ))} + + + + {rows.map((row) => ( + + {columns.map((column, index) => ( + + {row.cells[index]} + + ))} + + ))} + +
+ ); +} diff --git a/ts/packages/web-console/test/StackedTable.unit.test.tsx b/ts/packages/web-console/test/StackedTable.unit.test.tsx new file mode 100644 index 00000000..112efd10 --- /dev/null +++ b/ts/packages/web-console/test/StackedTable.unit.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { StackedTable } from "../src/components/StackedTable.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +function renderTable(): HTMLElement { + const { container } = render( + + Open] }, + { key: "b", cells: ["Grace", ] }, + ]} + /> + , + ); + return container; +} + +function rolesOf(container: HTMLElement, selector: string): (string | null)[] { + return [...container.querySelectorAll(selector)].map((element) => + element.getAttribute("role"), + ); +} + +describe("StackedTable", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("states every table role explicitly, since the phone layout changes display and Safari then drops the native ones", () => { + const container = renderTable(); + + expect(rolesOf(container, "thead, tbody")).toEqual([ + "rowgroup", + "rowgroup", + ]); + expect(rolesOf(container, "tr")).toEqual(["row", "row", "row"]); + expect(rolesOf(container, "th")).toEqual(["columnheader", "columnheader"]); + expect(rolesOf(container, "td")).toEqual(["cell", "cell", "cell", "cell"]); + }); + + it("labels each cell with its column, and gives a headerless column no header text", () => { + const container = renderTable(); + + expect( + [...container.querySelectorAll("tbody td")].map((cell) => + cell.getAttribute("data-label"), + ), + ).toEqual(["name", "actions", "name", "actions"]); + expect( + [...container.querySelectorAll("th")].map((cell) => cell.textContent), + ).toEqual(["name", ""]); + }); +}); From bd70f6ef4df7efeea0ad0cbaac6a01ffd5b48ab0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:38:04 +0100 Subject: [PATCH 8/9] fix(web-console): write a self-asserted name with its short id in the conversation header and search results --- .../src/components/ConversationList.tsx | 5 +-- .../web-console/src/components/RoomPanel.tsx | 9 ++--- .../src/components/SearchPanel.tsx | 5 +-- ts/packages/web-console/src/conversations.ts | 10 +++-- .../web-console/test/PeerLabel.unit.test.tsx | 40 ++++--------------- .../web-console/test/RoomPanel.unit.test.tsx | 37 +++++------------ .../test/SearchPanel.unit.test.tsx | 25 +++++++++++- .../test/conversations.unit.test.ts | 23 +++++++++-- .../web-console/test/names-harness.tsx | 29 ++++++++++++++ 9 files changed, 103 insertions(+), 80 deletions(-) diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index 413c1c38..cbb6e3a9 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -3,7 +3,6 @@ import { Badge, Group, NavLink, Stack, Text } from "@mantine/core"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; -import { labelText } from "../peer-names.js"; export interface ConversationListProps { conversations: readonly ConversationView[]; @@ -29,9 +28,7 @@ export function ConversationList({ - labelText(names.labelOf(hex)), - )} + label={participantLabel(conversation, names)} description={ conversation.status === "connected" ? undefined : "offline" } diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index 65451220..7a98b86f 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -4,7 +4,6 @@ import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; import { SubmitRow } from "web-ui-primitives"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; -import { labelText } from "../peer-names.js"; import { PeerName } from "./PeerName.js"; import { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -43,8 +42,8 @@ export function RoomPanel({ return ( - - {participantLabel(view, (hex) => names.labelOf(hex).primary)} + + {participantLabel(view, names)} {routeLabel(view.via)} @@ -89,9 +88,7 @@ export function RoomPanel({ - labelText(names.labelOf(hex)), - )} + peerLabel={participantLabel(view, names)} onRetry={onRetry} onDiscard={onDiscard} /> diff --git a/ts/packages/web-console/src/components/SearchPanel.tsx b/ts/packages/web-console/src/components/SearchPanel.tsx index c1656ea6..b2389739 100644 --- a/ts/packages/web-console/src/components/SearchPanel.tsx +++ b/ts/packages/web-console/src/components/SearchPanel.tsx @@ -67,10 +67,7 @@ export function SearchPanel({ {conversation === undefined ? result.roomPath - : participantLabel( - conversation, - (hex) => names.labelOf(hex).primary, - )} + : participantLabel(conversation, names)} {" · "} {new Date(result.at).toLocaleString()} diff --git a/ts/packages/web-console/src/conversations.ts b/ts/packages/web-console/src/conversations.ts index 15bf1a6e..e8fcce01 100644 --- a/ts/packages/web-console/src/conversations.ts +++ b/ts/packages/web-console/src/conversations.ts @@ -10,6 +10,8 @@ import { bytesToHex } from "wire-mesh-core/domain/device-id"; import { parseRoomPath } from "wire-mesh-core/domain/room-path"; import type { RoomJoinDecision } from "./room-client.js"; import type { StoredMessage } from "./message-store.js"; +import { labelText } from "./peer-names.js"; +import type { PeerLabel } from "./peer-names.js"; export interface PendingJoinRequest { requesterHex: string; @@ -277,11 +279,13 @@ export function participantsOf( return parsed.owner === ownDeviceHex ? [] : [parsed.owner]; } -/** How a conversation's other members are named in the UI: each by `nameOf`, which applies the console's naming convention to a device-id. A conversation with no other members is named by its room path. */ +/** How a conversation's other members are named in the UI: each by the console's naming convention as one line of text, so a self-asserted name always carries the short id beside it. A conversation with no other members is named by its room path. */ export function participantLabel( view: Readonly>, - nameOf: (deviceHex: string) => string, + names: Readonly<{ labelOf: (deviceHex: string) => PeerLabel }>, ): string { if (view.participants.length === 0) return view.roomPath; - return view.participants.map(nameOf).join(", "); + return view.participants + .map((hex) => labelText(names.labelOf(hex))) + .join(", "); } diff --git a/ts/packages/web-console/test/PeerLabel.unit.test.tsx b/ts/packages/web-console/test/PeerLabel.unit.test.tsx index e3225b94..34332d50 100644 --- a/ts/packages/web-console/test/PeerLabel.unit.test.tsx +++ b/ts/packages/web-console/test/PeerLabel.unit.test.tsx @@ -2,44 +2,20 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useEffect } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { PeerLabel } from "../src/components/PeerLabel.js"; -import { usePeerNames } from "../src/hooks/use-peer-names.js"; -import { selfNameExtension, shortId } from "../src/peer-names.js"; -import { syntheticAdvertProof } from "./synthetic-advert.js"; -import { WithNames, memoryNameStore } from "./names-harness.js"; +import { shortId } from "../src/peer-names.js"; +import { + AssertsSelfName, + WithNames, + memoryNameStore, +} from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; const DEVICE_ID_BYTES = 32; const DEVICE_HEX = "cd".repeat(DEVICE_ID_BYTES); const OTHER_HEX = "ef".repeat(DEVICE_ID_BYTES); -/** Feeds the naming context a directory in which `deviceHex` asserts `selfName` about itself, as a connected node's directory would. */ -function SelfClaim({ - deviceHex, - selfName, -}: Readonly<{ deviceHex: string; selfName: string }>): null { - const { observeDirectory } = usePeerNames(); - useEffect(() => { - const device = deviceIdFromHex(deviceHex); - observeDirectory([ - { - device, - advert: { - device, - addresses: [], - "snapshot-seconds": 0, - ...syntheticAdvertProof(), - ...selfNameExtension(selfName), - }, - }, - ]); - }, [deviceHex, selfName, observeDirectory]); - return null; -} - describe("PeerLabel", () => { beforeEach(() => { stubMantineJsdomGlobals(); @@ -129,7 +105,7 @@ describe("PeerLabel", () => { it("marks a self-asserted name as the peer's own claim, apart from a petname", async () => { render( - + , ); @@ -158,7 +134,7 @@ describe("PeerLabel", () => { await store.setPetname(OTHER_HEX, "Alice"); render( - + , ); diff --git a/ts/packages/web-console/test/RoomPanel.unit.test.tsx b/ts/packages/web-console/test/RoomPanel.unit.test.tsx index 4c1bbe95..5be3e314 100644 --- a/ts/packages/web-console/test/RoomPanel.unit.test.tsx +++ b/ts/packages/web-console/test/RoomPanel.unit.test.tsx @@ -9,15 +9,15 @@ import { screen, waitFor, } from "@testing-library/react"; -import { useEffect } from "react"; import { RoomPanel } from "../src/components/RoomPanel.js"; -import { usePeerNames } from "../src/hooks/use-peer-names.js"; -import { selfNameExtension, shortId } from "../src/peer-names.js"; +import { shortId } from "../src/peer-names.js"; import type { ConversationView } from "../src/conversations.js"; -import { WithNames, memoryNameStore } from "./names-harness.js"; +import { + AssertsSelfName, + WithNames, + memoryNameStore, +} from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; -import { deviceIdFromFillHex } from "./hex.js"; -import { syntheticAdvertProof } from "./synthetic-advert.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -211,29 +211,9 @@ describe("RoomPanel", () => { it("shows the requester's short id beside a name it chose for itself, so a stranger cannot pass as someone else", async () => { const requester = "ab".repeat(DEVICE_ID_HEX_LENGTH / 2); - /** Feeds the naming context a directory in which the requester asserts the name "Support", the way a connection panel does. */ - function AssertsName(): null { - const { observeDirectory } = usePeerNames(); - useEffect(() => { - observeDirectory([ - { - device: deviceIdFromFillHex("ab"), - advert: { - device: deviceIdFromFillHex("ab"), - addresses: [], - "snapshot-seconds": 0, - ...syntheticAdvertProof(), - ...selfNameExtension("Support"), - }, - }, - ]); - }, [observeDirectory]); - return null; - } - render( - + { `Support (${shortId(requester)}) wants to message you`, ); }); + expect(screen.getByTestId("conversation-header")).toHaveTextContent( + `Support (${shortId(requester)})`, + ); }); }); diff --git a/ts/packages/web-console/test/SearchPanel.unit.test.tsx b/ts/packages/web-console/test/SearchPanel.unit.test.tsx index fd713def..5dfc2cc9 100644 --- a/ts/packages/web-console/test/SearchPanel.unit.test.tsx +++ b/ts/packages/web-console/test/SearchPanel.unit.test.tsx @@ -5,7 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { SearchPanel } from "../src/components/SearchPanel.js"; import type { ConversationView } from "../src/conversations.js"; -import { WithNames, memoryNameStore } from "./names-harness.js"; +import { shortId } from "../src/peer-names.js"; +import { + AssertsSelfName, + WithNames, + memoryNameStore, +} from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -91,6 +96,24 @@ describe("SearchPanel", () => { expect(onSelect).toHaveBeenCalledWith(view.roomPath); }); + it("shows the short id beside a name the peer chose for itself, so a stranger cannot pass as someone else", async () => { + render( + + + undefined} + /> + , + ); + + search("harbour"); + + expect( + await screen.findByText(`Support (${shortId(PEER)})`, { exact: false }), + ).toBeInTheDocument(); + }); + it("says when nothing matches", () => { renderSearch([conversation("lunch at the harbour")]); diff --git a/ts/packages/web-console/test/conversations.unit.test.ts b/ts/packages/web-console/test/conversations.unit.test.ts index c7d2b59c..bc9d2a3a 100644 --- a/ts/packages/web-console/test/conversations.unit.test.ts +++ b/ts/packages/web-console/test/conversations.unit.test.ts @@ -18,7 +18,8 @@ import { type RelayRoute, } from "../src/conversations.js"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; -import { shortId } from "../src/peer-names.js"; +import { labelPeer } from "../src/peer-names.js"; +import type { PeerLabel } from "../src/peer-names.js"; import type { StoredMessage } from "../src/message-store.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -431,17 +432,33 @@ describe("participantsOf", () => { }); describe("participantLabel", () => { + const names = { + labelOf: (hex: string): PeerLabel => + labelPeer(hex, undefined, undefined, new Set()), + }; + it("shortens each participant's device-id and joins them", () => { expect( participantLabel( { roomPath: DM, participants: [PEER, OTHER_PEER] }, - shortId, + names, ), ).toBe("222222222222, 333333333333"); }); + it("writes a self-asserted name with the short id beside it", () => { + const claiming = { + labelOf: (hex: string): PeerLabel => + labelPeer(hex, undefined, "Support", new Set()), + }; + + expect( + participantLabel({ roomPath: DM, participants: [PEER] }, claiming), + ).toBe("Support (222222222222)"); + }); + it("falls back to the room path when nobody else is in the room", () => { - expect(participantLabel({ roomPath: DM, participants: [] }, shortId)).toBe( + expect(participantLabel({ roomPath: DM, participants: [] }, names)).toBe( DM, ); }); diff --git a/ts/packages/web-console/test/names-harness.tsx b/ts/packages/web-console/test/names-harness.tsx index cc5c046f..c60664a6 100644 --- a/ts/packages/web-console/test/names-harness.tsx +++ b/ts/packages/web-console/test/names-harness.tsx @@ -1,9 +1,14 @@ // Wraps a component in the naming context over an in-memory name store, the way App does for the real tree. +import { useEffect } from "react"; import { MantineProvider } from "@mantine/core"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { PeerNamesProvider } from "../src/components/PeerNamesProvider.js"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; import { createNameStore } from "../src/name-store.js"; +import { selfNameExtension } from "../src/peer-names.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; import type { NameStore } from "../src/name-store.js"; export function memoryNameStore(): NameStore { @@ -23,3 +28,27 @@ export function WithNames({ ); } + +/** Feeds the naming context a directory in which `deviceHex` asserts `selfName` about itself, the way a connection panel does with a connected node's directory. */ +export function AssertsSelfName({ + deviceHex, + selfName, +}: Readonly<{ deviceHex: string; selfName: string }>): null { + const { observeDirectory } = usePeerNames(); + useEffect(() => { + const device = deviceIdFromHex(deviceHex); + observeDirectory([ + { + device, + advert: { + device, + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...selfNameExtension(selfName), + }, + }, + ]); + }, [deviceHex, selfName, observeDirectory]); + return null; +} From 5211158ae1530b3c31ae9dd1d6750c82c0c8754a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:38:05 +0100 Subject: [PATCH 9/9] fix(web-console): show the first-run intro when its stored answer cannot be read --- ts/packages/web-console/src/hooks/use-intro.ts | 8 +++++--- ts/packages/web-console/test/use-intro.unit.test.tsx | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ts/packages/web-console/src/hooks/use-intro.ts b/ts/packages/web-console/src/hooks/use-intro.ts index 4ec96755..136ab4c9 100644 --- a/ts/packages/web-console/src/hooks/use-intro.ts +++ b/ts/packages/web-console/src/hooks/use-intro.ts @@ -1,12 +1,12 @@ -// Whether the first-run intro is on screen. It opens on a device that has never dismissed it, stays closed until the stored answer has loaded so a returning user never sees it flash, and can be reopened. +// Whether the first-run intro is on screen. It opens on a device that has never dismissed it, stays closed until the stored answer has loaded so a returning user never sees it flash (an answer that cannot be read counts as not dismissed), and can be reopened. import { useCallback, useEffect, useState } from "react"; import type { PreferencesStore } from "../preferences-store.js"; export interface Intro { - /** True once the stored answer has loaded and the intro is wanted. */ + /** True once the stored answer has loaded, or failed to load, and the intro is wanted. */ visible: boolean; - /** True once the stored answer has loaded, so the control that reopens the intro is not offered before then. */ + /** True once the stored answer has loaded or failed to, so the control that reopens the intro is not offered before then. */ ready: boolean; /** Why the stored answer could not be read or saved, for the person to see: the intro then follows this session's choice only, and reappears next load if the dismissal was not saved. */ error: string | undefined; @@ -24,6 +24,8 @@ export function useIntro(store: Readonly): Intro { useEffect(() => { store.introDismissed().then(setDismissed, (reason: unknown) => { + // An answer that cannot be read is no dismissal, so the intro is shown and can be dismissed for this session. + setDismissed(false); setError(describe(reason)); }); }, [store]); diff --git a/ts/packages/web-console/test/use-intro.unit.test.tsx b/ts/packages/web-console/test/use-intro.unit.test.tsx index 525bbfbf..575be04b 100644 --- a/ts/packages/web-console/test/use-intro.unit.test.tsx +++ b/ts/packages/web-console/test/use-intro.unit.test.tsx @@ -31,7 +31,7 @@ describe("useIntro", () => { expect(result.current.error).toBeUndefined(); }); - it("reports why the stored answer could not be read, and stays closed and not ready", async () => { + it("reports why the stored answer could not be read, and shows the intro as it would on a first run", async () => { const failing = store({ introDismissed: async () => Promise.reject(new Error(STORAGE_FAILURE)), }); @@ -40,8 +40,8 @@ describe("useIntro", () => { await waitFor(() => { expect(result.current.error).toBe(STORAGE_FAILURE); }); - expect(result.current.visible).toBe(false); - expect(result.current.ready).toBe(false); + expect(result.current.visible).toBe(true); + expect(result.current.ready).toBe(true); }); it("reports a dismissal that could not be saved while still closing the intro for this session", async () => {