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..9e565ede 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,18 +1,21 @@ // 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, useRef, useState } 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 +111,37 @@ 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. 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) 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 ?? []; const frameLog = event?.frameLog ?? []; @@ -139,6 +173,11 @@ export function ConnectionPanel({ {blocked} )} + {publishFailure !== undefined && ( + + {publishFailure} + + )} Peer directory @@ -160,7 +199,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..413c1c38 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -2,6 +2,8 @@ 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[]; @@ -14,6 +16,7 @@ export function ConversationList({ selected, onSelect, }: Readonly): React.JSX.Element | null { + const names = usePeerNames(); if (conversations.length === 0) { return null; } @@ -26,7 +29,9 @@ export function ConversationList({ + labelText(names.labelOf(hex)), + )} 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..25466706 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerLabel.tsx @@ -0,0 +1,126 @@ +// 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, + Badge, + 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.source === "self" && ( + + calls itself + + )} + + {label.primary} + + {label.matchesPetname && ( + + same as a name you set + + )} + {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..9bac52b3 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerName.tsx @@ -0,0 +1,14 @@ +// 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; +} + +export function PeerName({ + deviceHex, +}: Readonly): React.JSX.Element { + return <>{labelText(usePeerNames().labelOf(deviceHex))}; +} 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..6154e477 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -0,0 +1,146 @@ +// 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, 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"; +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); + const [storageFailure, setStorageFailure] = useState( + undefined, + ); + + /** 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 => { + 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; + }, + [], + ); + + useEffect(() => { + 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 => + reportFailure(async () => { + await store.setPetname(deviceHex, name); + setPetnames(await store.petnames()); + }), + [store, reportFailure], + ); + + const setSelfName = useCallback( + async (name: string): Promise => + reportFailure(async () => { + await store.setSelfName(name); + setOwnName(await store.selfName()); + }), + [store, reportFailure], + ); + + 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 (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); + } + } + return next ?? current; + }); + }, + [], + ); + + const heldPetnames = useMemo(() => new Set(petnames.values()), [petnames]); + + const value = useMemo( + () => ({ + labelOf: (deviceHex) => + labelPeer( + deviceHex, + petnames.get(deviceHex), + selfNames.get(deviceHex), + heldPetnames, + ), + petnameOf: (deviceHex) => petnames.get(deviceHex), + rename, + selfName, + setSelfName, + observeDirectory, + }), + [ + petnames, + heldPetnames, + selfNames, + selfName, + rename, + setSelfName, + observeDirectory, + ], + ); + + return ( + + {storageFailure !== undefined && ( + + {storageFailure} + + )} + {children} + + ); +} diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index a3cbea22..5a722d7b 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -3,6 +3,8 @@ 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"; @@ -36,6 +38,7 @@ export function RoomPanel({ onRetry, onDiscard, }: Readonly): React.JSX.Element { + const names = usePeerNames(); return ( @@ -82,7 +85,9 @@ export function RoomPanel({ + labelText(names.labelOf(hex)), + )} 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..7807a76d --- /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, cleanName } 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..66948d47 --- /dev/null +++ b/ts/packages/web-console/src/peer-names.ts @@ -0,0 +1,98 @@ +// 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; + /** 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 { + return deviceHex.slice(0, SHORT_ID_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, 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("").trimEnd(); +} + +/** 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 } }; +} + +/** 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 { + primary: petname, + secondary: + selfName !== undefined && selfName !== petname ? selfName : undefined, + source: "petname", + matchesPetname: false, + }; + } + if (selfName !== undefined) { + return { + primary: selfName, + secondary: shortId(deviceHex), + source: "self", + matchesPetname: heldPetnames.has(selfName), + }; + } + 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. */ +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/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index b7e3576f..49c703c4 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,49 @@ 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]; + }); + }); +} + +/** 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 = []; @@ -275,7 +350,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 +385,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 +404,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 +440,67 @@ 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"]); + }); + }); + + 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]); + }); + }); }); 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( - + - , + , ); } 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..e3225b94 --- /dev/null +++ b/ts/packages/web-console/test/PeerLabel.unit.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment jsdom + +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 { 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(); + }); + + 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); + }); + + 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/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/PeerNamesProvider.unit.test.tsx b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx new file mode 100644 index 00000000..3cb60179 --- /dev/null +++ b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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", () => { + 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(); + }); + + 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"); + }); +}); 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/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/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..bf50fa62 --- /dev/null +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; +import { + MAX_NAME_LENGTH, + SHORT_ID_LENGTH, + cleanName, + labelPeer, + labelText, + 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("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("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(); + }); +}); + +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); + }); +}); + +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", 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", 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", 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, 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", () => { + 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" }, + }); + }); +}); + +describe("labelText", () => { + it("puts the short id beside a self-asserted name", () => { + 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", NO_PETNAMES))).toBe( + "Grace", + ); + expect( + labelText(labelPeer(DEVICE_HEX, undefined, undefined, NO_PETNAMES)), + ).toBe(shortId(DEVICE_HEX)); + }); +});