feat(ui): refine topology presentation (#326)

This commit is contained in:
AAA authored and GitHub committed 2026-08-29 05:19:12 +08:00
1 parent 3d92ddcf66
commit 050a20c4c7
9 files changed
+770 -323

No files matched your search

+59 -56
View File
@@ -68,74 +68,77 @@ export function Couch({
<rect x="44" y="62" width="552" height="50" rx="23" fill="var(--couch)" />
<path d="M212 64v46M428 64v46" stroke="var(--couch-dark)" strokeWidth="4" strokeLinecap="round" />
</svg>
<div
className={`lr-pawns${crowded ? " is-crowded" : ""}`}
role="group"
aria-label={t("common.viewers")}
>
{entries.map((entry, index) => {
const stateLabel = entry.connected
? t("state.peer.connected")
: (entry.statusLabel ?? t("state.peer.connecting"));
const label = entry.you
? `${entry.name} · ${t("common.you")}`
: `${entry.name} · ${stateLabel}`;
const showName = !vis && !entry.child && !crowded;
const inner = (
<>
<PawnSvg color={pawnColor(entry.key, entry.you)} />
<i
className={`lr-pawn-led${entry.connected ? "" : " is-wait"}`}
// With a name pill the default bottom:4px lands on the pill's
// right end; lift the LED onto the figure instead. Scoped here
// because the living-room stylesheet is owned elsewhere.
style={showName ? { bottom: 20 } : undefined}
aria-hidden="true"
/>
{showName ? (
// 58px ellipsizes the EN default "Visitor"; 72px fits it.
<span className="lr-pawn-name" style={{ maxWidth: 72 }}>
<div className="lr-pawns-window">
<div
className={`lr-pawns${crowded ? " is-crowded" : ""}`}
role="group"
aria-label={t("common.viewers")}
>
{entries.map((entry, index) => {
const stateLabel = entry.connected
? t("state.peer.connected")
: (entry.statusLabel ?? t("state.peer.connecting"));
const label = entry.you
? `${entry.name} · ${t("common.you")}`
: `${entry.name} · ${stateLabel}`;
const inner = (
<>
<PawnSvg color={pawnColor(entry.key, entry.you)} />
<i
className={`lr-pawn-led${entry.connected ? "" : " is-wait"}`}
aria-hidden="true"
/>
<span
className={`lr-pawn-name${entry.child ? " is-mini" : ""}`}
>
{entry.name}
</span>
) : null}
</>
);
// Hop stagger derives from position only, so render stays pure
// (a seen-keys map mutates during render: StrictMode's discarded
// first pass pre-fills it and the committed render gets 0ms).
const style = { animationDelay: `${Math.min(index, 12) * 70}ms` };
const className = `lr-pawn${entry.you ? " is-you" : ""}${entry.child ? " is-child" : ""}${
entry.selectable === false ? " is-static" : ""
}${selectedKey === entry.key ? " is-selected" : ""}`;
if (entry.selectable === false) {
</>
);
// Hop stagger derives from position only, so render stays pure.
const style = {
animationDelay: `${Math.min(index, 12) * 70}ms`,
};
const className = `lr-pawn${entry.you ? " is-you" : ""}${entry.child ? " is-child" : ""}${
entry.selectable === false ? " is-static" : ""
}${selectedKey === entry.key ? " is-selected" : ""}`;
if (entry.selectable === false) {
return (
<span
key={entry.key}
className={className}
style={style}
title={vis ? undefined : label}
aria-label={label}
>
{inner}
</span>
);
}
return (
<span
<button
key={entry.key}
type="button"
className={className}
style={style}
title={vis ? undefined : label}
aria-label={label}
aria-pressed={selectedKey === entry.key}
onClick={() => onSelect?.(entry.key)}
>
{inner}
</span>
</button>
);
}
return (
<button
key={entry.key}
type="button"
className={className}
style={style}
title={vis ? undefined : label}
aria-label={label}
aria-pressed={selectedKey === entry.key}
onClick={() => onSelect?.(entry.key)}
>
{inner}
</button>
);
})}
})}
</div>
</div>
<span
className="lr-couch-count"
aria-label={`${t("common.viewers")} ${entries.length}`}
>
<Glyph name="users" size={14} />
<b>{entries.length}</b>
</span>
{entries.length === 0 ? (
<div
className="lr-couch-empty"
+303 -181
View File
@@ -1,11 +1,19 @@
// The connection topology as a real tree: host (crowned) roots direct P2P
// viewers and the SFU node; relay children hang off their parent viewer.
// Data comes from deriveParticipantTopology.
import { memo } from "react";
import { PawnSvg, pawnColor } from "./Couch";
import { useCopy } from "../../ui/copy";
import { memo, useSyncExternalStore } from "react";
import type { LabeledViewerPresence } from "../../lib/viewer-presence";
import { deriveParticipantTopology, type TopologyBranch } from "../../lib/participant-topology";
import {
deriveParticipantTopology,
type TopologyBranch,
} from "../../lib/participant-topology";
import { useCopy } from "../../ui/copy";
import { PawnSvg, pawnColor } from "./Couch";
import {
topologyLayoutForViewport,
type TopologyLayout,
} from "./route-tree-layout";
interface TreeNode {
key: string;
@@ -13,15 +21,58 @@ interface TreeNode {
via: string | null;
sfu: boolean;
you: boolean;
ready: boolean;
}
const HOST_X = 40;
const COLUMN_GAP = 190;
const ROW_BASE = 40;
const PENDING_COLOR = "#d98e04";
const NARROW_TOPOLOGY_QUERY = "(max-width: 640px)";
function xForDepth(depth: number): number {
return HOST_X + (depth + 1) * COLUMN_GAP;
function subscribeToNarrowViewport(listener: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const query = window.matchMedia(NARROW_TOPOLOGY_QUERY);
query.addEventListener("change", listener);
return () => query.removeEventListener("change", listener);
}
function narrowViewportSnapshot(): boolean {
return (
typeof window !== "undefined" &&
Boolean(window.matchMedia?.(NARROW_TOPOLOGY_QUERY).matches)
);
}
function useNarrowViewport(): boolean {
return useSyncExternalStore(
subscribeToNarrowViewport,
narrowViewportSnapshot,
() => false,
);
}
function compactVisibleLabel(label: string, maximumCodePoints: number): string {
const codePoints = Array.from(label);
if (codePoints.length <= maximumCodePoints) return label;
const suffix = label.match(/ \([^()]+\)$/u)?.[0] ?? null;
if (suffix) {
const suffixCodePoints = Array.from(suffix);
const nameCodePoints = Array.from(label.slice(0, -suffix.length));
const visibleNameLength = Math.max(
0,
maximumCodePoints - suffixCodePoints.length - 1,
);
return `${nameCodePoints.slice(0, visibleNameLength).join("")}…${suffix}`;
}
return `${codePoints.slice(0, maximumCodePoints - 1).join("")}…`;
}
function xForDepth(depth: number, layout: TopologyLayout): number {
return layout.hostX + (depth + 1) * layout.columnGap;
}
function edgeClass(kind: "p2p" | "sfu", ready = true): string {
return `lr-route-edge is-${kind}${ready ? "" : " is-recovering"}`;
}
export const RouteTree = memo(function RouteTree({
@@ -29,15 +80,17 @@ export const RouteTree = memo(function RouteTree({
hostLabel,
viewers,
selfPeerId,
flowing = false,
selectedPeerId,
}: {
hostPeerId: string | null;
hostLabel: string;
viewers: readonly LabeledViewerPresence[];
selfPeerId?: string | null;
flowing?: boolean;
selectedPeerId?: string | null;
}) {
const { t, vis } = useCopy();
const { t } = useCopy();
const narrowViewport = useNarrowViewport();
const layoutConfig = topologyLayoutForViewport(narrowViewport);
const topology = deriveParticipantTopology(hostPeerId, viewers);
const nodes: TreeNode[] = [];
@@ -48,8 +101,11 @@ export const RouteTree = memo(function RouteTree({
via,
sfu,
you: selfPeerId === branch.viewer.peerId,
ready: branch.viewer.mediaReady === true,
});
branch.children.forEach((child) => collect(child, branch.viewer.peerId, false));
branch.children.forEach((child) =>
collect(child, branch.viewer.peerId, false),
);
};
topology.peerRoots.forEach((root) => collect(root, null, false));
topology.sfuRoots.forEach((root) => collect(root, null, true));
@@ -65,258 +121,324 @@ export const RouteTree = memo(function RouteTree({
else if (node.sfu) add("sfu", node);
else add(null, node);
}
const hasSfu = nodes.some((n) => n.sfu);
const spacing = nodes.length > 10 ? 32 : 42;
const spacing = nodes.length > 10 ? 42 : 44;
let row = 0;
const pos = new Map<string, { x: number; y: number }>();
function layout(node: TreeNode, depth: number): void {
const kids = childrenOf.get(node.key) ?? [];
const children = childrenOf.get(node.key) ?? [];
let y: number;
if (kids.length === 0) {
if (children.length === 0) {
y = ROW_BASE + row * spacing;
row += 1;
} else {
kids.forEach((kid) => layout(kid, depth + 1));
y = kids.reduce((sum, kid) => sum + pos.get(kid.key)!.y, 0) / kids.length;
children.forEach((child) => layout(child, depth + 1));
y =
children.reduce((sum, child) => sum + pos.get(child.key)!.y, 0) /
children.length;
}
pos.set(node.key, { x: xForDepth(depth), y });
}
(childrenOf.get(null) ?? []).forEach((node) => layout(node, 0));
let sfuPos: { x: number; y: number } | null = null;
if (hasSfu) {
const sfuKids = (childrenOf.get("sfu") ?? []);
sfuKids.forEach((node) => layout(node, 1));
sfuPos = {
x: xForDepth(0),
y: sfuKids.reduce((sum, kid) => sum + pos.get(kid.key)!.y, 0) / sfuKids.length,
};
pos.set(node.key, { x: xForDepth(depth, layoutConfig), y });
}
// Pending viewers (upstream not yet in the tree) dangle off the host as
// dashed amber edges — visible in every language mode, not just text.
(childrenOf.get(null) ?? []).forEach((node) => layout(node, 0));
const sfuChildren = childrenOf.get("sfu") ?? [];
sfuChildren.forEach((node) => layout(node, 1));
let sfuPos: { x: number; y: number } | null =
sfuChildren.length > 0
? {
x: xForDepth(0, layoutConfig),
y:
sfuChildren.reduce(
(sum, child) => sum + pos.get(child.key)!.y,
0,
) / sfuChildren.length,
}
: null;
const pendingPos = topology.pending.map((viewer) => {
const p = { x: xForDepth(0), y: ROW_BASE + row * spacing };
const point = {
x: xForDepth(0, layoutConfig),
y: ROW_BASE + row * spacing,
};
row += 1;
return { viewer, ...p };
return { viewer, ...point };
});
const height = Math.max(140, row * spacing + 64);
const rootYs = [
...(childrenOf.get(null) ?? []).map((node) => pos.get(node.key)!.y),
...(sfuPos ? [sfuPos.y] : []),
...pendingPos.map((p) => p.y),
...pendingPos.map((point) => point.y),
];
const hostPos = {
x: HOST_X,
x: layoutConfig.hostX,
y:
rootYs.length > 0
? rootYs.reduce((a, b) => a + b, 0) / rootYs.length
? rootYs.reduce((sum, y) => sum + y, 0) / rootYs.length
: height / 2,
};
const width = Math.max(
640,
...[...pos.values(), ...pendingPos, ...(sfuPos ? [sfuPos] : [])].map(
({ x }) => x + 70,
),
layoutConfig.baseWidth,
...[
...pos.values(),
...pendingPos,
...(sfuPos ? [sfuPos] : []),
].map(({ x }) => x + layoutConfig.rightLabelReserve),
);
// Center the whole composition vertically inside the panel.
const allYs = [
...[...pos.values()].map((p) => p.y),
...[...pos.values()].map((point) => point.y),
...(sfuPos ? [sfuPos.y] : []),
...pendingPos.map((p) => p.y),
...pendingPos.map((point) => point.y),
hostPos.y,
];
const minY = Math.min(...allYs) - 26;
const maxY = Math.max(...allYs) + (vis ? 22 : 34);
const maxY = Math.max(...allYs) + 34;
const shift = Math.max(0, (height - (maxY - minY)) / 2 - minY + 4);
if (shift > 0) {
pos.forEach((p) => {
p.y += shift;
pos.forEach((point) => {
point.y += shift;
});
pendingPos.forEach((p) => {
p.y += shift;
pendingPos.forEach((point) => {
point.y += shift;
});
if (sfuPos) sfuPos.y += shift;
hostPos.y += shift;
}
const flowClass = flowing ? "flow" : undefined;
const labelText = (name: string, x: number, y: number) =>
vis ? null : (
<text x={x} y={y} textAnchor="middle">
{name}
</text>
);
const pendingLabel = `${t("host.topology.pending")}: ${topology.pending
.map((viewer) => viewer.label)
.join(", ")}`;
const srSummary = [
`${t("common.host")} ${hostLabel}`,
...nodes.map(
(node) =>
`${node.label} (${t(node.sfu ? "state.route.sfu" : "state.route.p2p")})`,
),
...topology.pending.map(
(viewer) => `${viewer.label} (${t("host.topology.pending")})`,
),
].join(" · ");
const labelByPeer = new Map(nodes.map((node) => [node.key, node.label]));
const topologyTitleId = "room-topology-title";
const scrollableCanvas = width > layoutConfig.baseWidth;
const sfuChildPoints = sfuChildren.map((child) => ({
child,
point: pos.get(child.key)!,
}));
const sfuRail =
sfuPos && sfuChildPoints.length > 1
? {
x: sfuPos.x + layoutConfig.columnGap * 0.45,
minY: Math.min(...sfuChildPoints.map(({ point }) => point.y)),
maxY: Math.max(...sfuChildPoints.map(({ point }) => point.y)),
}
: null;
return (
<div
className="lr-route"
role="group"
aria-label={`${t("host.topology")}: ${srSummary}`}
aria-labelledby={topologyTitleId}
id="room-topology"
tabIndex={0}
>
<span id={topologyTitleId} className="visually-hidden">
{t("host.topology")}
</span>
<svg
viewBox={`0 0 ${width} ${height}`}
style={{ width, minWidth: "100%", maxWidth: "none" }}
aria-hidden="true"
focusable="false"
style={
narrowViewport || scrollableCanvas
? { width, maxWidth: "none" }
: {
width: "100%",
maxWidth: nodes.length > 10 ? layoutConfig.baseWidth : 880,
}
}
>
{/* In vis mode <title> doubles as a native hover tooltip, leaking
human-language UI text into the zero-text mode; accessible names
ride on aria-label instead, which never becomes hover text. */}
{vis ? null : <title>{t("host.topology")}</title>}
{/* edges first, nodes on top */}
{nodes.map((node) => {
const p = pos.get(node.key)!;
const parentNode = node.via ? pos.get(node.via) : undefined;
const parent = parentNode ?? (node.sfu && sfuPos ? sfuPos : hostPos);
const color = node.sfu ? "#8ea3b8" : "#2fa66a";
return (
{nodes
.filter((node) => !node.sfu)
.map((node) => {
const point = pos.get(node.key)!;
const parent = node.via ? pos.get(node.via)! : hostPos;
return (
<path
key={`edge-${node.key}`}
d={`M ${parent.x + 20} ${parent.y} Q ${(parent.x + point.x) / 2} ${parent.y + (point.y - parent.y) * 0.55}, ${point.x - 18} ${point.y}`}
className={edgeClass("p2p", node.ready)}
/>
);
})}
{sfuPos ? (
<>
<path
key={`edge-${node.key}`}
d={`M ${parent.x + 20} ${parent.y} Q ${(parent.x + p.x) / 2} ${parent.y + (p.y - parent.y) * 0.55}, ${p.x - 18} ${p.y}`}
fill="none"
stroke={color}
strokeWidth={2.5}
className={flowClass}
d={`M ${hostPos.x + 20} ${hostPos.y} L ${sfuPos.x - 20} ${sfuPos.y}`}
className={edgeClass("sfu")}
/>
);
})}
{pendingPos.map((p) => (
{sfuChildren.length === 1 ? (
<path
d={`M ${sfuPos.x + 20} ${sfuPos.y} L ${pos.get(sfuChildren[0]!.key)!.x - 18} ${pos.get(sfuChildren[0]!.key)!.y}`}
className={edgeClass("sfu", sfuChildren[0]!.ready)}
/>
) : sfuRail ? (
<>
<path
d={`M ${sfuPos.x + 20} ${sfuPos.y} H ${sfuRail.x}`}
className={edgeClass("sfu")}
/>
<path
d={`M ${sfuRail.x} ${sfuRail.minY} V ${sfuRail.maxY}`}
className={edgeClass("sfu")}
/>
{sfuChildPoints.map(({ child, point }) => (
<path
key={`edge-${child.key}`}
d={`M ${sfuRail.x} ${point.y} H ${point.x - 18}`}
className={edgeClass("sfu", child.ready)}
/>
))}
</>
) : null}
</>
) : null}
{pendingPos.map((point) => (
<path
key={`edge-pending-${p.viewer.peerId}`}
d={`M ${hostPos.x + 20} ${hostPos.y} Q ${(hostPos.x + p.x) / 2} ${hostPos.y + (p.y - hostPos.y) * 0.55}, ${p.x - 18} ${p.y}`}
fill="none"
stroke={PENDING_COLOR}
strokeWidth={2.5}
strokeDasharray="6 7"
className={flowClass}
key={`edge-pending-${point.viewer.peerId}`}
d={`M ${hostPos.x + 20} ${hostPos.y} Q ${(hostPos.x + point.x) / 2} ${hostPos.y + (point.y - hostPos.y) * 0.55}, ${point.x - 18} ${point.y}`}
className="lr-route-edge is-pending"
/>
))}
{sfuPos ? (
<path
d={`M ${hostPos.x + 20} ${hostPos.y} L ${sfuPos.x - 20} ${sfuPos.y}`}
fill="none"
stroke="#8ea3b8"
strokeWidth={2.5}
className={flowClass}
/>
) : null}
<g
className="lr-route-node is-host"
transform={`translate(${hostPos.x - 4}, ${hostPos.y - 20}) scale(0.85)`}
role={vis ? "img" : undefined}
aria-label={vis ? `${hostLabel} · ${t("common.host")}` : undefined}
>
{vis ? null : <title>{`${hostLabel} · ${t("common.host")}`}</title>}
<PawnSvg color="var(--couch)" crown />
</g>
{labelText(hostLabel, hostPos.x + 14, hostPos.y + 30)}
<text
className="lr-route-label is-host"
x={hostPos.x + 14}
y={hostPos.y + 30}
textAnchor="middle"
>
{compactVisibleLabel(
hostLabel,
layoutConfig.maxVisibleLabelCodePoints,
)}
</text>
{sfuPos ? (
<g
transform={`translate(${sfuPos.x - 20}, ${sfuPos.y - 16})`}
role={vis ? "img" : undefined}
aria-label={vis ? t("host.sfu") : undefined}
>
{vis ? null : <title>{t("host.sfu")}</title>}
<rect width="40" height="32" rx="7" fill="none" stroke="#8ea3b8" strokeWidth="2.5" />
<path d="M8 12h24M8 20h24" stroke="#8ea3b8" strokeWidth="2.5" strokeLinecap="round" />
</g>
<>
<g
className="lr-route-sfu"
transform={`translate(${sfuPos.x - 20}, ${sfuPos.y - 16})`}
>
<rect width="40" height="32" rx="7" />
<path d="M8 12h24M8 20h24" />
</g>
<text
className="lr-route-label is-sfu"
x={sfuPos.x}
y={sfuPos.y + 32}
textAnchor="middle"
>
SFU
</text>
</>
) : null}
{sfuPos ? labelText(t("host.sfu"), sfuPos.x, sfuPos.y + 32) : null}
{nodes.map((node) => {
const p = pos.get(node.key)!;
const isChild = node.via !== null && !node.sfu;
const nodeLabel = node.you ? `${node.label} · ${t("common.you")}` : node.label;
const point = pos.get(node.key)!;
const child = node.via !== null && !node.sfu;
const selected = selectedPeerId === node.key;
return (
<g
key={node.key}
transform={`translate(${p.x - 14}, ${p.y - 15}) scale(${isChild ? 0.52 : 0.72})`}
role={vis ? "img" : undefined}
aria-label={vis ? nodeLabel : undefined}
className={`lr-route-node${node.ready ? "" : " is-recovering"}${selected ? " is-selected" : ""}`}
transform={`translate(${point.x - 14}, ${point.y - 15}) scale(${child ? 0.52 : 0.72})`}
>
{vis ? null : <title>{nodeLabel}</title>}
<PawnSvg color={pawnColor(node.key, node.you)} />
{selected ? (
<circle className="lr-route-selection" cx="20" cy="26" r="25" />
) : null}
{node.you ? (
<circle cx="20" cy="26" r="22" fill="none" stroke="var(--you)" strokeWidth="3" />
<circle className="lr-route-you" cx="20" cy="26" r="21" />
) : null}
</g>
);
})}
{pendingPos.map((p) => (
{pendingPos.map((point) => (
<g
key={`pending-${p.viewer.peerId}`}
transform={`translate(${p.x - 14}, ${p.y - 15}) scale(0.72)`}
opacity={0.55}
role={vis ? "img" : undefined}
aria-label={vis ? `${p.viewer.label} · ${t("host.topology.pending")}` : undefined}
key={`pending-${point.viewer.peerId}`}
className={`lr-route-node is-recovering${selectedPeerId === point.viewer.peerId ? " is-selected" : ""}`}
transform={`translate(${point.x - 14}, ${point.y - 15}) scale(0.72)`}
>
{vis ? null : <title>{`${p.viewer.label} · ${t("host.topology.pending")}`}</title>}
<PawnSvg color={pawnColor(p.viewer.peerId, selfPeerId === p.viewer.peerId)} />
<PawnSvg
color={pawnColor(
point.viewer.peerId,
selfPeerId === point.viewer.peerId,
)}
/>
{selectedPeerId === point.viewer.peerId ? (
<circle className="lr-route-selection" cx="20" cy="26" r="25" />
) : null}
{selfPeerId === point.viewer.peerId ? (
<circle className="lr-route-you" cx="20" cy="26" r="21" />
) : null}
</g>
))}
{vis
? null
: nodes.map((node) => {
const p = pos.get(node.key)!;
const isChild = node.via !== null && !node.sfu;
return (
<text key={`label-${node.key}`} x={p.x + 4} y={p.y + (isChild ? 20 : 26)} textAnchor="middle">
{node.label}
</text>
);
})}
{vis
? null
: pendingPos.map((p) => (
<text
key={`label-pending-${p.viewer.peerId}`}
x={p.x + 4}
y={p.y + 26}
textAnchor="middle"
opacity={0.7}
>
{p.viewer.label}
</text>
))}
</svg>
<ul className="visually-hidden">
{nodes.map((node) => (
<li key={`sr-${node.key}`}>
{node.label} {node.sfu ? "SFU" : "P2P"}
</li>
{nodes.map((node) => {
const point = pos.get(node.key)!;
const child = node.via !== null && !node.sfu;
return (
<text
key={`label-${node.key}`}
className={`lr-route-label${selectedPeerId === node.key ? " is-selected" : ""}`}
x={point.x + 4}
y={point.y + (child ? 20 : 26)}
textAnchor="middle"
>
{compactVisibleLabel(
node.label,
layoutConfig.maxVisibleLabelCodePoints,
)}
</text>
);
})}
{pendingPos.map((point) => (
<text
key={`label-pending-${point.viewer.peerId}`}
className={`lr-route-label is-recovering${selectedPeerId === point.viewer.peerId ? " is-selected" : ""}`}
x={point.x + 4}
y={point.y + 26}
textAnchor="middle"
>
{compactVisibleLabel(
point.viewer.label,
layoutConfig.maxVisibleLabelCodePoints,
)}
</text>
))}
</svg>
<ul className="visually-hidden">
<li>
{hostLabel} · {t("common.host")}
</li>
{nodes.map((node) => {
const parentLabel = node.via
? (labelByPeer.get(node.via) ?? hostLabel)
: node.sfu
? "SFU"
: hostLabel;
return (
<li key={`sr-${node.key}`}>
{node.label} ← {parentLabel} ·{" "}
{t(node.sfu ? "state.route.sfu" : "state.route.p2p")}
{node.ready ? "" : ` · ${t("host.topology.pending")}`}
</li>
);
})}
{topology.pending.map((viewer) => (
<li key={`sr-pending-${viewer.peerId}`}>
{viewer.label} {t("host.topology.pending")}
{viewer.label} · {t("host.topology.pending")}
</li>
))}
</ul>
{topology.pending.length > 0 ? (
vis ? (
<span className="visually-hidden">{pendingLabel}</span>
) : (
<div className="lr-status-text" style={{ padding: "4px 6px" }}>
{pendingLabel}
</div>
)
) : null}
</div>
);
});
@@ -0,0 +1,27 @@
export interface TopologyLayout {
baseWidth: number;
hostX: number;
columnGap: number;
rightLabelReserve: number;
maxVisibleLabelCodePoints: number;
}
const DESKTOP_LAYOUT: TopologyLayout = {
baseWidth: 640,
hostX: 170,
columnGap: 220,
rightLabelReserve: 170,
maxVisibleLabelCodePoints: 16,
};
const NARROW_LAYOUT: TopologyLayout = {
baseWidth: 260,
hostX: 54,
columnGap: 130,
rightLabelReserve: 65,
maxVisibleLabelCodePoints: 10,
};
export function topologyLayoutForViewport(narrow: boolean): TopologyLayout {
return narrow ? NARROW_LAYOUT : DESKTOP_LAYOUT;
}
+1 -1
View File
@@ -3601,7 +3601,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
hostPeerId={hostPeerId}
hostLabel={labeledHostPresence?.label ?? displayName}
viewers={viewers}
flowing={phase === "starting"}
selectedPeerId={selectedPawn}
/>
</Row>
) : null}
+22 -38
View File
@@ -42,7 +42,6 @@ import {
NameTag,
Pill,
Row,
RowGroup,
StatusText,
} from "../components/living/primitives";
import { Glyph, type GlyphName } from "../ui/icons";
@@ -137,16 +136,6 @@ interface RemoteMediaBinding {
videoTrackKey: string;
}
const CONNECTING_STAGES: readonly ViewerStage[] = [
"joining",
"preparing-p2p",
"preparing-sfu",
"waiting-sfu",
"receiving",
"allocating",
"recovering",
];
function stageOverlayGlyph(stage: ViewerStage): { icon: GlyphName; spin: boolean } {
switch (stage) {
case "needs-play":
@@ -299,17 +288,11 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
),
[peerSnapshot, relaySnapshot],
);
const hostDisplayName = useMemo(
() =>
participantPresence?.find(
(participant) => participant.role === "host",
)?.displayName ?? null,
[participantPresence],
);
const { host: labeledHostPresence, viewers } = useMemo(
() => labelParticipantSnapshot(participantPresence ?? []),
[participantPresence],
);
const hostDisplayName = labeledHostPresence?.label ?? null;
function clearParticipantPresence(): void {
setParticipantPresence(null);
@@ -2367,25 +2350,26 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
onClose={() => setSelectedPawn(null)}
/>
) : null}
<Row>
<RowGroup>
<div className="lr-row lr-viewer-summary-row">
<div className="lr-row-group lr-viewer-room-slot">
<FieldCap k="common.roomCode" />
<Lcd code={roomId} />
</RowGroup>
<RowGroup>
<StatusText>
{hostDisplayName
? `${t("viewer.title", { name: hostDisplayName })} · `
: ""}
{t(presentation.messageKey)}
{participantPresence
? ` · ${t("viewer.onlineCount", { n: String(viewers.length) })}`
: ""}
</StatusText>
</RowGroup>
<span className="lr-spacer" />
</div>
<div
className="lr-row-group lr-viewer-host-slot"
aria-label={t(
hostDisplayName ? "viewer.title" : "viewer.titleFallback",
hostDisplayName ? { name: hostDisplayName } : undefined,
)}
>
<Glyph name="tv" size={17} />
<b>{hostDisplayName ?? t("common.host")}</b>
</div>
<div className="lr-row-group lr-viewer-state-slot">
<StatusText>{t(presentation.messageKey)}</StatusText>
</div>
<form
className="lr-row-group lr-group-name"
className="lr-row-group lr-group-name lr-viewer-self-slot"
onSubmit={(event) => {
event.preventDefault();
commitDisplayName();
@@ -2448,7 +2432,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
<Pill icon="alert" tone="bad" label={t("host.nameError")} alert />
)}
</form>
<RowGroup actions>
<div className="lr-row-group lr-group-actions lr-viewer-actions-slot">
{labeledHostPresence
? hintWrap(
"hint-topology",
@@ -2492,8 +2476,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
}
onClick={() => setShowConnectionDetails((current) => !current)}
/>
</RowGroup>
</Row>
</div>
</div>
{presentation.noticeKey ? (
<Pill icon="alert" label={t(presentation.noticeKey)} />
) : null}
@@ -2552,7 +2536,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
hostLabel={labeledHostPresence.label}
viewers={viewers}
selfPeerId={selfPeerId}
flowing={CONNECTING_STAGES.includes(presentation.stage)}
selectedPeerId={selectedPawn}
/>
</Row>
) : null}
+133 -18
View File
@@ -27,6 +27,9 @@
--live: #2fa66a;
--warn: #d98e04;
--danger: #d64541;
--route-p2p: #1f7a4a;
--route-sfu: #53676a;
--route-pending: #875400;
--lcd: #1d2b22;
--lcd-digits: #9de8bf;
--stage: #101a2c;
@@ -50,6 +53,9 @@ html[data-theme="dark"] {
--live: #3fb97a;
--warn: #e8a33d;
--danger: #e25a52;
--route-p2p: #3fb97a;
--route-sfu: #9bb0c5;
--route-pending: #e8a33d;
--lcd: #0d1611;
}
@@ -345,20 +351,30 @@ ul { margin: 0; padding: 0; list-style: none; }
margin: 22px auto 0;
}
.lr-couch > svg { display: block; width: 100%; height: auto; }
.lr-pawns {
.lr-pawns-window {
position: absolute;
inset: 0 8% auto;
top: -16px;
top: -35px;
overflow-x: auto;
overflow-y: hidden;
padding: 20px 12px 10px;
scrollbar-color: var(--route-sfu) transparent;
scrollbar-width: thin;
}
.lr-pawns {
display: flex;
flex-wrap: wrap;
width: max-content;
min-width: 100%;
flex-wrap: nowrap;
justify-content: center;
align-items: start;
gap: 2px 10px;
padding: 0 12px;
}
.lr-pawn {
position: relative;
display: grid;
width: 44px;
width: max-content;
min-width: 44px;
place-items: center;
padding: 0;
border: 0;
@@ -370,7 +386,8 @@ ul { margin: 0; padding: 0; list-style: none; }
.lr-pawn svg { width: 40px; height: 48px; display: block; }
.lr-pawn-led {
position: absolute;
top: 2px; right: 0;
top: 2px;
left: calc(50% + 10px);
width: 10px; height: 10px;
border: 2px solid var(--wall);
border-radius: 50%;
@@ -397,14 +414,13 @@ ul { margin: 0; padding: 0; list-style: none; }
border: 2.5px solid var(--action);
border-radius: 13px;
}
.lr-pawn.is-child { width: 34px; margin-left: -8px; align-self: end; }
.lr-pawn.is-child { min-width: 34px; align-self: end; }
.lr-pawn.is-child svg { width: 30px; height: 36px; }
.lr-pawns.is-crowded { gap: 6px 3px; top: -14px; }
.lr-pawns.is-crowded .lr-pawn { width: 33px; }
.lr-pawns.is-crowded { gap: 6px; }
.lr-pawns.is-crowded .lr-pawn { min-width: 33px; }
.lr-pawns.is-crowded .lr-pawn svg { width: 29px; height: 35px; }
.lr-pawns.is-crowded .lr-pawn-led { width: 8px; height: 8px; }
.lr-pawn-name {
max-width: 58px;
margin-top: -4px;
padding: 1px 6px;
border-radius: 7px;
@@ -412,12 +428,27 @@ ul { margin: 0; padding: 0; list-style: none; }
color: var(--ink);
font-size: 10px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 1px 0 rgba(0, 0, 0, .12);
}
.lr-pawn-name.is-mini { font-size: 9px; max-width: 40px; padding: 0 5px; }
.lr-pawn-name.is-mini { font-size: 9px; padding: 0 5px; }
.lr-couch-count {
position: absolute;
right: 4%;
bottom: 8px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border: 2px solid var(--ink);
border-radius: 999px;
background: var(--paper);
color: var(--ink);
font-family: var(--mono);
font-size: 11px;
line-height: 1;
}
.lr-couch-count b { font-size: inherit; }
.lr-couch-empty {
position: absolute;
inset: -8px 0 auto;
@@ -451,6 +482,37 @@ ul { margin: 0; padding: 0; list-style: none; }
.lr-row.is-sub { background: var(--wall-2); border-color: transparent; }
.lr-row-group { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-width: 0; }
.lr-group-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.lr-viewer-summary-row {
display: grid;
grid-template-columns: auto minmax(140px, 220px) minmax(220px, 1fr);
}
.lr-viewer-room-slot { grid-column: 1; grid-row: 1; }
.lr-viewer-host-slot {
grid-column: 2;
grid-row: 1;
max-width: 220px;
flex-wrap: nowrap;
font-size: 13px;
}
.lr-viewer-host-slot svg { flex: 0 0 auto; color: #53676a; }
html[data-theme="dark"] .lr-viewer-host-slot svg { color: #9bb0c5; }
.lr-viewer-host-slot b {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.25;
}
.lr-viewer-state-slot { grid-column: 3; grid-row: 1; }
.lr-viewer-self-slot {
display: grid;
grid-column: 1 / 3;
grid-row: 2;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.lr-viewer-actions-slot {
grid-column: 3;
grid-row: 2;
justify-self: end;
}
.lr-spacer { flex: 1; }
.lr-divider { width: 2.5px; align-self: stretch; border-radius: 2px; background: var(--wall-2); }
.lr-row.is-sub .lr-divider { background: #c3d4cb; }
@@ -771,10 +833,49 @@ html[data-theme="dark"] .lr-meter-cell svg { color: #7d92a8; }
overflow-x: auto;
padding: 6px 2px;
box-sizing: border-box;
overscroll-behavior-inline: contain;
scrollbar-color: var(--route-sfu) transparent;
scrollbar-width: thin;
}
.lr-route svg { display: block; width: 100%; max-width: 880px; height: auto; margin: 0 auto; }
.lr-route text { fill: var(--ink); font-size: 11px; font-weight: 600; opacity: .72; paint-order: stroke; stroke: var(--paper); stroke-width: 3px; }
.lr-route .flow { stroke-dasharray: 5 7; animation: lr-flow 1.1s linear infinite; }
.lr-route:focus-visible { border-radius: 10px; }
.lr-route-edge {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.lr-route-edge.is-p2p { stroke: var(--route-p2p); }
.lr-route-edge.is-sfu { stroke: var(--route-sfu); }
.lr-route-edge.is-pending,
.lr-route-edge.is-recovering {
stroke: var(--route-pending);
stroke-dasharray: 6 7;
animation: lr-flow 1.1s linear infinite;
}
.lr-route-node { transition: opacity 180ms ease; }
.lr-route-node.is-recovering { opacity: .58; }
.lr-route-selection,
.lr-route-you { fill: none; vector-effect: non-scaling-stroke; }
.lr-route-selection { stroke: var(--action); stroke-width: 3; }
.lr-route-you { stroke: var(--route-p2p); stroke-width: 2.5; stroke-dasharray: 4 3; }
.lr-route-sfu { fill: none; stroke: var(--route-sfu); stroke-width: 2.5; }
.lr-route-sfu path { stroke-linecap: round; }
.lr-route-label {
fill: var(--ink);
font-size: 11px;
font-weight: 650;
opacity: .78;
paint-order: stroke;
stroke: var(--wall-2);
stroke-width: 3px;
stroke-linejoin: round;
}
.lr-route-label.is-host { font-weight: 800; opacity: 1; }
.lr-route-label.is-sfu { fill: var(--route-sfu); font-weight: 800; opacity: 1; }
.lr-route-label.is-recovering { fill: var(--route-pending); opacity: 1; }
.lr-route-label.is-selected { fill: var(--action-deep); font-weight: 800; opacity: 1; }
@keyframes lr-flow { to { stroke-dashoffset: -24; } }
/* ---------- advanced door ---------- */
@@ -899,13 +1000,26 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
/* ---------- responsive ---------- */
@media (max-width: 640px) {
.lr-pawns { top: -14px; gap: 2px 6px; }
.lr-pawn { width: 36px; }
.lr-pawns-window { inset-inline: 4%; }
.lr-pawns { gap: 2px 6px; }
.lr-pawn { min-width: 36px; }
.lr-pawn svg { width: 32px; height: 39px; }
.lr-route { padding-inline: 0; }
.lr-row { flex-direction: column; align-items: stretch; gap: 12px; padding: 14px; }
.lr-row > .lr-spacer,
.lr-row > .lr-divider { display: none; }
.lr-row-group { width: 100%; gap: 9px; }
.lr-viewer-summary-row { grid-template-columns: minmax(0, 1fr); }
.lr-viewer-room-slot,
.lr-viewer-host-slot,
.lr-viewer-state-slot,
.lr-viewer-self-slot,
.lr-viewer-actions-slot {
grid-column: 1;
grid-row: auto;
max-width: 100%;
}
.lr-viewer-actions-slot { justify-self: stretch; }
.lr-group-actions { display: flex; width: 100%; gap: 8px; min-width: 0; }
.lr-group-actions .lr-btn { flex: 1 1 0; min-width: 0; min-height: 52px; padding: 4px 5px; gap: 6px; }
.lr-group-actions .lr-btn .lr-cap { white-space: normal; text-align: center; line-height: 1.25; font-size: 11px; }
@@ -1077,7 +1191,8 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
transition-duration: 0.01ms !important;
}
.lr-tv-static { animation: none; opacity: .35; }
.lr-route .flow { animation: none; stroke-dasharray: none; }
.lr-route-edge.is-pending,
.lr-route-edge.is-recovering { animation: none; }
}
/* ---------- interaction and legibility constraints ---------- */
-2
View File
@@ -333,7 +333,6 @@ const zh = {
"stats.warn.audioCodec": "音频编码为 {codec},预期 Opus",
"viewer.msg.joinUnavailable": "暂时无法加入房间",
"viewer.onlineCount": "在线 {n}",
"viewer.stageAria": "共享画面",
"host.warn.audioUnread": "浏览器未读回音频码率上限",
@@ -680,7 +679,6 @@ const en: Record<keyof typeof zh, string> = {
"stats.warn.audioCodec": "Audio codec is {codec}; expected Opus",
"viewer.msg.joinUnavailable": "Cannot join the room right now",
"viewer.onlineCount": "{n} online",
"viewer.stageAria": "Shared picture",
"host.warn.audioUnread": "The browser did not report the audio bitrate ceiling",
+24
View File
@@ -42,6 +42,7 @@ describe("living-room presentation", () => {
expect(html).toContain("TNT · Recovering");
expect(html).toContain("lr-pawn-led is-wait");
expect(html).toContain('aria-label="Viewers 1"');
});
it("keeps the current display name visible in visual mode", () => {
@@ -69,4 +70,27 @@ describe("living-room presentation", () => {
expect(html).toContain(">Share</button>");
expect(html).not.toContain('viewBox="0 0 320 96"');
});
it("keeps every crowded and relay Viewer name visible in visual mode", () => {
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(Couch, {
entries: Array.from({ length: 12 }, (_, index) => ({
key: `viewer-${index}`,
name:
index === 11
? "Relay Viewer With A Full Name"
: `Viewer ${index}`,
connected: true,
child: index === 11,
})),
}),
);
expect(html).toContain("lr-pawns is-crowded");
expect(html).toContain(
'<span class="lr-pawn-name is-mini">Relay Viewer With A Full Name</span>',
);
expect(html).not.toContain("max-width:72px");
});
});
+201 -27
View File
@@ -1,93 +1,261 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { RouteTree } from "../src/client/components/living/RouteTree.tsx";
import { topologyLayoutForViewport } from "../src/client/components/living/route-tree-layout.ts";
import { labelParticipantSnapshot } from "../src/client/lib/viewer-presence.ts";
import { setCopy } from "../src/client/ui/copy.ts";
setCopy({ lang: "zh", vis: false });
afterEach(() => setCopy({ lang: "zh", vis: false }));
describe("RouteTree", () => {
it("disambiguates Host and Viewer names without exposing unique peer IDs", () => {
it("uses real-size narrow coordinates without compressing deep trees", () => {
const narrow = topologyLayoutForViewport(true);
const desktop = topologyLayoutForViewport(false);
expect(narrow).toEqual({
baseWidth: 260,
hostX: 54,
columnGap: 130,
rightLabelReserve: 65,
maxVisibleLabelCodePoints: 10,
});
expect(
narrow.hostX + narrow.columnGap + narrow.rightLabelReserve,
).toBeLessThanOrEqual(narrow.baseWidth);
expect(
narrow.hostX + narrow.columnGap * 3 + narrow.rightLabelReserve,
).toBeGreaterThan(narrow.baseWidth);
expect(desktop.baseWidth).toBe(640);
expect(desktop.columnGap).toBe(220);
});
it("disambiguates names without exposing complete peer IDs", () => {
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_private_123456",
displayName: "同名",
displayName: "Same",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_private_root_654321",
displayName: "同名",
displayName: "Same",
upstream: { kind: "peer", peerId: "host_private_123456" },
mediaReady: true,
},
{
role: "viewer",
peerId: "viewer_private_child_456789",
displayName: "朋友乙",
displayName: "Friend A",
upstream: { kind: "peer", peerId: "viewer_private_root_654321" },
mediaReady: true,
},
{
role: "viewer",
peerId: "viewer_private_sfu_789012",
displayName: "朋友丙",
displayName: "Friend B",
upstream: { kind: "sfu" },
mediaReady: true,
},
]);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "分享者",
hostLabel: host?.label ?? "Host",
viewers,
selfPeerId: null,
}),
);
expect(html).toContain("同名 (123456)");
expect(html).toContain("同名 (654321)");
expect(html).toContain("朋友乙");
expect(html).toContain("朋友丙");
expect(html).toContain("媒体服务器");
expect(html).toContain("分享者");
expect(html).toContain("Same (123456)");
expect(html).toContain("Same (654321)");
expect(html).toContain("Friend A");
expect(html).toContain("Friend B");
expect(html).not.toContain("host_private_123456");
expect(html).not.toContain("viewer_private_root_654321");
expect(html).not.toContain("viewer_private_child_456789");
expect(html).not.toContain("viewer_private_sfu_789012");
});
it("keeps the tree text-free in visual mode", () => {
it("compacts long visible labels while preserving duplicate suffixes", () => {
const longChinese = "一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯";
const longEnglish = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_private_111111",
displayName: longChinese,
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_private_123456",
displayName: longEnglish,
upstream: { kind: "peer", peerId: "host_private_111111" },
mediaReady: true,
},
{
role: "viewer",
peerId: "viewer_private_654321",
displayName: longEnglish,
upstream: { kind: "peer", peerId: "host_private_111111" },
mediaReady: true,
},
{
role: "viewer",
peerId: "viewer_private_999999",
displayName: `${longChinese}尾`,
upstream: { kind: "peer", peerId: "host_private_111111" },
mediaReady: true,
},
]);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "Host",
viewers,
}),
);
const visibleLabels = [...html.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map(
(match) => match[1]!,
);
const firstDuplicate = visibleLabels.find((label) =>
label.endsWith(" (123456)"),
);
const secondDuplicate = visibleLabels.find((label) =>
label.endsWith(" (654321)"),
);
expect(firstDuplicate).toBeDefined();
expect(secondDuplicate).toBeDefined();
expect(Array.from(firstDuplicate!).length).toBeLessThanOrEqual(16);
expect(Array.from(secondDuplicate!).length).toBeLessThanOrEqual(16);
expect(visibleLabels.some((label) => label.endsWith("…"))).toBe(true);
expect(html).toContain(`${longEnglish} (123456)`);
expect(html).toContain(`${longEnglish} (654321)`);
expect(html).toContain(`${longChinese}尾`);
expect(html).not.toContain("viewer_private_123456");
expect(html).not.toContain("viewer_private_654321");
});
it("keeps participant data labels visible in visual mode", () => {
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_abc12345",
displayName: "阿舟",
displayName: "Alice",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_def67890",
displayName: "阿茶",
displayName: "Bob",
upstream: { kind: "peer", peerId: "host_abc12345" },
mediaReady: true,
},
]);
setCopy({ vis: true });
setCopy({ lang: "en", vis: true });
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "分享者",
hostLabel: host?.label ?? "Host",
viewers,
selfPeerId: null,
}),
);
expect(html).not.toContain("<text");
// Accessible names ride on aria-label: <title> doubles as a native hover
// tooltip, which leaks localized UI text into the zero-text mode.
expect(html).toContain(">Alice</text>");
expect(html).toContain(">Bob</text>");
expect(html).not.toContain("<title");
expect(html).toContain('aria-label="阿茶"');
setCopy({ vis: false });
});
it("fits a one-Viewer topology inside a narrow container", () => {
const hostName = "这是一个合法的二十四字中文分享者显示名称甲乙丙丁";
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_mobile",
displayName: hostName,
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_mobile",
displayName: "Mobile Viewer",
upstream: { kind: "peer", peerId: "host_mobile" },
mediaReady: true,
},
]);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "Host",
viewers,
}),
);
const visibleLabels = [...html.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map(
(match) => match[1]!,
);
expect(html).toContain('viewBox="0 0 640 ');
expect(html).toContain('style="width:100%;max-width:880px"');
expect(html).not.toContain("min-width:640px");
expect(html).toContain('tabindex="0"');
expect(visibleLabels).toContain("这是一个合法的二十四字中文分享…");
expect(html).toContain(hostName);
});
it("distinguishes ready, recovering, pending, and selected viewers", () => {
const { host, viewers } = labelParticipantSnapshot([
{
role: "host",
peerId: "host",
displayName: "Host",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "ready",
displayName: "Ready",
upstream: { kind: "peer", peerId: "host" },
mediaReady: true,
},
{
role: "viewer",
peerId: "recovering",
displayName: "Recovering",
upstream: { kind: "peer", peerId: "host" },
},
{
role: "viewer",
peerId: "pending",
displayName: "Pending",
upstream: { kind: "none" },
},
]);
const html = renderToStaticMarkup(
createElement(RouteTree, {
hostPeerId: host?.peerId ?? null,
hostLabel: host?.label ?? "Host",
viewers,
selectedPeerId: "recovering",
}),
);
expect(html).toContain('class="lr-route-edge is-p2p"');
expect(html).toContain('class="lr-route-edge is-p2p is-recovering"');
expect(html).toContain('class="lr-route-edge is-pending"');
expect(html).toContain(
'class="lr-route-node is-recovering is-selected"',
);
expect(html).toContain("Recovering");
expect(html).toContain("← Host");
});
it("gives every reachable depth its own horizontal column", () => {
@@ -106,6 +274,7 @@ describe("RouteTree", () => {
kind: "peer" as const,
peerId: index === 0 ? "host" : `viewer-${index - 1}`,
},
mediaReady: true as const,
})),
];
const { host, viewers } = labelParticipantSnapshot(participants);
@@ -119,6 +288,11 @@ describe("RouteTree", () => {
const width = Number(html.match(/viewBox="0 0 (\d+) /)?.[1]);
expect(width).toBeGreaterThan(640);
expect(new Set([...html.matchAll(/translate\((\d+),/g)].map((match) => match[1])).size).toBeGreaterThanOrEqual(6);
expect(html).toContain(`style="width:${width}px;max-width:none"`);
expect(
new Set(
[...html.matchAll(/translate\((\d+),/g)].map((match) => match[1]),
).size,
).toBeGreaterThanOrEqual(6);
});
});