forked from unom/punktfunk
The Virtual displays card previously only exposed keep_alive/topology/max_displays as editable custom fields; conflict/identity/layout (enforced since Stages 3-5) had no controls, and the presets weren't surfaced as one-click options. Rework the card so the whole policy is configurable WITHOUT any client connected: - Presets front-and-center: each of the five (default/shared-desktop/hotdesk/workstation/ gaming-rig) is a one-click row showing its story AND what it sets (keep-alive · topology · conflict · identity badges), highlighting the active one. A click applies it immediately. gaming-rig stays disabled + "coming soon" (keep_alive: forever isn't cross-platform yet). - Custom mode reveals EVERY axis editably — keep-alive, topology, conflict, identity, layout, max-displays — seeded from the current effective behavior, with a Save button. A reusable `Choice` button-group + a tolerant `tr()` label lookup keep it tidy. - The live-display list + multi-monitor arrangement table stay below (they need a live session); the settings above work standalone. - en+de i18n for the new controls; refreshed the effective-preview row to show all axes. web tsc + vite build + biome-lint green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
585 lines
19 KiB
TypeScript
585 lines
19 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import { Button } from "@unom/ui/button";
|
||
import { type FC, useEffect, useState } from "react";
|
||
import {
|
||
getGetDisplayStateQueryKey,
|
||
getGetDisplaySettingsQueryKey,
|
||
useGetDisplaySettings,
|
||
useGetDisplayState,
|
||
useReleaseDisplay,
|
||
useSetDisplayLayout,
|
||
useSetDisplaySettings,
|
||
} from "@/api/gen/display/display";
|
||
import type {
|
||
ApiDisplayInfo,
|
||
DisplayPolicy,
|
||
EffectivePolicy,
|
||
Identity,
|
||
KeepAlive,
|
||
LayoutMode,
|
||
ModeConflict,
|
||
Preset,
|
||
Topology,
|
||
} from "@/api/gen/model";
|
||
import { ApiError } from "@/api/fetcher";
|
||
import { QueryState } from "@/components/query-state";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { m } from "@/paraglide/messages";
|
||
|
||
/**
|
||
* Container: the host's virtual-display management policy (design/display-management.md). Lets the
|
||
* operator pick a one-click preset OR set every option by hand — all WITHOUT any client connected
|
||
* (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement
|
||
* table below act on whatever is currently streaming.
|
||
*/
|
||
export const DisplaySection: FC = () => {
|
||
const qc = useQueryClient();
|
||
const q = useGetDisplaySettings();
|
||
const save = useSetDisplaySettings();
|
||
|
||
// Local edit buffer, seeded once from the server and re-seeded after every successful apply.
|
||
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
|
||
useEffect(() => {
|
||
if (q.data && draft === null) setDraft(q.data.settings);
|
||
}, [q.data, draft]);
|
||
|
||
// Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on
|
||
// the next connect; a live session keeps the display it opened on.
|
||
const apply = (policy: DisplayPolicy) =>
|
||
save.mutate(
|
||
{ data: policy },
|
||
{
|
||
onSuccess: (res) => {
|
||
setDraft(res.settings);
|
||
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||
},
|
||
},
|
||
);
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{m.host_displays()}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
||
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
|
||
{q.data && draft && (
|
||
<DisplayForm
|
||
draft={draft}
|
||
setDraft={setDraft}
|
||
presets={q.data.presets}
|
||
apply={apply}
|
||
busy={save.isPending}
|
||
error={apiErrorMessage(save.error)}
|
||
/>
|
||
)}
|
||
</QueryState>
|
||
<LiveDisplays />
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
|
||
const PRESET_ORDER = [
|
||
"default",
|
||
"shared-desktop",
|
||
"hotdesk",
|
||
"workstation",
|
||
"gaming-rig",
|
||
"custom",
|
||
] as const;
|
||
|
||
const DisplayForm: FC<{
|
||
draft: DisplayPolicy;
|
||
setDraft: (p: DisplayPolicy) => void;
|
||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||
apply: (p: DisplayPolicy) => void;
|
||
busy: boolean;
|
||
error?: string;
|
||
}> = ({ draft, setDraft, presets, apply, busy, error }) => {
|
||
const preset: Preset = draft.preset ?? "custom";
|
||
const isCustom = preset === "custom";
|
||
|
||
// The Custom fields (defaults filled): the edit buffer when preset === "custom", and what a
|
||
// preset→Custom switch is seeded from, so you customize starting from the current behavior.
|
||
const customFields: EffectivePolicy = {
|
||
keep_alive: draft.keep_alive ?? { mode: "duration", seconds: 10 },
|
||
topology: draft.topology ?? "auto",
|
||
mode_conflict: draft.mode_conflict ?? "separate",
|
||
identity: draft.identity ?? "per-client",
|
||
layout: draft.layout ?? { mode: "auto-row", positions: {} },
|
||
max_displays: draft.max_displays ?? 4,
|
||
};
|
||
const effective: EffectivePolicy =
|
||
(isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ?? customFields;
|
||
|
||
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
|
||
// effective behavior (nothing changes until you Save).
|
||
const pickPreset = (id: string) => {
|
||
if (id === "custom") {
|
||
setDraft({
|
||
version: 1,
|
||
preset: "custom",
|
||
keep_alive: effective.keep_alive,
|
||
topology: effective.topology,
|
||
mode_conflict: effective.mode_conflict,
|
||
identity: effective.identity,
|
||
layout: effective.layout,
|
||
max_displays: effective.max_displays,
|
||
});
|
||
} else {
|
||
apply({ ...draft, preset: id as Preset });
|
||
}
|
||
};
|
||
|
||
const ka = customFields.keep_alive;
|
||
const secondsValue = ka.mode === "duration" ? ka.seconds : 300;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* One-click presets */}
|
||
<div className="space-y-2">
|
||
<Label>{m.display_preset()}</Label>
|
||
<div className="grid gap-2">
|
||
{PRESET_ORDER.map((id) => {
|
||
const p = presets.find((x) => x.id === id);
|
||
const fields = id === "custom" ? undefined : p?.fields;
|
||
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
|
||
const selected = preset === id;
|
||
const soon = DISABLED_PRESETS.has(id);
|
||
const cls = [
|
||
"w-full rounded-md border p-3 text-left transition-colors",
|
||
selected ? "border-primary ring-1 ring-primary" : "hover:bg-muted/50",
|
||
soon ? "opacity-60" : "",
|
||
].join(" ");
|
||
return (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
disabled={busy || soon}
|
||
onClick={() => pickPreset(id)}
|
||
className={cls}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-medium">
|
||
{(PRESET_LABEL[id] ?? (() => id))()}
|
||
{soon && (
|
||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||
{m.display_preset_soon()}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{selected && (
|
||
<Badge variant="success">{m.display_preset_current()}</Badge>
|
||
)}
|
||
</div>
|
||
{summary && <p className="mt-0.5 text-xs text-muted-foreground">{summary}</p>}
|
||
{fields && (
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
|
||
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
|
||
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
|
||
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Custom: every option by hand */}
|
||
{isCustom && (
|
||
<div className="space-y-4 rounded-md border p-4">
|
||
<div className="space-y-2">
|
||
<Label>{m.display_keep_alive()}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant={ka.mode === "off" ? "default" : "outline"}
|
||
disabled={busy}
|
||
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
|
||
>
|
||
{m.display_keep_alive_off()}
|
||
</Button>
|
||
<Input
|
||
type="number"
|
||
min={0}
|
||
className="w-24"
|
||
value={secondsValue}
|
||
disabled={busy}
|
||
onChange={(e) =>
|
||
setDraft({
|
||
...draft,
|
||
keep_alive: {
|
||
mode: "duration",
|
||
seconds: Math.max(0, Number(e.target.value) || 0),
|
||
},
|
||
})
|
||
}
|
||
/>
|
||
<span className="text-sm text-muted-foreground">
|
||
{m.display_keep_alive_seconds()}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{m.display_keep_alive_help()}</p>
|
||
</div>
|
||
|
||
<Choice
|
||
label={m.display_topology()}
|
||
help={m.display_topology_help()}
|
||
value={customFields.topology}
|
||
options={["auto", "extend", "primary", "exclusive"]}
|
||
labels={TOPOLOGY_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => setDraft({ ...draft, topology: v as Topology })}
|
||
/>
|
||
<Choice
|
||
label={m.display_conflict()}
|
||
help={m.display_conflict_help()}
|
||
value={customFields.mode_conflict}
|
||
options={["separate", "steal", "join", "reject"]}
|
||
labels={CONFLICT_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })}
|
||
/>
|
||
<Choice
|
||
label={m.display_identity()}
|
||
help={m.display_identity_help()}
|
||
value={customFields.identity}
|
||
options={["shared", "per-client", "per-client-mode"]}
|
||
labels={IDENTITY_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) => setDraft({ ...draft, identity: v as Identity })}
|
||
/>
|
||
<Choice
|
||
label={m.display_layout_mode()}
|
||
help={m.display_layout_help()}
|
||
value={customFields.layout.mode ?? "auto-row"}
|
||
options={["auto-row", "manual"]}
|
||
labels={LAYOUT_LABEL}
|
||
disabled={busy}
|
||
onPick={(v) =>
|
||
setDraft({
|
||
...draft,
|
||
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} },
|
||
})
|
||
}
|
||
/>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="disp-max">{m.display_max()}</Label>
|
||
<Input
|
||
id="disp-max"
|
||
type="number"
|
||
min={1}
|
||
max={16}
|
||
className="w-24"
|
||
value={draft.max_displays ?? 4}
|
||
disabled={busy}
|
||
onChange={(e) =>
|
||
setDraft({
|
||
...draft,
|
||
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<Button onClick={() => apply(draft)} disabled={busy}>
|
||
{m.display_save()}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* What's in force right now */}
|
||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
||
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
||
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</Badge>
|
||
<Badge variant="outline">{tr(CONFLICT_LABEL, effective.mode_conflict)}</Badge>
|
||
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge>
|
||
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge>
|
||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||
</div>
|
||
|
||
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** A labeled row of mutually-exclusive option buttons (topology / conflict / identity / layout). */
|
||
const Choice: FC<{
|
||
label: string;
|
||
help?: string;
|
||
value: string;
|
||
options: readonly string[];
|
||
labels: Record<string, () => string>;
|
||
disabled: boolean;
|
||
onPick: (v: string) => void;
|
||
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
|
||
<div className="space-y-2">
|
||
<Label>{label}</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map((o) => (
|
||
<Button
|
||
key={o}
|
||
size="sm"
|
||
variant={value === o ? "default" : "outline"}
|
||
disabled={disabled}
|
||
onClick={() => onPick(o)}
|
||
>
|
||
{(labels[o] ?? (() => o))()}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||
</div>
|
||
);
|
||
|
||
/**
|
||
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
||
* for lingering/pinned ones (active displays can't be released — that's session control).
|
||
*/
|
||
const LiveDisplays: FC = () => {
|
||
const qc = useQueryClient();
|
||
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
|
||
const release = useReleaseDisplay();
|
||
const displays = state.data?.displays ?? [];
|
||
const kept = displays.filter((d) => d.state !== "active");
|
||
|
||
const doRelease = (slot?: number) =>
|
||
release.mutate(
|
||
{ data: { slot: slot ?? null } },
|
||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-2 border-t pt-4">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<h4 className="text-sm font-medium">{m.display_live()}</h4>
|
||
{kept.length > 0 && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={release.isPending}
|
||
onClick={() => doRelease()}
|
||
>
|
||
{m.display_release_all()}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{displays.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
|
||
) : (
|
||
<ul className="divide-y rounded-md border">
|
||
{displays.map((d) => (
|
||
<DisplayRow
|
||
key={d.slot}
|
||
d={d}
|
||
busy={release.isPending}
|
||
onRelease={() => doRelease(d.slot)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<DisplayArrangement displays={displays} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* The multi-monitor **arrangement** editor (design/display-management.md §6.2): an x/y table over the
|
||
* live displays that carry a stable identity slot (the manual-layout key). Saving writes
|
||
* `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect.
|
||
* Shown only for a ≥2-display group — arranging a single display is moot.
|
||
*/
|
||
const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => {
|
||
const qc = useQueryClient();
|
||
const saveLayout = useSetDisplayLayout();
|
||
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
|
||
const arrangeable = displays.filter((d) => d.identity_slot != null);
|
||
|
||
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
|
||
const [pos, setPos] = useState<Record<string, { x: number; y: number }> | null>(null);
|
||
useEffect(() => {
|
||
if (pos === null && arrangeable.length > 0) {
|
||
const seed: Record<string, { x: number; y: number }> = {};
|
||
for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y };
|
||
setPos(seed);
|
||
}
|
||
}, [arrangeable, pos]);
|
||
|
||
if (arrangeable.length < 2) return null;
|
||
const cur = pos ?? {};
|
||
|
||
const setXY = (slot: number, key: "x" | "y", val: number) => {
|
||
const k = String(slot);
|
||
setPos({ ...cur, [k]: { ...(cur[k] ?? { x: 0, y: 0 }), [key]: val } });
|
||
};
|
||
|
||
const onSave = () =>
|
||
saveLayout.mutate(
|
||
{ data: { positions: cur } },
|
||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-2 border-t pt-4">
|
||
<h4 className="text-sm font-medium">{m.display_arrange()}</h4>
|
||
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p>
|
||
<div className="space-y-2">
|
||
{arrangeable.map((d) => {
|
||
const slot = d.identity_slot as number;
|
||
const p = cur[String(slot)] ?? { x: d.x, y: d.y };
|
||
return (
|
||
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
|
||
<span className="w-44 truncate">
|
||
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code>
|
||
</span>
|
||
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
|
||
X
|
||
</Label>
|
||
<Input
|
||
id={`disp-x-${slot}`}
|
||
type="number"
|
||
className="w-24"
|
||
value={p.x}
|
||
disabled={saveLayout.isPending}
|
||
onChange={(e) => setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))}
|
||
/>
|
||
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
|
||
Y
|
||
</Label>
|
||
<Input
|
||
id={`disp-y-${slot}`}
|
||
type="number"
|
||
className="w-24"
|
||
value={p.y}
|
||
disabled={saveLayout.isPending}
|
||
onChange={(e) => setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{saveLayout.error && (
|
||
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||
{apiErrorMessage(saveLayout.error)}
|
||
</p>
|
||
)}
|
||
<Button size="sm" onClick={onSave} disabled={saveLayout.isPending}>
|
||
{m.display_arrange_save()}
|
||
</Button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({
|
||
d,
|
||
busy,
|
||
onRelease,
|
||
}) => {
|
||
const active = d.state === "active";
|
||
const stateLabel =
|
||
d.state === "active"
|
||
? m.display_state_active()
|
||
: d.state === "pinned"
|
||
? m.display_state_pinned()
|
||
: m.display_state_lingering();
|
||
return (
|
||
<li className="flex items-center justify-between gap-4 px-4 py-3">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium">{d.mode}</span>
|
||
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
|
||
{active && d.sessions > 0 && (
|
||
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
|
||
)}
|
||
</div>
|
||
<code className="text-xs text-muted-foreground">
|
||
{d.backend}
|
||
{d.expires_in_ms != null
|
||
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
|
||
: ""}
|
||
</code>
|
||
</div>
|
||
{!active && (
|
||
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
|
||
{m.display_release_btn()}
|
||
</Button>
|
||
)}
|
||
</li>
|
||
);
|
||
};
|
||
|
||
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
|
||
const apiErrorMessage = (err: unknown): string | undefined => {
|
||
if (err instanceof ApiError) {
|
||
const data = err.data as { error?: string } | undefined;
|
||
return data?.error ?? err.message;
|
||
}
|
||
return err ? String(err) : undefined;
|
||
};
|
||
|
||
/** `gaming-rig` expands to `keep_alive: forever`, which the host still rejects (Windows has no
|
||
* Pinned state yet) — surface it, but disabled, rather than let the one-click apply 400. */
|
||
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
|
||
|
||
const PRESET_LABEL: Record<string, () => string> = {
|
||
custom: m.display_preset_custom,
|
||
default: m.display_preset_default,
|
||
"gaming-rig": m.display_preset_gaming_rig,
|
||
"shared-desktop": m.display_preset_shared_desktop,
|
||
hotdesk: m.display_preset_hotdesk,
|
||
workstation: m.display_preset_workstation,
|
||
};
|
||
|
||
const TOPOLOGY_LABEL: Record<string, () => string> = {
|
||
auto: m.display_topology_auto,
|
||
extend: m.display_topology_extend,
|
||
primary: m.display_topology_primary,
|
||
exclusive: m.display_topology_exclusive,
|
||
};
|
||
|
||
const CONFLICT_LABEL: Record<string, () => string> = {
|
||
separate: m.display_conflict_separate,
|
||
steal: m.display_conflict_steal,
|
||
join: m.display_conflict_join,
|
||
reject: m.display_conflict_reject,
|
||
};
|
||
|
||
const IDENTITY_LABEL: Record<string, () => string> = {
|
||
shared: m.display_identity_shared,
|
||
"per-client": m.display_identity_per_client,
|
||
"per-client-mode": m.display_identity_per_client_mode,
|
||
};
|
||
|
||
const LAYOUT_LABEL: Record<string, () => string> = {
|
||
"auto-row": m.display_layout_auto_row,
|
||
manual: m.display_layout_manual,
|
||
};
|
||
|
||
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
|
||
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
||
const fn = key == null ? undefined : map[key];
|
||
return fn ? fn() : String(key ?? "");
|
||
};
|
||
|
||
const fmtKeepAlive = (k: KeepAlive): string => {
|
||
switch (k.mode) {
|
||
case "off":
|
||
return m.display_keep_alive_off();
|
||
case "duration":
|
||
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
|
||
case "forever":
|
||
return "∞";
|
||
}
|
||
};
|