From b23f772917feb7ca5d0b5ee383f4e7ef8d054bc7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:06:41 +0100 Subject: [PATCH 1/7] feat(web-console): name peers by petname, self display name or short id A petname is this viewer's own label for a device, kept in the console's own storage and never gossiped. A self display name is read from the agent/self entry of a verified advert, the key agent-comms and the public hub's advert policy already carry, and this console publishes its own through the same entry. The convention is implemented once in peer-names.ts and reached through a context by the conversation list, the connection directory and the discovered-peers table. --- ts/packages/web-console/src/App.tsx | 180 ++++++++++-------- .../src/components/ConnectionPanel.tsx | 23 ++- .../src/components/ConversationList.tsx | 7 +- .../src/components/DiscoveredPeersPanel.tsx | 5 +- .../web-console/src/components/PeerLabel.tsx | 114 +++++++++++ .../web-console/src/components/PeerName.tsx | 13 ++ .../src/components/PeerNamesProvider.tsx | 85 +++++++++ .../web-console/src/components/RoomPanel.tsx | 4 +- .../src/components/SelfNameField.tsx | 43 +++++ ts/packages/web-console/src/conversations.ts | 9 +- .../web-console/src/hooks/use-peer-names.ts | 27 +++ ts/packages/web-console/src/main.tsx | 3 + ts/packages/web-console/src/name-store.ts | 54 ++++++ ts/packages/web-console/src/peer-names.ts | 71 +++++++ .../web-console/test/App.unit.test.tsx | 123 +++++++++++- .../test/ConversationList.unit.test.tsx | 32 +++- .../web-console/test/PeerLabel.unit.test.tsx | 99 ++++++++++ .../web-console/test/RoomPanel.unit.test.tsx | 6 +- .../test/conversations.unit.test.ts | 10 +- .../web-console/test/name-store.unit.test.ts | 48 +++++ .../web-console/test/names-harness.tsx | 25 +++ .../web-console/test/peer-names.unit.test.ts | 111 +++++++++++ 22 files changed, 985 insertions(+), 107 deletions(-) create mode 100644 ts/packages/web-console/src/components/PeerLabel.tsx create mode 100644 ts/packages/web-console/src/components/PeerName.tsx create mode 100644 ts/packages/web-console/src/components/PeerNamesProvider.tsx create mode 100644 ts/packages/web-console/src/components/SelfNameField.tsx create mode 100644 ts/packages/web-console/src/hooks/use-peer-names.ts create mode 100644 ts/packages/web-console/src/name-store.ts create mode 100644 ts/packages/web-console/src/peer-names.ts create mode 100644 ts/packages/web-console/test/PeerLabel.unit.test.tsx create mode 100644 ts/packages/web-console/test/name-store.unit.test.ts create mode 100644 ts/packages/web-console/test/names-harness.tsx create mode 100644 ts/packages/web-console/test/peer-names.unit.test.ts diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 17156af3..5ade11f7 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -35,6 +35,10 @@ import type { WebrtcNegotiator } from "./webrtc-negotiation.js"; import { ConnectionPanel, deviceHex } from "./components/ConnectionPanel.js"; import { DiscoveredPeersPanel } from "./components/DiscoveredPeersPanel.js"; import type { DiscoveredPeerRow } from "./components/DiscoveredPeersPanel.js"; +import { PeerNamesProvider } from "./components/PeerNamesProvider.js"; +import { PeerName } from "./components/PeerName.js"; +import { SelfNameField } from "./components/SelfNameField.js"; +import type { NameStore } from "./name-store.js"; import { ConversationList } from "./components/ConversationList.js"; import { RoomPanel } from "./components/RoomPanel.js"; import { useRoomMessaging } from "./hooks/use-room-messaging.js"; @@ -54,6 +58,8 @@ export interface AppProps { roomStorage: KeyValueStorage; /** What each node last announced about the certificates it serves, so a node's address keeps working as its certificates change. */ certificateMemory: CertificateMemory; + /** The petnames this viewer has given peers and this console's own display name, kept in the console's own storage. */ + nameStore: NameStore; /** Attempts same-device node auto-discovery once, on mount. Defaults to the real `discoverLocalNode` (a no-op when this console is served from a loopback origin, a real localhost probe otherwise); tests inject a fake to avoid depending on `location`/`fetch`. */ discoverLocalNode?: () => Promise; // Seeds the Node field. Left as a prop (rather than App reading location/import.meta.env itself) so App stays the plain, testable component its own header comment describes; main.tsx computes the real value via default-hub-address.ts. @@ -105,6 +111,7 @@ export function App({ messageStore, roomStorage, certificateMemory, + nameStore, discoverLocalNode = discoverLocalNodeDefault, defaultAddress = DEFAULT_ADDRESS, }: Readonly): React.JSX.Element { @@ -319,93 +326,100 @@ export function App({ } return ( - - wire-mesh console -
- - { - setAddress(event.currentTarget.value); - }} - required - style={{ flex: 1, minWidth: "16rem" }} - /> - - - - - {AVAILABLE_DOMAINS.map((domain) => ( - - ))} + + + wire-mesh console + + + + { + setAddress(event.currentTarget.value); + }} + required + style={{ flex: 1, minWidth: "16rem" }} + /> + - -
- { - resolveDiscovered(key, true); - }} - onDismiss={(key) => { - resolveDiscovered(key, false); - }} - /> - {connections.map((entry) => ( - { - handleClose(entry); + + + {AVAILABLE_DOMAINS.map((domain) => ( + + ))} + + + + { + resolveDiscovered(key, true); }} - onMessagePeer={(device) => { - handleMessagePeer(entry, device); + onDismiss={(key) => { + resolveDiscovered(key, false); }} /> - ))} - {failures.map((failure) => ( - { - setFailures((current) => - current.filter((entry) => entry.key !== failure.key), - ); - }} - > - {failure.reason}. Messages go through the hub instead. - - ))} - - {selectedConversation !== undefined && ( - - roomMessaging.send(selectedConversation.roomPath, text) - } - onPostNotice={async (text) => - roomMessaging.postNotice(selectedConversation.roomPath, text) - } - onRetry={(localId) => { - void roomMessaging.retry(selectedConversation.roomPath, localId); - }} - onDiscard={(localId) => { - roomMessaging.discard(selectedConversation.roomPath, localId); - }} + {connections.map((entry) => ( + { + handleClose(entry); + }} + onMessagePeer={(device) => { + handleMessagePeer(entry, device); + }} + /> + ))} + {failures.map((failure) => ( + + No direct connection to + + } + withCloseButton + onClose={() => { + setFailures((current) => + current.filter((entry) => entry.key !== failure.key), + ); + }} + > + {failure.reason}. Messages go through the hub instead. + + ))} + - )} -
+ {selectedConversation !== undefined && ( + + roomMessaging.send(selectedConversation.roomPath, text) + } + onPostNotice={async (text) => + roomMessaging.postNotice(selectedConversation.roomPath, text) + } + onRetry={(localId) => { + void roomMessaging.retry(selectedConversation.roomPath, localId); + }} + onDiscard={(localId) => { + roomMessaging.discard(selectedConversation.roomPath, localId); + }} + /> + )} + + ); } diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index f7eb83ad..8c0e6d4f 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -7,12 +7,16 @@ import type { SessionEvent, } from "wire-mesh-core/domain/mesh-session"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; +import { useEffect } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { browserPermissions, explainLocalNetworkBlock, type PermissionQuerier, } from "../local-network.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { selfNameExtension } from "../peer-names.js"; +import { PeerLabel } from "./PeerLabel.js"; const HEX_RADIX = 16; @@ -108,6 +112,21 @@ export function ConnectionPanel({ }, [failing, address, pageHost, permissions]); const blocked = failing && explained?.address === address ? explained.message : undefined; + const { observeDirectory, selfName } = usePeerNames(); + const connected = event?.state.status === "connected"; + useEffect(() => { + if (event !== undefined) { + observeDirectory(event.directory); + } + }, [event, observeDirectory]); + // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. + useEffect(() => { + if (connected && selfName !== undefined) { + session.sendGossipUpdate(selfNameExtension(selfName)).catch(() => { + // The link dropped between the status read and the send; this effect runs again when the session reconnects, which republishes the name. + }); + } + }, [connected, selfName, session]); const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; const frameLog = event?.frameLog ?? []; @@ -160,7 +179,9 @@ export function ConnectionPanel({ {directory.map((entry) => ( - {deviceHex(entry.device)} + + + {entry.advert.addresses.join(", ")} {entry.advert["snapshot-seconds"]} diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index e6ad658f..697fe886 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -2,6 +2,7 @@ import { Badge, Group, NavLink, Stack, Text } from "@mantine/core"; import { participantLabel, type ConversationView } from "../conversations.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; export interface ConversationListProps { conversations: readonly ConversationView[]; @@ -14,6 +15,7 @@ export function ConversationList({ selected, onSelect, }: Readonly): React.JSX.Element | null { + const names = usePeerNames(); if (conversations.length === 0) { return null; } @@ -26,7 +28,10 @@ export function ConversationList({ names.labelOf(hex).primary, + )} description={ conversation.status === "connected" ? undefined : "offline" } diff --git a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx index 29935bc2..9be97f24 100644 --- a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx +++ b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx @@ -3,6 +3,7 @@ import { Button, Card, Group, Table, Text } from "@mantine/core"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceHex } from "./ConnectionPanel.js"; +import { PeerLabel } from "./PeerLabel.js"; export interface DiscoveredPeerRow { key: string; @@ -40,7 +41,9 @@ export function DiscoveredPeersPanel({ {peers.map((peer) => ( - {deviceHex(peer.device)} + + + {peer.addresses.join(", ")} diff --git a/ts/packages/web-console/src/components/PeerLabel.tsx b/ts/packages/web-console/src/components/PeerLabel.tsx new file mode 100644 index 00000000..b8467234 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerLabel.tsx @@ -0,0 +1,114 @@ +// One peer as a person reads it: the label from the naming convention, the short id as a copy target for the full device-id, and an inline rename that sets this viewer's own petname. + +import { useState } from "react"; +import { + ActionIcon, + CopyButton, + Group, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { usePeerNames } from "../hooks/use-peer-names.js"; + +/** Focuses the rename field as it appears, so the keyboard lands where the click that opened it was aimed. */ +function focusOnMount(input: HTMLInputElement | null): void { + input?.focus(); +} + +export interface PeerLabelProps { + deviceHex: string; +} + +export function PeerLabel({ + deviceHex, +}: Readonly): React.JSX.Element { + const names = usePeerNames(); + const label = names.labelOf(deviceHex); + const [draft, setDraft] = useState(undefined); + + if (draft !== undefined) { + return ( +
{ + event.preventDefault(); + void names.rename(deviceHex, draft).then(() => { + setDraft(undefined); + }); + }} + > + + { + setDraft(event.currentTarget.value); + }} + /> + + ✓ + + { + setDraft(undefined); + }} + > + × + + +
+ ); + } + + return ( + +
+ + {label.primary} + + {label.secondary !== undefined && ( + + {label.secondary} + + )} +
+ + {({ copied, copy }) => ( + + + ⧉ + + + )} + + { + setDraft(names.petnameOf(deviceHex) ?? ""); + }} + > + ✎ + +
+ ); +} diff --git a/ts/packages/web-console/src/components/PeerName.tsx b/ts/packages/web-console/src/components/PeerName.tsx new file mode 100644 index 00000000..218f0f40 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerName.tsx @@ -0,0 +1,13 @@ +// A peer's name as plain inline text, for sentences and messages where PeerLabel's copy and rename controls would be out of place. + +import { usePeerNames } from "../hooks/use-peer-names.js"; + +export interface PeerNameProps { + deviceHex: string; +} + +export function PeerName({ + deviceHex, +}: Readonly): React.JSX.Element { + return <>{usePeerNames().labelOf(deviceHex).primary}; +} diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx new file mode 100644 index 00000000..ace1a429 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -0,0 +1,85 @@ +// Holds the petnames loaded from the console's name store and the self display names observed in directories, and serves them through PeerNamesContext. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import type { NameStore } from "../name-store.js"; +import { labelPeer, selfAssertedName } from "../peer-names.js"; +import { PeerNamesContext } from "../hooks/use-peer-names.js"; +import type { PeerNames } from "../hooks/use-peer-names.js"; + +export interface PeerNamesProviderProps { + store: NameStore; + children: React.ReactNode; +} + +export function PeerNamesProvider({ + store, + children, +}: Readonly): React.JSX.Element { + const [petnames, setPetnames] = useState>( + new Map(), + ); + const [selfNames, setSelfNames] = useState>( + new Map(), + ); + const [selfName, setOwnName] = useState(undefined); + + useEffect(() => { + void store.petnames().then(setPetnames); + void store.selfName().then(setOwnName); + }, [store]); + + const rename = useCallback( + async (deviceHex: string, name: string): Promise => { + await store.setPetname(deviceHex, name); + setPetnames(await store.petnames()); + }, + [store], + ); + + const setSelfName = useCallback( + async (name: string): Promise => { + await store.setSelfName(name); + setOwnName(await store.selfName()); + }, + [store], + ); + + const observeDirectory = useCallback( + (directory: readonly DirectoryEntry[]): void => { + setSelfNames((current) => { + let next: Map | undefined; + for (const entry of directory) { + const name = selfAssertedName(entry.advert); + const hex = deviceIdToHex(entry.device); + if (name !== undefined && current.get(hex) !== name) { + next ??= new Map(current); + next.set(hex, name); + } + } + return next ?? current; + }); + }, + [], + ); + + const value = useMemo( + () => ({ + labelOf: (deviceHex) => + labelPeer(deviceHex, petnames.get(deviceHex), selfNames.get(deviceHex)), + petnameOf: (deviceHex) => petnames.get(deviceHex), + rename, + selfName, + setSelfName, + observeDirectory, + }), + [petnames, selfNames, selfName, rename, setSelfName, observeDirectory], + ); + + return ( + + {children} + + ); +} diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index a3cbea22..4d8266a3 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -3,6 +3,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 { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -36,6 +37,7 @@ export function RoomPanel({ onRetry, onDiscard, }: Readonly): React.JSX.Element { + const names = usePeerNames(); return ( @@ -82,7 +84,7 @@ export function RoomPanel({ names.labelOf(hex).primary)} onRetry={onRetry} onDiscard={onDiscard} /> diff --git a/ts/packages/web-console/src/components/SelfNameField.tsx b/ts/packages/web-console/src/components/SelfNameField.tsx new file mode 100644 index 00000000..3e5d9ae5 --- /dev/null +++ b/ts/packages/web-console/src/components/SelfNameField.tsx @@ -0,0 +1,43 @@ +// The field for this console's own display name: what it publishes, signed by its device key, to every node it connects to. Empty means it publishes none and peers see only its short id. + +import { useState } from "react"; +import { Button, Group, TextInput } from "@mantine/core"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { MAX_NAME_LENGTH } from "../peer-names.js"; + +export function SelfNameField(): React.JSX.Element { + const { selfName, setSelfName } = usePeerNames(); + // The text being typed, or undefined while the field just shows the saved name. + const [edit, setEdit] = useState(undefined); + const draft = edit ?? selfName ?? ""; + return ( +
{ + event.preventDefault(); + void setSelfName(draft).then(() => { + setEdit(undefined); + }); + }} + > + + { + setEdit(event.currentTarget.value); + }} + style={{ flex: 1, minWidth: "16rem" }} + /> + + +
+ ); +} diff --git a/ts/packages/web-console/src/conversations.ts b/ts/packages/web-console/src/conversations.ts index 1aa7b469..15bf1a6e 100644 --- a/ts/packages/web-console/src/conversations.ts +++ b/ts/packages/web-console/src/conversations.ts @@ -277,14 +277,11 @@ export function participantsOf( return parsed.owner === ownDeviceHex ? [] : [parsed.owner]; } -/** How a conversation's other members are named in the UI. A device-id is long, so this shows enough leading hex to tell devices apart at a glance; a conversation with no other members is named by its room path. */ -const DEVICE_LABEL_LENGTH = 12; - +/** 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. */ export function participantLabel( view: Readonly>, + nameOf: (deviceHex: string) => string, ): string { if (view.participants.length === 0) return view.roomPath; - return view.participants - .map((hex) => hex.slice(0, DEVICE_LABEL_LENGTH)) - .join(", "); + return view.participants.map(nameOf).join(", "); } diff --git a/ts/packages/web-console/src/hooks/use-peer-names.ts b/ts/packages/web-console/src/hooks/use-peer-names.ts new file mode 100644 index 00000000..67c518af --- /dev/null +++ b/ts/packages/web-console/src/hooks/use-peer-names.ts @@ -0,0 +1,27 @@ +// The naming context every peer-bearing component reads, so the display convention (petname, then self display name, then short id) is implemented once in peer-names.ts and reached from ConversationList, the connection directory and the discovered-peers table alike. + +import { createContext, useContext } from "react"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import type { PeerLabel } from "../peer-names.js"; + +export interface PeerNames { + labelOf: (deviceHex: string) => PeerLabel; + petnameOf: (deviceHex: string) => string | undefined; + /** Gives a device a petname of this viewer's own, or removes it when blank. */ + rename: (deviceHex: string, name: string) => Promise; + /** This console's own display name, published in its gossip adverts. */ + selfName: string | undefined; + setSelfName: (name: string) => Promise; + /** Records the display names the verified adverts of a directory assert. */ + observeDirectory: (directory: readonly DirectoryEntry[]) => void; +} + +export const PeerNamesContext = createContext(undefined); + +export function usePeerNames(): PeerNames { + const names = useContext(PeerNamesContext); + if (names === undefined) { + throw new Error("usePeerNames must be used inside PeerNamesProvider"); + } + return names; +} diff --git a/ts/packages/web-console/src/main.tsx b/ts/packages/web-console/src/main.tsx index 106b0729..88626e86 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 { createNameStore } from "./name-store.js"; import { createMessageStore } from "./message-store.js"; import { App } from "./App.js"; import { PwaUpdatePrompt } from "./components/PwaUpdatePrompt.js"; @@ -21,6 +22,7 @@ const roomStorage = await createIndexedDbStorage(); const certificateMemory = createCertificateMemory( await createIndexedDbStorage(), ); +const nameStore = createNameStore(await createIndexedDbStorage()); const address = defaultHubAddress(import.meta.env.DEV, window.location); const container = document.getElementById("root"); @@ -37,6 +39,7 @@ createRoot(container).render( messageStore={messageStore} roomStorage={roomStorage} certificateMemory={certificateMemory} + nameStore={nameStore} defaultAddress={address} /> diff --git a/ts/packages/web-console/src/name-store.ts b/ts/packages/web-console/src/name-store.ts new file mode 100644 index 00000000..d4fd70e2 --- /dev/null +++ b/ts/packages/web-console/src/name-store.ts @@ -0,0 +1,54 @@ +// Persistence for the names this console keeps about itself and its peers, over the portable KeyValueStorage port. Petnames (device-id to this viewer's own label) and this console's own display name are local to the storage they are written to and are never sent anywhere by this module. + +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; +import { cleanName } from "./peer-names.js"; + +const PETNAME_PREFIX = "petname/"; +const SELF_NAME_KEY = "self-name"; + +export interface NameStore { + /** Every petname held, keyed by device-id hex. */ + petnames: () => Promise>; + /** Sets the petname for a device, or removes it when `name` is blank. */ + setPetname: (deviceHex: string, name: string) => Promise; + /** This console's own display name, or undefined when none is set. */ + selfName: () => Promise; + /** Sets this console's own display name, or clears it when `name` is blank. */ + setSelfName: (name: string) => Promise; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function createNameStore(storage: Readonly): NameStore { + async function write(key: string, name: string): Promise { + const cleaned = cleanName(name); + if (cleaned === undefined) { + await storage.delete(key); + return; + } + await storage.set(key, new Uint8Array(encoder.encode(cleaned))); + } + return { + async petnames() { + const names = new Map(); + for (const key of await storage.keys(PETNAME_PREFIX)) { + const value = await storage.get(key); + if (value !== undefined) { + names.set(key.slice(PETNAME_PREFIX.length), decoder.decode(value)); + } + } + return names; + }, + async setPetname(deviceHex, name) { + await write(PETNAME_PREFIX + deviceHex, name); + }, + async selfName() { + const value = await storage.get(SELF_NAME_KEY); + return value === undefined ? undefined : decoder.decode(value); + }, + async setSelfName(name) { + await write(SELF_NAME_KEY, name); + }, + }; +} diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts new file mode 100644 index 00000000..c2f3e5fc --- /dev/null +++ b/ts/packages/web-console/src/peer-names.ts @@ -0,0 +1,71 @@ +// How a peer is named wherever it appears in the console. A device-id is 64 hex characters, which no person can hold in mind, so every surface shows the same three-tier label: a petname this viewer gave the device, else the name the device signed about itself, else a short id. Petnames live in this console's own storage and are never gossiped, so there is no global authority and no collision to resolve. The convention matches agent-comms, so the two surfaces read the same. + +import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; + +/** Leading hex characters of a device-id shown as its short id: enough to tell devices apart at a glance, and short enough to read aloud. */ +export const SHORT_ID_LENGTH = 12; + +/** A name longer than this is cut when shown. A self-asserted name arrives from a stranger through a hub that only checks it is a string, so the console bounds what it will lay out. */ +export const MAX_NAME_LENGTH = 64; + +/** The gossip extension a self display name travels in. It is the key agent-comms already publishes its agent name under, and the one the public hub's advert policy carries, so a console name reaches peers through that hub and agent-comms names appear here without a second convention. The advert signature covers every extension entry, so the name is signed by the device's own key. */ +export const SELF_ADVERT_KEY = "agent/self"; + +/** The `harness` the same extension requires alongside `name`. */ +export const CONSOLE_HARNESS = "wire-mesh-console"; + +/** Which tier of the naming convention produced a label. */ +export type PeerLabelSource = "petname" | "self" | "id"; + +export interface PeerLabel { + /** The text that names the peer: the petname, else the self display name, else the short id. */ + primary: string; + /** What to show beside the primary text, or undefined when the primary is already the short id. A petname is shown with the peer's own claim so a renamed peer can still be recognised; a self name is shown with the short id so two peers claiming one name can be told apart. */ + secondary: string | undefined; + source: PeerLabelSource; +} + +export function shortId(deviceHex: string): string { + return deviceHex.slice(0, SHORT_ID_LENGTH); +} + +/** A name fit to display: trimmed, non-empty, and within MAX_NAME_LENGTH. */ +export function cleanName(name: string): string | undefined { + const trimmed = name.trim(); + if (trimmed === "") return undefined; + return trimmed.slice(0, MAX_NAME_LENGTH); +} + +/** The display name a peer signed about itself, read from its verified advert, or undefined when it published none or a malformed one. */ +export function selfAssertedName( + advert: Readonly, +): string | undefined { + const claim: unknown = advert[SELF_ADVERT_KEY]; + if (typeof claim !== "object" || claim === null) return undefined; + if (!("name" in claim) || typeof claim.name !== "string") return undefined; + return cleanName(claim.name); +} + +/** The extension bag that publishes `name` as this device's own display name. */ +export function selfNameExtension(name: string): Record { + return { [SELF_ADVERT_KEY]: { name, harness: CONSOLE_HARNESS } }; +} + +export function labelPeer( + deviceHex: string, + petname: string | undefined, + selfName: string | undefined, +): PeerLabel { + if (petname !== undefined) { + return { + primary: petname, + secondary: + selfName !== undefined && selfName !== petname ? selfName : undefined, + source: "petname", + }; + } + if (selfName !== undefined) { + return { primary: selfName, secondary: shortId(deviceHex), source: "self" }; + } + return { primary: shortId(deviceHex), secondary: undefined, source: "id" }; +} diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index b7e3576f..2b9d7cc3 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -19,14 +19,24 @@ import { } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; import type { Clock } from "wire-mesh-core/ports/clock"; import type { GossipFrame } from "wire-mesh-core/generated/protocol"; -import { messageFromFrame } from "wire-mesh-core/adapters/frame-codec"; +import { + decodeMessage, + messageFromFrame, +} from "wire-mesh-core/adapters/frame-codec"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { dmRoomPath } from "wire-mesh-core/domain/room-path"; 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 { createNameStore } from "../src/name-store.js"; +import { + selfAssertedName, + selfNameExtension, + shortId, +} from "../src/peer-names.js"; import { createCertificateMemory } from "../src/certificate-memory.js"; import { App } from "../src/App.js"; import type { MessageStore, StoredMessage } from "../src/message-store.js"; @@ -70,12 +80,14 @@ function renderApp( discoverLocalNode?: () => Promise; defaultAddress?: string; messageStore?: MessageStore; + nameStorage?: KeyValueStorage; }> = {}, ): ReturnType { const { discoverLocalNode, defaultAddress, messageStore = fakeMessageStore(), + nameStorage = createMemoryStorage(), } = options; return render( @@ -85,6 +97,7 @@ function renderApp( messageStore={messageStore} roomStorage={createMemoryStorage()} certificateMemory={createCertificateMemory(createMemoryStorage())} + nameStore={createNameStore(nameStorage)} {...(discoverLocalNode === undefined ? {} : { discoverLocalNode })} {...(defaultAddress === undefined ? {} : { defaultAddress })} /> @@ -103,6 +116,11 @@ const GOSSIPED_ADDRESS = "203.0.113.5:4433"; let gossipedFrame: GossipFrame; let gossipedDeviceHex: string; +/** A second remote whose advert carries a self display name, and the hex of the device it names. */ +const SELF_ASSERTED_NAME = "Ada's laptop"; +let namedFrame: GossipFrame; +let namedDeviceHex: string; + beforeAll(async () => { appIdentity = await createWebCryptoIdentity(); const remote = await createWebCryptoIdentity(); @@ -114,6 +132,20 @@ beforeAll(async () => { }); gossipedFrame = { type: "gossip", peers: [advert] }; gossipedDeviceHex = deviceIdToHex(remote.deviceId); + const named = await createWebCryptoIdentity(); + namedFrame = { + type: "gossip", + peers: [ + await signPeerAdvert(named, { + ...selfNameExtension(SELF_ASSERTED_NAME), + device: named.deviceId, + addresses: [GOSSIPED_ADDRESS], + "snapshot-seconds": 0, + "identity-key": named.identityKey, + }), + ], + }; + namedDeviceHex = deviceIdToHex(named.deviceId); }); /** messageFromFrame's own Uint8Array may be a view into a larger backing buffer -- slicing to its own byteOffset/byteLength before handing it to emitMessage is what every other adapter test here already does (see websocket-transport.integration.test.ts's identical helper), since FakeWebSocket.emitMessage takes the raw buffer, not a view onto it. */ @@ -127,7 +159,7 @@ function arrayBuffer(bytes: Uint8Array): ArrayBuffer { /** The discovered-peer table row for a given device within an already-confirmed-present discovered-peers panel -- scoped lookup so its own "Connect" button can be found distinctly from both the connect form's identically-labelled submit button and the identical device hex rendered elsewhere (a device's own hex also renders inside its originating ConnectionPanel's unrelated "Peer directory" table). Callers must await screen.findByTestId("discovered-peers") first: the panel itself never mounts at all while no peer is pending, so this cannot also do that initial wait. */ function discoveredRow(deviceHex: string): HTMLElement { const row = within(screen.getByTestId("discovered-peers")) - .getByText(deviceHex) + .getByText(shortId(deviceHex)) .closest("tr"); if (row === null) { throw new Error(`expected a table row for device ${deviceHex}`); @@ -135,6 +167,36 @@ function discoveredRow(deviceHex: string): HTMLElement { return row; } +/** Connects the form's address and opens its socket, resolving once the session reports connected, which is when its frame listener is registered. */ +async function connectRoot(): Promise { + submitConnectForm(); + await vi.waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const rootSocket = sockets[0]; + if (rootSocket === undefined) { + throw new Error("expected the root socket to exist"); + } + rootSocket.emitOpen(); + await screen.findByText(/^connected/); + return rootSocket; +} + +/** The display names carried by the gossip frames a socket sent after `from` sends. */ +function publishedSelfNames( + socket: Readonly, + from: number, +): unknown[] { + return socket.sent.slice(from).flatMap((data) => { + const frame = decodeMessage(data); + if (frame.type !== "gossip") return []; + return frame.peers.flatMap((advert) => { + const name = selfAssertedName(advert); + return name === undefined ? [] : [name]; + }); + }); +} + describe("App", () => { beforeEach(() => { sockets = []; @@ -275,7 +337,9 @@ describe("App", () => { rootSocket.emitMessage(arrayBuffer(messageFromFrame(gossipedFrame))); const panel = await screen.findByTestId("discovered-peers"); - expect(within(panel).getByText(gossipedDeviceHex)).toBeInTheDocument(); + expect( + within(panel).getByText(shortId(gossipedDeviceHex)), + ).toBeInTheDocument(); expect(within(panel).getByText(GOSSIPED_ADDRESS)).toBeInTheDocument(); fireEvent.click( @@ -308,9 +372,15 @@ describe("App", () => { rootSocket.emitMessage(arrayBuffer(messageFromFrame(gossipedFrame))); // jsdom has no RTCPeerConnection, so the negotiation fails as soon as it starts. - const directoryRow = (await screen.findAllByText(gossipedDeviceHex)) + const directoryRow = ( + await screen.findAllByText(shortId(gossipedDeviceHex)) + ) .map((cell) => cell.closest("tr")) - .find((row) => row?.querySelector("button")?.textContent === "Message"); + .find( + (row) => + row !== null && + within(row).queryByRole("button", { name: "Message" }) !== null, + ); if (directoryRow === null || directoryRow === undefined) { throw new Error("expected a directory row with a Message button"); } @@ -321,7 +391,7 @@ describe("App", () => { // The conversation is open over the hub at once, and only the direct connection failed. const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent( - `No direct connection to ${gossipedDeviceHex}`, + `No direct connection to ${shortId(gossipedDeviceHex)}`, ); expect(alert).toHaveTextContent(/RTCPeerConnection/); expect(alert).toHaveTextContent("Messages go through the hub instead"); @@ -357,4 +427,45 @@ describe("App", () => { expect(screen.queryByTestId("discovered-peers")).toBeNull(); expect(sockets).toHaveLength(1); }); + + it("names a gossiped peer by the display name its signed advert asserts, with its short id beside it", async () => { + renderApp(); + + const rootSocket = await connectRoot(); + rootSocket.emitMessage(arrayBuffer(messageFromFrame(namedFrame))); + + const panel = await screen.findByTestId("discovered-peers"); + expect(within(panel).getByText(SELF_ASSERTED_NAME)).toBeInTheDocument(); + expect( + within(panel).getByText(shortId(namedDeviceHex)), + ).toBeInTheDocument(); + }); + + it("shows a petname from the console's own storage ahead of the name the peer asserts", async () => { + const nameStorage = createMemoryStorage(); + await createNameStore(nameStorage).setPetname(namedDeviceHex, "Ada"); + renderApp({ nameStorage }); + + const rootSocket = await connectRoot(); + rootSocket.emitMessage(arrayBuffer(messageFromFrame(namedFrame))); + + const panel = await screen.findByTestId("discovered-peers"); + expect(within(panel).getByText("Ada")).toBeInTheDocument(); + expect(within(panel).getByText(SELF_ASSERTED_NAME)).toBeInTheDocument(); + }); + + it("publishes this console's own display name in a signed gossip advert once it is saved on a connected session", async () => { + renderApp(); + const rootSocket = await connectRoot(); + const sentBefore = rootSocket.sent.length; + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + + await vi.waitFor(() => { + expect(publishedSelfNames(rootSocket, sentBefore)).toEqual(["Grace"]); + }); + }); }); diff --git a/ts/packages/web-console/test/ConversationList.unit.test.tsx b/ts/packages/web-console/test/ConversationList.unit.test.tsx index b8dcf6da..a966fce5 100644 --- a/ts/packages/web-console/test/ConversationList.unit.test.tsx +++ b/ts/packages/web-console/test/ConversationList.unit.test.tsx @@ -3,9 +3,9 @@ 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 { MantineProvider } from "@mantine/core"; import { ConversationList } from "../src/components/ConversationList.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; @@ -31,16 +31,17 @@ function conversation( function renderList( props: Partial> = {}, + store = memoryNameStore(), ): ReturnType { return render( - + undefined} {...props} /> - , + , ); } @@ -113,3 +114,28 @@ describe("ConversationList", () => { expect(onSelect).toHaveBeenCalledWith(second.roomPath); }); }); + +describe("ConversationList naming", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("labels a conversation by the petname held for its peer", async () => { + const store = memoryNameStore(); + await store.setPetname("2".repeat(DEVICE_ID_HEX_LENGTH), "Ada"); + + renderList( + { conversations: [conversation("2"), conversation("3")] }, + store, + ); + + await screen.findByText("Ada"); + expect(screen.getByText("333333333333")).toBeInTheDocument(); + expect(screen.queryByText("222222222222")).toBeNull(); + }); +}); diff --git a/ts/packages/web-console/test/PeerLabel.unit.test.tsx b/ts/packages/web-console/test/PeerLabel.unit.test.tsx new file mode 100644 index 00000000..cb52d5fc --- /dev/null +++ b/ts/packages/web-console/test/PeerLabel.unit.test.tsx @@ -0,0 +1,99 @@ +// @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 { PeerLabel } from "../src/components/PeerLabel.js"; +import { shortId } from "../src/peer-names.js"; +import { 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); + +describe("PeerLabel", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("shows the short id for a peer nothing names", () => { + render( + + + , + ); + + expect(screen.getByText(shortId(DEVICE_HEX))).toBeInTheDocument(); + }); + + it("shows a stored petname in place of the short id", async () => { + const store = memoryNameStore(); + await store.setPetname(DEVICE_HEX, "Ada"); + + render( + + + , + ); + + await screen.findByText("Ada"); + expect(screen.queryByText(shortId(DEVICE_HEX))).toBeNull(); + }); + + it("renames a peer, persists the petname and shows it", async () => { + const store = memoryNameStore(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Rename/ })); + fireEvent.change(screen.getByLabelText(/^Petname for/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save petname" })); + + await screen.findByText("Grace"); + expect(await store.petnames()).toEqual(new Map([[DEVICE_HEX, "Grace"]])); + }); + + it("leaves the name unchanged when a rename is cancelled", async () => { + const store = memoryNameStore(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Rename/ })); + fireEvent.change(screen.getByLabelText(/^Petname for/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel rename" })); + + expect(screen.getByText(shortId(DEVICE_HEX))).toBeInTheDocument(); + expect(await store.petnames()).toEqual(new Map()); + }); + + it("copies the full device-id", () => { + const writeText = vi.fn<(text: string) => Promise>(async () => + Promise.resolve(), + ); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Copy device id/ })); + + expect(writeText).toHaveBeenCalledWith(DEVICE_HEX); + }); +}); diff --git a/ts/packages/web-console/test/RoomPanel.unit.test.tsx b/ts/packages/web-console/test/RoomPanel.unit.test.tsx index 5098db92..e2c8ba48 100644 --- a/ts/packages/web-console/test/RoomPanel.unit.test.tsx +++ b/ts/packages/web-console/test/RoomPanel.unit.test.tsx @@ -9,9 +9,9 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; import { RoomPanel } from "../src/components/RoomPanel.js"; import type { ConversationView } from "../src/conversations.js"; +import { WithNames } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -35,7 +35,7 @@ function renderPanel( props: Partial> = {}, ): ReturnType { return render( - + Promise.resolve()} @@ -44,7 +44,7 @@ function renderPanel( onDiscard={() => undefined} {...props} /> - , + , ); } diff --git a/ts/packages/web-console/test/conversations.unit.test.ts b/ts/packages/web-console/test/conversations.unit.test.ts index ca2a0810..c7d2b59c 100644 --- a/ts/packages/web-console/test/conversations.unit.test.ts +++ b/ts/packages/web-console/test/conversations.unit.test.ts @@ -18,6 +18,7 @@ import { type RelayRoute, } from "../src/conversations.js"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; +import { shortId } from "../src/peer-names.js"; import type { StoredMessage } from "../src/message-store.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -432,11 +433,16 @@ describe("participantsOf", () => { describe("participantLabel", () => { it("shortens each participant's device-id and joins them", () => { expect( - participantLabel({ roomPath: DM, participants: [PEER, OTHER_PEER] }), + participantLabel( + { roomPath: DM, participants: [PEER, OTHER_PEER] }, + shortId, + ), ).toBe("222222222222, 333333333333"); }); it("falls back to the room path when nobody else is in the room", () => { - expect(participantLabel({ roomPath: DM, participants: [] })).toBe(DM); + expect(participantLabel({ roomPath: DM, participants: [] }, shortId)).toBe( + DM, + ); }); }); diff --git a/ts/packages/web-console/test/name-store.unit.test.ts b/ts/packages/web-console/test/name-store.unit.test.ts new file mode 100644 index 00000000..4d9f415b --- /dev/null +++ b/ts/packages/web-console/test/name-store.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createNameStore } from "../src/name-store.js"; + +describe("name store", () => { + it("holds a petname per device and returns them all", async () => { + const store = createNameStore(createMemoryStorage()); + await store.setPetname("aa", "Ada"); + await store.setPetname("bb", "Grace"); + + expect(await store.petnames()).toEqual( + new Map([ + ["aa", "Ada"], + ["bb", "Grace"], + ]), + ); + }); + + it("removes a petname when it is set blank", async () => { + const store = createNameStore(createMemoryStorage()); + await store.setPetname("aa", "Ada"); + await store.setPetname("aa", " "); + + expect(await store.petnames()).toEqual(new Map()); + }); + + it("keeps petnames across store instances over the same storage", async () => { + const storage = createMemoryStorage(); + await createNameStore(storage).setPetname("aa", "Ada"); + + expect(await createNameStore(storage).petnames()).toEqual( + new Map([["aa", "Ada"]]), + ); + }); + + it("keeps the console's own display name apart from petnames", async () => { + const store = createNameStore(createMemoryStorage()); + expect(await store.selfName()).toBeUndefined(); + + await store.setSelfName(" Me "); + + expect(await store.selfName()).toBe("Me"); + expect(await store.petnames()).toEqual(new Map()); + + await store.setSelfName(""); + expect(await store.selfName()).toBeUndefined(); + }); +}); diff --git a/ts/packages/web-console/test/names-harness.tsx b/ts/packages/web-console/test/names-harness.tsx new file mode 100644 index 00000000..cc5c046f --- /dev/null +++ b/ts/packages/web-console/test/names-harness.tsx @@ -0,0 +1,25 @@ +// Wraps a component in the naming context over an in-memory name store, the way App does for the real tree. + +import { MantineProvider } from "@mantine/core"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { PeerNamesProvider } from "../src/components/PeerNamesProvider.js"; +import { createNameStore } from "../src/name-store.js"; +import type { NameStore } from "../src/name-store.js"; + +export function memoryNameStore(): NameStore { + return createNameStore(createMemoryStorage()); +} + +export function WithNames({ + store = memoryNameStore(), + children, +}: Readonly<{ + store?: NameStore; + children: React.ReactNode; +}>): React.JSX.Element { + return ( + + {children} + + ); +} diff --git a/ts/packages/web-console/test/peer-names.unit.test.ts b/ts/packages/web-console/test/peer-names.unit.test.ts new file mode 100644 index 00000000..4161892a --- /dev/null +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; +import { + MAX_NAME_LENGTH, + SHORT_ID_LENGTH, + cleanName, + labelPeer, + selfAssertedName, + selfNameExtension, + shortId, +} from "../src/peer-names.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; +import { bytesFromHex } from "./hex.js"; + +const DEVICE_ID_BYTES = 32; +const DEVICE_HEX = "ab".repeat(DEVICE_ID_BYTES); +const OVERLONG_EXTRA = 1; + +function advertWith(extension: Record): PeerAdvert { + return { + device: bytesFromHex(DEVICE_HEX), + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...extension, + }; +} + +describe("shortId", () => { + it("is the leading characters of the device-id", () => { + expect(shortId(DEVICE_HEX)).toBe(DEVICE_HEX.slice(0, SHORT_ID_LENGTH)); + }); +}); + +describe("cleanName", () => { + it("trims and bounds a name", () => { + expect(cleanName(" Ada ")).toBe("Ada"); + expect( + cleanName("x".repeat(MAX_NAME_LENGTH + OVERLONG_EXTRA)), + ).toHaveLength(MAX_NAME_LENGTH); + }); + + it("rejects a blank name", () => { + expect(cleanName(" ")).toBeUndefined(); + }); +}); + +describe("selfAssertedName", () => { + it("reads the name from the agent/self extension", () => { + expect(selfAssertedName(advertWith(selfNameExtension("Ada")))).toBe("Ada"); + }); + + it("is undefined for an advert with no claim, a malformed claim or a blank name", () => { + expect(selfAssertedName(advertWith({}))).toBeUndefined(); + expect( + selfAssertedName(advertWith({ "agent/self": "Ada" })), + ).toBeUndefined(); + expect( + selfAssertedName(advertWith({ "agent/self": { name: 3 } })), + ).toBeUndefined(); + expect( + selfAssertedName(advertWith({ "agent/self": { name: " " } })), + ).toBeUndefined(); + }); + + it("bounds a long claimed name", () => { + const name = selfAssertedName( + advertWith(selfNameExtension("n".repeat(MAX_NAME_LENGTH * 2))), + ); + expect(name).toHaveLength(MAX_NAME_LENGTH); + }); +}); + +describe("labelPeer", () => { + it("prefers the petname and shows the peer's own claim beside it", () => { + expect(labelPeer(DEVICE_HEX, "Ada", "ada-laptop")).toEqual({ + primary: "Ada", + secondary: "ada-laptop", + source: "petname", + }); + }); + + it("omits the secondary text when the petname equals the claim or there is no claim", () => { + expect(labelPeer(DEVICE_HEX, "Ada", "Ada").secondary).toBeUndefined(); + expect(labelPeer(DEVICE_HEX, "Ada", undefined).secondary).toBeUndefined(); + }); + + it("falls back to the self-asserted name, with the short id beside it", () => { + expect(labelPeer(DEVICE_HEX, undefined, "ada-laptop")).toEqual({ + primary: "ada-laptop", + secondary: shortId(DEVICE_HEX), + source: "self", + }); + }); + + it("falls back to the short id when nothing names the peer", () => { + expect(labelPeer(DEVICE_HEX, undefined, undefined)).toEqual({ + primary: shortId(DEVICE_HEX), + secondary: undefined, + source: "id", + }); + }); +}); + +describe("selfNameExtension", () => { + it("carries the name under the key and shape the public hub's advert policy accepts", () => { + expect(selfNameExtension("Ada")).toEqual({ + "agent/self": { name: "Ada", harness: "wire-mesh-console" }, + }); + }); +}); From 4b3e7ca71410d649e9a99ae6c2b01eda9dc63ad1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:23:15 +0100 Subject: [PATCH 2/7] fix(web-console): show the short id beside a self-asserted name and strip control characters from it PeerName and the conversation labels rendered only the stranger-chosen name, so two peers claiming one name could not be told apart. cleanName now drops control and bidirectional formatting characters and truncates by code point. --- .../src/components/ConversationList.tsx | 6 +- .../web-console/src/components/PeerName.tsx | 5 +- .../web-console/src/components/RoomPanel.tsx | 5 +- ts/packages/web-console/src/peer-names.ts | 18 ++- .../web-console/test/PeerName.unit.test.tsx | 135 ++++++++++++++++++ .../web-console/test/peer-names.unit.test.ts | 34 +++++ 6 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 ts/packages/web-console/test/PeerName.unit.test.tsx diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index 697fe886..413c1c38 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -3,6 +3,7 @@ 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[]; @@ -28,9 +29,8 @@ export function ConversationList({ names.labelOf(hex).primary, + label={participantLabel(conversation, (hex) => + labelText(names.labelOf(hex)), )} description={ conversation.status === "connected" ? undefined : "offline" diff --git a/ts/packages/web-console/src/components/PeerName.tsx b/ts/packages/web-console/src/components/PeerName.tsx index 218f0f40..9bac52b3 100644 --- a/ts/packages/web-console/src/components/PeerName.tsx +++ b/ts/packages/web-console/src/components/PeerName.tsx @@ -1,6 +1,7 @@ -// A peer's name as plain inline text, for sentences and messages where PeerLabel's copy and rename controls would be out of place. +// A peer's name as plain inline text (a self-asserted name with its short id beside it), for sentences and messages where PeerLabel's copy and rename controls would be out of place. import { usePeerNames } from "../hooks/use-peer-names.js"; +import { labelText } from "../peer-names.js"; export interface PeerNameProps { deviceHex: string; @@ -9,5 +10,5 @@ export interface PeerNameProps { export function PeerName({ deviceHex, }: Readonly): React.JSX.Element { - return <>{usePeerNames().labelOf(deviceHex).primary}; + return <>{labelText(usePeerNames().labelOf(deviceHex))}; } diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index 4d8266a3..5a722d7b 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 { labelText } from "../peer-names.js"; import { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -84,7 +85,9 @@ export function RoomPanel({ names.labelOf(hex).primary)} + peerLabel={participantLabel(view, (hex) => + labelText(names.labelOf(hex)), + )} onRetry={onRetry} onDiscard={onDiscard} /> diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts index c2f3e5fc..36f7810f 100644 --- a/ts/packages/web-console/src/peer-names.ts +++ b/ts/packages/web-console/src/peer-names.ts @@ -29,11 +29,15 @@ export function shortId(deviceHex: string): string { return deviceHex.slice(0, SHORT_ID_LENGTH); } -/** A name fit to display: trimmed, non-empty, and within MAX_NAME_LENGTH. */ +/** Control characters and the bidirectional formatting characters (marks, embeddings, overrides, isolates). A self-asserted name is laid out beside neighbouring text, and these let a stranger reorder or hide that text. Other format characters such as the zero-width joiner stay, since emoji sequences need them. */ +const UNSAFE_NAME_CHARACTERS = + /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; + +/** A name fit to display: free of control and bidirectional formatting characters, trimmed, non-empty, and within MAX_NAME_LENGTH code points (never cutting a surrogate pair in half). */ export function cleanName(name: string): string | undefined { - const trimmed = name.trim(); + const trimmed = name.replace(UNSAFE_NAME_CHARACTERS, "").trim(); if (trimmed === "") return undefined; - return trimmed.slice(0, MAX_NAME_LENGTH); + return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join(""); } /** The display name a peer signed about itself, read from its verified advert, or undefined when it published none or a malformed one. */ @@ -69,3 +73,11 @@ export function labelPeer( } return { primary: shortId(deviceHex), secondary: undefined, source: "id" }; } + +/** A label as one line of plain text. A self-asserted name carries the short id so that two peers claiming the same name still read differently; a petname or a short id stands alone. */ +export function labelText(label: Readonly): string { + if (label.source === "self" && label.secondary !== undefined) { + return `${label.primary} (${label.secondary})`; + } + return label.primary; +} diff --git a/ts/packages/web-console/test/PeerName.unit.test.tsx b/ts/packages/web-console/test/PeerName.unit.test.tsx new file mode 100644 index 00000000..a1896e12 --- /dev/null +++ b/ts/packages/web-console/test/PeerName.unit.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import type { NameStore } from "../src/name-store.js"; +import { PeerName } from "../src/components/PeerName.js"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; +import { selfNameExtension, shortId } from "../src/peer-names.js"; +import { deviceIdFromFillHex } from "./hex.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const FIRST_FILL = "ab"; +const SECOND_FILL = "cd"; +const FIRST_HEX = FIRST_FILL.repeat(DEVICE_ID_HEX_LENGTH / FIRST_FILL.length); +const SECOND_HEX = SECOND_FILL.repeat( + DEVICE_ID_HEX_LENGTH / SECOND_FILL.length, +); +const CLAIMED_NAME = "Alice"; + +function entry( + fill: string, + extension: Record, +): DirectoryEntry { + return { + device: deviceIdFromFillHex(fill), + advert: { + device: deviceIdFromFillHex(fill), + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...extension, + }, + }; +} + +/** Feeds a directory to the naming context the way a connection panel does, and renders each given device's PeerName. */ +function Observed({ + directory, + devices, +}: Readonly<{ + directory: readonly DirectoryEntry[]; + devices: readonly string[]; +}>): React.JSX.Element { + const { observeDirectory } = usePeerNames(); + useEffect(() => { + observeDirectory(directory); + }, [directory, observeDirectory]); + return ( +
    + {devices.map((hex) => ( +
  • + +
  • + ))} +
+ ); +} + +function renderObserved( + directory: readonly DirectoryEntry[], + store: Readonly = memoryNameStore(), +): ReturnType { + return render( + + + , + ); +} + +describe("PeerName", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("tells two peers claiming the same name apart by their short ids", async () => { + renderObserved([ + entry(FIRST_FILL, selfNameExtension(CLAIMED_NAME)), + entry(SECOND_FILL, selfNameExtension(CLAIMED_NAME)), + ]); + + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent( + `${CLAIMED_NAME} (${shortId(FIRST_HEX)})`, + ); + }); + expect(screen.getByTestId(SECOND_HEX)).toHaveTextContent( + `${CLAIMED_NAME} (${shortId(SECOND_HEX)})`, + ); + }); + + it("shows a petname alone, since the viewer chose it", async () => { + const store = memoryNameStore(); + await store.setPetname(FIRST_HEX, "Ada"); + renderObserved([entry(FIRST_FILL, selfNameExtension(CLAIMED_NAME))], store); + + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent(/^Ada$/); + }); + }); + + it("drops a name once a later advert from the peer no longer asserts one", async () => { + const { rerender } = renderObserved([ + entry(FIRST_FILL, selfNameExtension(CLAIMED_NAME)), + ]); + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent(CLAIMED_NAME); + }); + + rerender( + + + , + ); + + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent( + new RegExp(`^${shortId(FIRST_HEX)}$`), + ); + }); + }); +}); diff --git a/ts/packages/web-console/test/peer-names.unit.test.ts b/ts/packages/web-console/test/peer-names.unit.test.ts index 4161892a..a15f8f77 100644 --- a/ts/packages/web-console/test/peer-names.unit.test.ts +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -5,6 +5,7 @@ import { SHORT_ID_LENGTH, cleanName, labelPeer, + labelText, selfAssertedName, selfNameExtension, shortId, @@ -40,6 +41,24 @@ describe("cleanName", () => { ).toHaveLength(MAX_NAME_LENGTH); }); + it("strips control and bidirectional formatting characters", () => { + expect(cleanName("A\u202Eda\u0000\u2066!\u200F")).toBe("Ada!"); + expect(cleanName("\u202E\u0007")).toBeUndefined(); + }); + + it("keeps the zero-width joiner that emoji sequences need", () => { + const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; + expect(cleanName(family)).toBe(family); + }); + + it("bounds by code point, never cutting a surrogate pair", () => { + const name = cleanName( + "\u{1F600}".repeat(MAX_NAME_LENGTH + OVERLONG_EXTRA), + ); + expect(Array.from(name ?? "")).toHaveLength(MAX_NAME_LENGTH); + expect(name).toBe("\u{1F600}".repeat(MAX_NAME_LENGTH)); + }); + it("rejects a blank name", () => { expect(cleanName(" ")).toBeUndefined(); }); @@ -109,3 +128,18 @@ describe("selfNameExtension", () => { }); }); }); + +describe("labelText", () => { + it("puts the short id beside a self-asserted name", () => { + expect(labelText(labelPeer(DEVICE_HEX, undefined, "Ada"))).toBe( + `Ada (${shortId(DEVICE_HEX)})`, + ); + }); + + it("shows a petname or a short id alone", () => { + expect(labelText(labelPeer(DEVICE_HEX, "Grace", "Ada"))).toBe("Grace"); + expect(labelText(labelPeer(DEVICE_HEX, undefined, undefined))).toBe( + shortId(DEVICE_HEX), + ); + }); +}); From 6ac51fd0a8d8239db7122f13863d22e248d7a4dc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:23:15 +0100 Subject: [PATCH 3/7] fix(web-console): retract a published display name when it is cleared and surface publish failures --- .../src/components/ConnectionPanel.tsx | 36 ++++++++++++++----- .../web-console/test/App.unit.test.tsx | 35 ++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index 8c0e6d4f..9e565ede 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,13 +1,12 @@ // 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 } from "@mantine/core"; -import { useEffect, useState } from "react"; import type { MeshSession, SessionEvent, } from "wire-mesh-core/domain/mesh-session"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { browserPermissions, @@ -119,13 +118,29 @@ export function ConnectionPanel({ observeDirectory(event.directory); } }, [event, observeDirectory]); - // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. + // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. Clearing the name re-sends the advert with no extensions, which is what retracts the earlier claim; that is only needed once a name has been published on this session. + const namePublished = useRef(false); + const [publishFailure, setPublishFailure] = useState( + undefined, + ); useEffect(() => { - if (connected && selfName !== undefined) { - session.sendGossipUpdate(selfNameExtension(selfName)).catch(() => { - // The link dropped between the status read and the send; this effect runs again when the session reconnects, which republishes the name. - }); - } + if (!connected) return; + if (selfName === undefined && !namePublished.current) return; + namePublished.current = selfName !== undefined; + session + .sendGossipUpdate( + selfName === undefined ? undefined : selfNameExtension(selfName), + ) + .then( + () => { + setPublishFailure(undefined); + }, + (error: unknown) => { + setPublishFailure( + error instanceof Error ? error.message : String(error), + ); + }, + ); }, [connected, selfName, session]); const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; @@ -158,6 +173,11 @@ export function ConnectionPanel({ {blocked} )} + {publishFailure !== undefined && ( + + {publishFailure} + + )} Peer directory diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index 2b9d7cc3..49c703c4 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -197,6 +197,19 @@ function publishedSelfNames( }); } +/** The display-name claim of each advert in every gossip frame a socket sent after `from` sends, with undefined for an advert that asserts none. */ +function gossipedClaims( + socket: Readonly, + from: number, +): (string | undefined)[] { + return socket.sent.slice(from).flatMap((data) => { + const frame = decodeMessage(data); + return frame.type === "gossip" + ? frame.peers.map((advert) => selfAssertedName(advert)) + : []; + }); +} + describe("App", () => { beforeEach(() => { sockets = []; @@ -468,4 +481,26 @@ describe("App", () => { expect(publishedSelfNames(rootSocket, sentBefore)).toEqual(["Grace"]); }); }); + + it("retracts a published display name with an advert that carries none once the name is cleared", async () => { + renderApp(); + const rootSocket = await connectRoot(); + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await vi.waitFor(() => { + expect(publishedSelfNames(rootSocket, 0)).toEqual(["Grace"]); + }); + const sentBefore = rootSocket.sent.length; + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + + await vi.waitFor(() => { + expect(gossipedClaims(rootSocket, sentBefore)).toEqual([undefined]); + }); + }); }); From a710a341d7c94d10712b6d7b123d8872b56403a7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:23:15 +0100 Subject: [PATCH 4/7] fix(web-console): drop a peer's name once it stops advertising one and surface name storage failures --- .../src/components/PeerNamesProvider.tsx | 67 +++++++++++++++---- .../test/PeerNamesProvider.unit.test.tsx | 37 ++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx index ace1a429..b8488d5e 100644 --- a/ts/packages/web-console/src/components/PeerNamesProvider.tsx +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -1,6 +1,7 @@ // Holds the petnames loaded from the console's name store and the self display names observed in directories, and serves them through PeerNamesContext. import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert } from "@mantine/core"; import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import type { NameStore } from "../name-store.js"; @@ -24,26 +25,55 @@ export function PeerNamesProvider({ new Map(), ); const [selfName, setOwnName] = useState(undefined); + const [storageFailure, setStorageFailure] = useState( + undefined, + ); + + /** Runs one storage operation and shows its failure to the person, since names are read and written from event handlers with no caller positioned to handle a rejection. */ + const reportFailure = useCallback( + async (operation: () => Promise): Promise => { + try { + await operation(); + setStorageFailure(undefined); + } catch (error: unknown) { + setStorageFailure( + error instanceof Error ? error.message : String(error), + ); + } + }, + [], + ); useEffect(() => { - void store.petnames().then(setPetnames); - void store.selfName().then(setOwnName); + Promise.all([store.petnames(), store.selfName()]).then( + ([loadedPetnames, loadedSelfName]) => { + setPetnames(loadedPetnames); + setOwnName(loadedSelfName); + }, + (error: unknown) => { + setStorageFailure( + error instanceof Error ? error.message : String(error), + ); + }, + ); }, [store]); const rename = useCallback( - async (deviceHex: string, name: string): Promise => { - await store.setPetname(deviceHex, name); - setPetnames(await store.petnames()); - }, - [store], + async (deviceHex: string, name: string): Promise => + reportFailure(async () => { + await store.setPetname(deviceHex, name); + setPetnames(await store.petnames()); + }), + [store, reportFailure], ); const setSelfName = useCallback( - async (name: string): Promise => { - await store.setSelfName(name); - setOwnName(await store.selfName()); - }, - [store], + async (name: string): Promise => + reportFailure(async () => { + await store.setSelfName(name); + setOwnName(await store.selfName()); + }), + [store, reportFailure], ); const observeDirectory = useCallback( @@ -53,8 +83,12 @@ export function PeerNamesProvider({ for (const entry of directory) { const name = selfAssertedName(entry.advert); const hex = deviceIdToHex(entry.device); - if (name !== undefined && current.get(hex) !== name) { - next ??= new Map(current); + if (current.get(hex) === name) continue; + next ??= new Map(current); + // A later advert without a valid name retracts the earlier claim. + if (name === undefined) { + next.delete(hex); + } else { next.set(hex, name); } } @@ -79,6 +113,11 @@ export function PeerNamesProvider({ return ( + {storageFailure !== undefined && ( + + {storageFailure} + + )} {children} ); diff --git a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx new file mode 100644 index 00000000..b6e005d9 --- /dev/null +++ b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { NameStore } from "../src/name-store.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +const STORAGE_FAILURE = "the origin's storage quota is exhausted"; + +describe("PeerNamesProvider", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("shows the reason when the saved names cannot be read", async () => { + const store: NameStore = { + ...memoryNameStore(), + petnames: async () => Promise.reject(new Error(STORAGE_FAILURE)), + }; + + render( + +

child

+
, + ); + + expect(await screen.findByText(STORAGE_FAILURE)).toBeInTheDocument(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); +}); From ecb9c87c25b765c06c3964df5a92bb317423268f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:24:41 +0100 Subject: [PATCH 5/7] fix(web-console): mark a self-asserted name as the peer's own claim and trim a name after the length cut A self name is shown in italics under a 'calls itself' marker and flagged when it equals a petname the viewer holds for any device. cleanName trims after truncating so a cut after a space leaves no trailing space, and the Save button compares the draft with its cleaned form. --- .../web-console/src/components/PeerLabel.tsx | 14 +++- .../src/components/PeerNamesProvider.tsx | 19 ++++- .../src/components/SelfNameField.tsx | 4 +- ts/packages/web-console/src/peer-names.ts | 23 +++++- .../web-console/test/PeerLabel.unit.test.tsx | 73 ++++++++++++++++++- .../test/SelfNameField.unit.test.tsx | 62 ++++++++++++++++ .../web-console/test/peer-names.unit.test.ts | 58 +++++++++++---- 7 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 ts/packages/web-console/test/SelfNameField.unit.test.tsx diff --git a/ts/packages/web-console/src/components/PeerLabel.tsx b/ts/packages/web-console/src/components/PeerLabel.tsx index b8467234..25466706 100644 --- a/ts/packages/web-console/src/components/PeerLabel.tsx +++ b/ts/packages/web-console/src/components/PeerLabel.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ActionIcon, CopyButton, + Badge, Group, Text, TextInput, @@ -68,13 +69,24 @@ export function PeerLabel({ return (
+ {label.source === "self" && ( + + calls itself + + )} {label.primary} + {label.matchesPetname && ( + + same as a name you set + + )} {label.secondary !== undefined && ( new Set(petnames.values()), [petnames]); + const value = useMemo( () => ({ labelOf: (deviceHex) => - labelPeer(deviceHex, petnames.get(deviceHex), selfNames.get(deviceHex)), + labelPeer( + deviceHex, + petnames.get(deviceHex), + selfNames.get(deviceHex), + heldPetnames, + ), petnameOf: (deviceHex) => petnames.get(deviceHex), rename, selfName, setSelfName, observeDirectory, }), - [petnames, selfNames, selfName, rename, setSelfName, observeDirectory], + [ + petnames, + heldPetnames, + selfNames, + selfName, + rename, + setSelfName, + observeDirectory, + ], ); return ( diff --git a/ts/packages/web-console/src/components/SelfNameField.tsx b/ts/packages/web-console/src/components/SelfNameField.tsx index 3e5d9ae5..7807a76d 100644 --- a/ts/packages/web-console/src/components/SelfNameField.tsx +++ b/ts/packages/web-console/src/components/SelfNameField.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button, Group, TextInput } from "@mantine/core"; import { usePeerNames } from "../hooks/use-peer-names.js"; -import { MAX_NAME_LENGTH } from "../peer-names.js"; +import { MAX_NAME_LENGTH, cleanName } from "../peer-names.js"; export function SelfNameField(): React.JSX.Element { const { selfName, setSelfName } = usePeerNames(); @@ -33,7 +33,7 @@ export function SelfNameField(): React.JSX.Element { diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts index 36f7810f..66948d47 100644 --- a/ts/packages/web-console/src/peer-names.ts +++ b/ts/packages/web-console/src/peer-names.ts @@ -23,6 +23,8 @@ export interface PeerLabel { /** What to show beside the primary text, or undefined when the primary is already the short id. A petname is shown with the peer's own claim so a renamed peer can still be recognised; a self name is shown with the short id so two peers claiming one name can be told apart. */ secondary: string | undefined; source: PeerLabelSource; + /** True when the primary text is a self-asserted name that equals a petname this viewer holds for some device, so the peer may be passing itself off as someone the viewer already named. Always false for a petname or a short id. */ + matchesPetname: boolean; } export function shortId(deviceHex: string): string { @@ -33,11 +35,11 @@ export function shortId(deviceHex: string): string { const UNSAFE_NAME_CHARACTERS = /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; -/** A name fit to display: free of control and bidirectional formatting characters, trimmed, non-empty, and within MAX_NAME_LENGTH code points (never cutting a surrogate pair in half). */ +/** A name fit to display: free of control and bidirectional formatting characters, non-empty, within MAX_NAME_LENGTH code points (never cutting a surrogate pair in half), and trimmed after that cut so a cut that lands after a space leaves no trailing space. */ export function cleanName(name: string): string | undefined { const trimmed = name.replace(UNSAFE_NAME_CHARACTERS, "").trim(); if (trimmed === "") return undefined; - return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join(""); + return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join("").trimEnd(); } /** The display name a peer signed about itself, read from its verified advert, or undefined when it published none or a malformed one. */ @@ -55,10 +57,12 @@ export function selfNameExtension(name: string): Record { return { [SELF_ADVERT_KEY]: { name, harness: CONSOLE_HARNESS } }; } +/** Labels a peer. `heldPetnames` is every petname this viewer holds, for any device, so a self-asserted name that copies one can be flagged. */ export function labelPeer( deviceHex: string, petname: string | undefined, selfName: string | undefined, + heldPetnames: ReadonlySet, ): PeerLabel { if (petname !== undefined) { return { @@ -66,12 +70,23 @@ export function labelPeer( secondary: selfName !== undefined && selfName !== petname ? selfName : undefined, source: "petname", + matchesPetname: false, }; } if (selfName !== undefined) { - return { primary: selfName, secondary: shortId(deviceHex), source: "self" }; + return { + primary: selfName, + secondary: shortId(deviceHex), + source: "self", + matchesPetname: heldPetnames.has(selfName), + }; } - return { primary: shortId(deviceHex), secondary: undefined, source: "id" }; + return { + primary: shortId(deviceHex), + secondary: undefined, + source: "id", + matchesPetname: false, + }; } /** A label as one line of plain text. A self-asserted name carries the short id so that two peers claiming the same name still read differently; a petname or a short id stands alone. */ diff --git a/ts/packages/web-console/test/PeerLabel.unit.test.tsx b/ts/packages/web-console/test/PeerLabel.unit.test.tsx index cb52d5fc..e3225b94 100644 --- a/ts/packages/web-console/test/PeerLabel.unit.test.tsx +++ b/ts/packages/web-console/test/PeerLabel.unit.test.tsx @@ -2,14 +2,43 @@ 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 { shortId } from "../src/peer-names.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 { 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(() => { @@ -96,4 +125,46 @@ describe("PeerLabel", () => { expect(writeText).toHaveBeenCalledWith(DEVICE_HEX); }); + + it("marks a self-asserted name as the peer's own claim, apart from a petname", async () => { + render( + + + + , + ); + + await screen.findByText("Alice"); + expect(screen.getByText("calls itself")).toBeInTheDocument(); + expect(screen.getByText(shortId(DEVICE_HEX))).toBeInTheDocument(); + expect(screen.queryByText("same as a name you set")).toBeNull(); + }); + + it("does not mark a petname as a claim", async () => { + const store = memoryNameStore(); + await store.setPetname(DEVICE_HEX, "Alice"); + render( + + + , + ); + + await screen.findByText("Alice"); + expect(screen.queryByText("calls itself")).toBeNull(); + }); + + it("flags a self-asserted name that copies a petname held for another device", async () => { + const store = memoryNameStore(); + await store.setPetname(OTHER_HEX, "Alice"); + render( + + + + , + ); + + expect( + await screen.findByText("same as a name you set"), + ).toBeInTheDocument(); + }); }); diff --git a/ts/packages/web-console/test/SelfNameField.unit.test.tsx b/ts/packages/web-console/test/SelfNameField.unit.test.tsx new file mode 100644 index 00000000..21fead38 --- /dev/null +++ b/ts/packages/web-console/test/SelfNameField.unit.test.tsx @@ -0,0 +1,62 @@ +// @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 { SelfNameField } from "../src/components/SelfNameField.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +describe("SelfNameField", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("keeps Save disabled for a draft that cleans to the name already saved", async () => { + const store = memoryNameStore(); + await store.setSelfName("Ada"); + render( + + + , + ); + const field = await screen.findByDisplayValue("Ada"); + + fireEvent.change(field, { target: { value: "Ada‮" } }); + + expect(screen.getByRole("button", { name: "Save name" })).toBeDisabled(); + }); + + it("keeps Save disabled for a draft of only stripped characters when no name is saved", () => { + render( + + + , + ); + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "‮⁦" }, + }); + + expect(screen.getByRole("button", { name: "Save name" })).toBeDisabled(); + }); + + it("enables Save for a draft that cleans to a different name", () => { + render( + + + , + ); + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "Grace" }, + }); + + expect(screen.getByRole("button", { name: "Save name" })).toBeEnabled(); + }); +}); diff --git a/ts/packages/web-console/test/peer-names.unit.test.ts b/ts/packages/web-console/test/peer-names.unit.test.ts index a15f8f77..bf50fa62 100644 --- a/ts/packages/web-console/test/peer-names.unit.test.ts +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -59,6 +59,11 @@ describe("cleanName", () => { expect(name).toBe("\u{1F600}".repeat(MAX_NAME_LENGTH)); }); + it("leaves no trailing space when the length cut lands after a space", () => { + const name = `${"a".repeat(MAX_NAME_LENGTH - 1)} b`; + expect(cleanName(name)).toBe("a".repeat(MAX_NAME_LENGTH - 1)); + }); + it("rejects a blank name", () => { expect(cleanName(" ")).toBeUndefined(); }); @@ -90,35 +95,56 @@ describe("selfAssertedName", () => { }); }); +const NO_PETNAMES: ReadonlySet = new Set(); + describe("labelPeer", () => { it("prefers the petname and shows the peer's own claim beside it", () => { - expect(labelPeer(DEVICE_HEX, "Ada", "ada-laptop")).toEqual({ + expect(labelPeer(DEVICE_HEX, "Ada", "ada-laptop", NO_PETNAMES)).toEqual({ primary: "Ada", secondary: "ada-laptop", source: "petname", + matchesPetname: false, }); }); it("omits the secondary text when the petname equals the claim or there is no claim", () => { - expect(labelPeer(DEVICE_HEX, "Ada", "Ada").secondary).toBeUndefined(); - expect(labelPeer(DEVICE_HEX, "Ada", undefined).secondary).toBeUndefined(); + expect( + labelPeer(DEVICE_HEX, "Ada", "Ada", NO_PETNAMES).secondary, + ).toBeUndefined(); + expect( + labelPeer(DEVICE_HEX, "Ada", undefined, NO_PETNAMES).secondary, + ).toBeUndefined(); }); it("falls back to the self-asserted name, with the short id beside it", () => { - expect(labelPeer(DEVICE_HEX, undefined, "ada-laptop")).toEqual({ - primary: "ada-laptop", - secondary: shortId(DEVICE_HEX), - source: "self", - }); + expect(labelPeer(DEVICE_HEX, undefined, "ada-laptop", NO_PETNAMES)).toEqual( + { + primary: "ada-laptop", + secondary: shortId(DEVICE_HEX), + source: "self", + matchesPetname: false, + }, + ); }); it("falls back to the short id when nothing names the peer", () => { - expect(labelPeer(DEVICE_HEX, undefined, undefined)).toEqual({ + expect(labelPeer(DEVICE_HEX, undefined, undefined, NO_PETNAMES)).toEqual({ primary: shortId(DEVICE_HEX), secondary: undefined, source: "id", + matchesPetname: false, }); }); + + it("flags a self-asserted name that copies a petname held for any device", () => { + const held = new Set(["Ada"]); + expect(labelPeer(DEVICE_HEX, undefined, "Ada", held).matchesPetname).toBe( + true, + ); + expect(labelPeer(DEVICE_HEX, undefined, "Grace", held).matchesPetname).toBe( + false, + ); + }); }); describe("selfNameExtension", () => { @@ -131,15 +157,17 @@ describe("selfNameExtension", () => { describe("labelText", () => { it("puts the short id beside a self-asserted name", () => { - expect(labelText(labelPeer(DEVICE_HEX, undefined, "Ada"))).toBe( - `Ada (${shortId(DEVICE_HEX)})`, - ); + expect( + labelText(labelPeer(DEVICE_HEX, undefined, "Ada", NO_PETNAMES)), + ).toBe(`Ada (${shortId(DEVICE_HEX)})`); }); it("shows a petname or a short id alone", () => { - expect(labelText(labelPeer(DEVICE_HEX, "Grace", "Ada"))).toBe("Grace"); - expect(labelText(labelPeer(DEVICE_HEX, undefined, undefined))).toBe( - shortId(DEVICE_HEX), + expect(labelText(labelPeer(DEVICE_HEX, "Grace", "Ada", NO_PETNAMES))).toBe( + "Grace", ); + expect( + labelText(labelPeer(DEVICE_HEX, undefined, undefined, NO_PETNAMES)), + ).toBe(shortId(DEVICE_HEX)); }); }); From cfdcae1664d1bd387f2acaa6f0587a52005f6c92 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:24:42 +0100 Subject: [PATCH 6/7] fix(web-console): apply petname and self-name writes in the order they were made --- .../src/components/PeerNamesProvider.tsx | 27 +++++++---- .../test/PeerNamesProvider.unit.test.tsx | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx index 0b8fddec..6154e477 100644 --- a/ts/packages/web-console/src/components/PeerNamesProvider.tsx +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -1,6 +1,6 @@ // Holds the petnames loaded from the console's name store and the self display names observed in directories, and serves them through PeerNamesContext. -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "@mantine/core"; import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; @@ -29,17 +29,24 @@ export function PeerNamesProvider({ undefined, ); - /** Runs one storage operation and shows its failure to the person, since names are read and written from event handlers with no caller positioned to handle a rejection. */ + /** The tail of the chain of storage operations. Each write is followed by a re-read, and two overlapping ones could settle out of order and leave the older snapshot in state, so operations run one after another. Every link settles, so a failure never blocks the next. */ + const storageQueue = useRef>(Promise.resolve()); + + /** Queues one storage operation behind the earlier ones and shows its failure to the person, since names are read and written from event handlers with no caller positioned to handle a rejection. */ const reportFailure = useCallback( async (operation: () => Promise): Promise => { - try { - await operation(); - setStorageFailure(undefined); - } catch (error: unknown) { - setStorageFailure( - error instanceof Error ? error.message : String(error), - ); - } + const run = async (): Promise => { + try { + await operation(); + setStorageFailure(undefined); + } catch (error: unknown) { + setStorageFailure( + error instanceof Error ? error.message : String(error), + ); + } + }; + storageQueue.current = storageQueue.current.then(run); + await storageQueue.current; }, [], ); diff --git a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx index b6e005d9..3cb60179 100644 --- a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx +++ b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx @@ -2,11 +2,16 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; +import type { PeerNames } from "../src/hooks/use-peer-names.js"; import type { NameStore } from "../src/name-store.js"; import { WithNames, memoryNameStore } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; +const DEVICE_ID_BYTES = 32; +const DEVICE_HEX = "ab".repeat(DEVICE_ID_BYTES); +const SLOW_WRITE_MS = 30; const STORAGE_FAILURE = "the origin's storage quota is exhausted"; describe("PeerNamesProvider", () => { @@ -34,4 +39,43 @@ describe("PeerNamesProvider", () => { expect(await screen.findByText(STORAGE_FAILURE)).toBeInTheDocument(); expect(screen.getByText("child")).toBeInTheDocument(); }); + + it("applies overlapping renames in the order they were made, whatever order the storage answers in", async () => { + const inner = memoryNameStore(); + // The first write is slow, so an unserialised second rename would finish first and the first one would then land last and win. + let writes = 0; + const store: NameStore = { + ...inner, + setPetname: async (deviceHex, name) => { + writes += 1; + if (writes === 1) { + await new Promise((resolve) => { + setTimeout(resolve, SLOW_WRITE_MS); + }); + } + await inner.setPetname(deviceHex, name); + }, + }; + let names: PeerNames | undefined; + function Capture(): null { + names = usePeerNames(); + return null; + } + render( + + + , + ); + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + const first = names?.rename(DEVICE_HEX, "First"); + const second = names?.rename(DEVICE_HEX, "Second"); + await Promise.all([first, second]); + }); + + expect(names?.petnameOf(DEVICE_HEX)).toBe("Second"); + }); }); From f659e79286ecf800f0d8fbc9ccd91ab9550fe6e7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 09:50:21 +0100 Subject: [PATCH 7/7] test(web-console): render the local-network panel test inside the names context ConnectionPanel now reads peer names through PeerNamesProvider, so the local-network explanation test, written before names existed, has to mount the panel the way the app does. --- ts/packages/web-console/test/ConnectionPanel.unit.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx b/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx index 1c62ef69..9fd2bb68 100644 --- a/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx +++ b/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx @@ -3,13 +3,13 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; import { createMeshSession, type MeshSession, } from "wire-mesh-core/domain/mesh-session"; import type { Transport } from "wire-mesh-core/ports/transport"; import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js"; +import { WithNames } from "./names-harness.js"; import { ConnectionPanel } from "../src/components/ConnectionPanel.js"; import type { PermissionQuerier } from "../src/local-network.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; @@ -44,7 +44,7 @@ async function renderPanel( ): Promise { const session = await failingSession(); render( - + - , + , ); }