Merge pull request #41 from offendingcommit/feat/peer-display-name
feat(web): configurable peer display name
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 };
|
||||
|
||||
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 [];
|
||||
|
||||
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];
|
||||
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) {
|
||||
segments.push({ label: mask(subId), href: null, mono: true });
|
||||
segments.push({ label: subLabel, href: null, mono: subMono });
|
||||
return segments;
|
||||
}
|
||||
segments.push({
|
||||
label: mask(subId),
|
||||
label: subLabel,
|
||||
href: `/workspaces/${wid}/${section}/${subId}`,
|
||||
mono: true,
|
||||
mono: subMono,
|
||||
});
|
||||
|
||||
const subSection = parts[3];
|
||||
@@ -64,10 +74,10 @@ function buildSegments(pathname: string, mask: (v: string) => string): Segment[]
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function Breadcrumb() {
|
||||
export function Breadcrumb({ labels = {} }: { labels?: Record<string, string> } = {}) {
|
||||
const { state } = useRouter();
|
||||
const { mask } = useDemo();
|
||||
const segments = buildSegments(state.location.pathname, mask);
|
||||
const segments = buildSegments(state.location.pathname, mask, labels);
|
||||
|
||||
if (segments.length <= 1) return null;
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Save,
|
||||
Search,
|
||||
User,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
usePeerRepresentation,
|
||||
useSearchPeer,
|
||||
useSetPeerCard,
|
||||
useUpdatePeer,
|
||||
} from "@/api/queries";
|
||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||
import { Badge } from "@/components/shared/Badge";
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
import { DISPLAY_NAME_KEY, hasDisplayName, peerDisplayName } from "@/lib/peerDisplay";
|
||||
|
||||
export function PeerDetail() {
|
||||
const { mask } = useDemo();
|
||||
@@ -69,6 +73,20 @@ export function PeerDetail() {
|
||||
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
||||
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)
|
||||
?.configuration?.observe_me;
|
||||
|
||||
@@ -81,14 +99,54 @@ export function PeerDetail() {
|
||||
return (
|
||||
<div className="page-container page-container--xl">
|
||||
<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>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||
<PageTitle className="font-mono break-all">{mask(peerId)}</PageTitle>
|
||||
{observeMe !== undefined && (
|
||||
{nameDraft === null ? (
|
||||
<>
|
||||
<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
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
|
||||
style={{
|
||||
@@ -106,6 +164,9 @@ export function PeerDetail() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showsDisplayName && nameDraft === null && (
|
||||
<MonoCaption className="break-all">{mask(peerId)}</MonoCaption>
|
||||
)}
|
||||
<Body className="leading-none">Peer identity & memory</Body>
|
||||
</div>
|
||||
<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