Skip to content
180 changes: 97 additions & 83 deletions ts/packages/web-console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | undefined>;
// 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.
Expand Down Expand Up @@ -105,6 +111,7 @@ export function App({
messageStore,
roomStorage,
certificateMemory,
nameStore,
discoverLocalNode = discoverLocalNodeDefault,
defaultAddress = DEFAULT_ADDRESS,
}: Readonly<AppProps>): React.JSX.Element {
Expand Down Expand Up @@ -319,93 +326,100 @@ export function App({
}

return (
<Stack className={appShell} p="md" gap="lg">
<Title order={1}>wire-mesh console</Title>
<form onSubmit={handleSubmit}>
<Group align="flex-end" wrap="wrap">
<TextInput
label="Node"
value={address}
onChange={(event) => {
setAddress(event.currentTarget.value);
}}
required
style={{ flex: 1, minWidth: "16rem" }}
/>
<Button type="submit">Connect</Button>
</Group>
<Checkbox.Group
label="Domains offered"
value={domains}
onChange={setDomains}
>
<Group mt="xs">
{AVAILABLE_DOMAINS.map((domain) => (
<Checkbox key={domain} value={domain} label={domain} />
))}
<PeerNamesProvider store={nameStore}>
<Stack className={appShell} p="md" gap="lg">
<Title order={1}>wire-mesh console</Title>
<SelfNameField />
<form onSubmit={handleSubmit}>
<Group align="flex-end" wrap="wrap">
<TextInput
label="Node"
value={address}
onChange={(event) => {
setAddress(event.currentTarget.value);
}}
required
style={{ flex: 1, minWidth: "16rem" }}
/>
<Button type="submit">Connect</Button>
</Group>
</Checkbox.Group>
</form>
<DiscoveredPeersPanel
peers={discovered}
onConnect={(key) => {
resolveDiscovered(key, true);
}}
onDismiss={(key) => {
resolveDiscovered(key, false);
}}
/>
{connections.map((entry) => (
<ConnectionPanel
key={entry.address}
address={entry.address}
session={entry.session}
onClose={() => {
handleClose(entry);
<Checkbox.Group
label="Domains offered"
value={domains}
onChange={setDomains}
>
<Group mt="xs">
{AVAILABLE_DOMAINS.map((domain) => (
<Checkbox key={domain} value={domain} label={domain} />
))}
</Group>
</Checkbox.Group>
</form>
<DiscoveredPeersPanel
peers={discovered}
onConnect={(key) => {
resolveDiscovered(key, true);
}}
onMessagePeer={(device) => {
handleMessagePeer(entry, device);
onDismiss={(key) => {
resolveDiscovered(key, false);
}}
/>
))}
{failures.map((failure) => (
<Alert
key={failure.key}
color="yellow"
title={`No direct connection to ${failure.peer}`}
withCloseButton
onClose={() => {
setFailures((current) =>
current.filter((entry) => entry.key !== failure.key),
);
}}
>
{failure.reason}. Messages go through the hub instead.
</Alert>
))}
<ConversationList
conversations={roomMessaging.conversations}
selected={selectedConversation?.roomPath}
onSelect={setSelectedPath}
/>
{selectedConversation !== undefined && (
<RoomPanel
key={selectedConversation.roomPath}
view={selectedConversation}
onSend={async (text) =>
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) => (
<ConnectionPanel
key={entry.address}
address={entry.address}
session={entry.session}
onClose={() => {
handleClose(entry);
}}
onMessagePeer={(device) => {
handleMessagePeer(entry, device);
}}
/>
))}
{failures.map((failure) => (
<Alert
key={failure.key}
color="yellow"
title={
<>
No direct connection to <PeerName deviceHex={failure.peer} />
</>
}
withCloseButton
onClose={() => {
setFailures((current) =>
current.filter((entry) => entry.key !== failure.key),
);
}}
>
{failure.reason}. Messages go through the hub instead.
</Alert>
))}
<ConversationList
conversations={roomMessaging.conversations}
selected={selectedConversation?.roomPath}
onSelect={setSelectedPath}
/>
)}
</Stack>
{selectedConversation !== undefined && (
<RoomPanel
key={selectedConversation.roomPath}
view={selectedConversation}
onSend={async (text) =>
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);
}}
/>
)}
</Stack>
</PeerNamesProvider>
);
}
45 changes: 43 additions & 2 deletions ts/packages/web-console/src/components/ConnectionPanel.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string | undefined>(
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 ?? [];
Expand Down Expand Up @@ -139,6 +173,11 @@ export function ConnectionPanel({
{blocked}
</Alert>
)}
{publishFailure !== undefined && (
<Alert color="red" title="Could not publish your display name" mb="sm">
{publishFailure}
</Alert>
)}

<Text fw={600} size="sm">
Peer directory
Expand All @@ -160,7 +199,9 @@ export function ConnectionPanel({
<Table.Tbody>
{directory.map((entry) => (
<Table.Tr key={deviceHex(entry.device)}>
<Table.Td>{deviceHex(entry.device)}</Table.Td>
<Table.Td>
<PeerLabel deviceHex={deviceHex(entry.device)} />
</Table.Td>
<Table.Td>{entry.advert.addresses.join(", ")}</Table.Td>
<Table.Td>{entry.advert["snapshot-seconds"]}</Table.Td>
<Table.Td>
Expand Down
7 changes: 6 additions & 1 deletion ts/packages/web-console/src/components/ConversationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -14,6 +16,7 @@ export function ConversationList({
selected,
onSelect,
}: Readonly<ConversationListProps>): React.JSX.Element | null {
const names = usePeerNames();
if (conversations.length === 0) {
return null;
}
Expand All @@ -26,7 +29,9 @@ export function ConversationList({
<NavLink
key={conversation.roomPath}
active={conversation.roomPath === selected}
label={participantLabel(conversation)}
label={participantLabel(conversation, (hex) =>
labelText(names.labelOf(hex)),
)}
description={
conversation.status === "connected" ? undefined : "offline"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,7 +41,9 @@ export function DiscoveredPeersPanel({
<Table.Tbody>
{peers.map((peer) => (
<Table.Tr key={peer.key}>
<Table.Td>{deviceHex(peer.device)}</Table.Td>
<Table.Td>
<PeerLabel deviceHex={deviceHex(peer.device)} />
</Table.Td>
<Table.Td>{peer.addresses.join(", ")}</Table.Td>
<Table.Td>
<Group gap="xs">
Expand Down
Loading
Loading