feat(web): configurable peer display name
Human peers surface as raw ids (WhatsApp `…-lid`, UUIDs). Read an optional `display_name` from peer metadata and prefer it over the id in the peer header and breadcrumb, with the raw id kept as a sub-caption. An inline edit on the header writes `display_name` via useUpdatePeer (merging existing metadata); blank clears it. Falls back to the id when unset. Closes #32.
This commit is contained in:
@@ -15,7 +15,11 @@ const KNOWN_SECTIONS = new Set(Object.keys(SECTION_LABELS));
|
|||||||
|
|
||||||
type Segment = { label: string; href: string | null; mono?: boolean };
|
type Segment = { label: string; href: string | null; mono?: boolean };
|
||||||
|
|
||||||
function buildSegments(pathname: string, mask: (v: string) => string): Segment[] {
|
function buildSegments(
|
||||||
|
pathname: string,
|
||||||
|
mask: (v: string) => string,
|
||||||
|
labels: Record<string, string>,
|
||||||
|
): Segment[] {
|
||||||
if (!pathname.startsWith("/workspaces")) return [];
|
if (!pathname.startsWith("/workspaces")) return [];
|
||||||
|
|
||||||
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
|
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
|
||||||
@@ -46,14 +50,20 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
|
|||||||
const subId = parts[2];
|
const subId = parts[2];
|
||||||
if (!subId) return segments;
|
if (!subId) return segments;
|
||||||
|
|
||||||
|
// A friendly label override (e.g. a peer's display_name) renders in place of
|
||||||
|
// the raw id and drops the mono styling reserved for ids.
|
||||||
|
const override = labels[subId];
|
||||||
|
const subLabel = mask(override ?? subId);
|
||||||
|
const subMono = override === undefined;
|
||||||
|
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
segments.push({ label: mask(subId), href: null, mono: true });
|
segments.push({ label: subLabel, href: null, mono: subMono });
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
segments.push({
|
segments.push({
|
||||||
label: mask(subId),
|
label: subLabel,
|
||||||
href: `/workspaces/${wid}/${section}/${subId}`,
|
href: `/workspaces/${wid}/${section}/${subId}`,
|
||||||
mono: true,
|
mono: subMono,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subSection = parts[3];
|
const subSection = parts[3];
|
||||||
@@ -64,10 +74,10 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Breadcrumb() {
|
export function Breadcrumb({ labels = {} }: { labels?: Record<string, string> } = {}) {
|
||||||
const { state } = useRouter();
|
const { state } = useRouter();
|
||||||
const { mask } = useDemo();
|
const { mask } = useDemo();
|
||||||
const segments = buildSegments(state.location.pathname, mask);
|
const segments = buildSegments(state.location.pathname, mask, labels);
|
||||||
|
|
||||||
if (segments.length <= 1) return null;
|
if (segments.length <= 1) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Pencil,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
User,
|
User,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
usePeerRepresentation,
|
usePeerRepresentation,
|
||||||
useSearchPeer,
|
useSearchPeer,
|
||||||
useSetPeerCard,
|
useSetPeerCard,
|
||||||
|
useUpdatePeer,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { Badge } from "@/components/shared/Badge";
|
import { Badge } from "@/components/shared/Badge";
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { DISPLAY_NAME_KEY, hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
|
||||||
|
|
||||||
export function PeerDetail() {
|
export function PeerDetail() {
|
||||||
const { mask } = useDemo();
|
const { mask } = useDemo();
|
||||||
@@ -69,6 +73,20 @@ export function PeerDetail() {
|
|||||||
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const peerMeta = (peer as { metadata?: Record<string, unknown> } | undefined)?.metadata;
|
||||||
|
const displayName = peerDisplayName(peerMeta, peerId);
|
||||||
|
const showsDisplayName = hasDisplayName(peerMeta, peerId);
|
||||||
|
const updatePeer = useUpdatePeer(workspaceId, peerId);
|
||||||
|
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function saveDisplayName() {
|
||||||
|
const next = (nameDraft ?? "").trim();
|
||||||
|
const merged: Record<string, unknown> = { ...(peerMeta ?? {}) };
|
||||||
|
if (next) merged[DISPLAY_NAME_KEY] = next;
|
||||||
|
else delete merged[DISPLAY_NAME_KEY];
|
||||||
|
updatePeer.mutate({ metadata: merged }, { onSuccess: () => setNameDraft(null) });
|
||||||
|
}
|
||||||
|
|
||||||
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
|
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
|
||||||
?.configuration?.observe_me;
|
?.configuration?.observe_me;
|
||||||
|
|
||||||
@@ -81,14 +99,54 @@ export function PeerDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container page-container--xl">
|
<div className="page-container page-container--xl">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<Breadcrumb />
|
<Breadcrumb labels={{ [peerId]: displayName }} />
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<PageTitle className="font-mono break-all">{mask(peerId)}</PageTitle>
|
{nameDraft === null ? (
|
||||||
{observeMe !== undefined && (
|
<>
|
||||||
|
<PageTitle className={showsDisplayName ? "break-all" : "font-mono break-all"}>
|
||||||
|
{mask(displayName)}
|
||||||
|
</PageTitle>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNameDraft(showsDisplayName ? displayName : "")}
|
||||||
|
className="shrink-0 p-1 rounded-md transition-colors hover:bg-[color:var(--surface)]"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
title="Edit display name"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(e) => setNameDraft(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveDisplayName();
|
||||||
|
if (e.key === "Escape") setNameDraft(null);
|
||||||
|
}}
|
||||||
|
placeholder="Display name"
|
||||||
|
autoFocus
|
||||||
|
className="w-56"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="surface"
|
||||||
|
onClick={saveDisplayName}
|
||||||
|
disabled={updatePeer.isPending}
|
||||||
|
title="Save display name"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="surface" onClick={() => setNameDraft(null)} title="Cancel">
|
||||||
|
<X className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nameDraft === null && observeMe !== undefined && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
|
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
|
||||||
style={{
|
style={{
|
||||||
@@ -106,6 +164,9 @@ export function PeerDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showsDisplayName && nameDraft === null && (
|
||||||
|
<MonoCaption className="break-all">{mask(peerId)}</MonoCaption>
|
||||||
|
)}
|
||||||
<Body className="leading-none">Peer identity & memory</Body>
|
<Body className="leading-none">Peer identity & memory</Body>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
25
packages/web/src/lib/peerDisplay.ts
Normal file
25
packages/web/src/lib/peerDisplay.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a peer's friendly display name from its metadata, falling back to the
|
||||||
|
* raw peer id. Honcho peers are keyed by opaque ids (WhatsApp `…-lid`, UUIDs);
|
||||||
|
* a `display_name` metadata key lets the UI show something human-readable.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_NAME_KEY = "display_name";
|
||||||
|
|
||||||
|
export function peerDisplayName(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
fallbackId: string,
|
||||||
|
): string {
|
||||||
|
const value = metadata?.[DISPLAY_NAME_KEY];
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return fallbackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a peer has an explicit display name distinct from its id. */
|
||||||
|
export function hasDisplayName(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
peerId: string,
|
||||||
|
): boolean {
|
||||||
|
return peerDisplayName(metadata, peerId) !== peerId;
|
||||||
|
}
|
||||||
40
packages/web/src/test/peer-display.test.ts
Normal file
40
packages/web/src/test/peer-display.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
|
||||||
|
|
||||||
|
const PEER_ID = "22335577991-lid";
|
||||||
|
|
||||||
|
describe("peerDisplayName", () => {
|
||||||
|
it("returns the display_name when set", () => {
|
||||||
|
expect(peerDisplayName({ display_name: "Alice" }, PEER_ID)).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims surrounding whitespace", () => {
|
||||||
|
expect(peerDisplayName({ display_name: " Bob " }, PEER_ID)).toBe("Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when display_name is absent", () => {
|
||||||
|
expect(peerDisplayName({}, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when display_name is blank", () => {
|
||||||
|
expect(peerDisplayName({ display_name: " " }, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when display_name is not a string", () => {
|
||||||
|
expect(peerDisplayName({ display_name: 42 }, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the peer id when metadata is null", () => {
|
||||||
|
expect(peerDisplayName(null, PEER_ID)).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasDisplayName", () => {
|
||||||
|
it("is true when a distinct display name is set", () => {
|
||||||
|
expect(hasDisplayName({ display_name: "Alice" }, PEER_ID)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when no display name is set", () => {
|
||||||
|
expect(hasDisplayName({}, PEER_ID)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user