refactor(ui): unify channel status and setup controls (#117499)

This commit is contained in:
Peter Steinberger
2026-08-01 11:03:07 -07:00
committed by GitHub
parent 3f9b24237b
commit fd343ca8cc
15 changed files with 650 additions and 820 deletions
+156 -100
View File
@@ -17,13 +17,31 @@ type WizardStepControlsProps = {
inputId: string;
onValueChange: (value: unknown) => void;
onAnswer: (value: unknown, includeValue?: boolean) => void;
presentation?: "channels";
answerLabel?: string;
};
function renderMessage(step: WizardStep) {
return step.message ? html`<div class="wizard-step__message">${step.message}</div>` : nothing;
function stepClass(props: WizardStepControlsProps, name: string): string {
return `${props.presentation === "channels" ? "channels-wizard" : "wizard-step"}__${name}`;
}
function renderOptionBody(option: WizardStepOption) {
function renderMessage(props: WizardStepControlsProps) {
return props.step.message
? html`<div class=${stepClass(props, "message")}>${props.step.message}</div>`
: nothing;
}
function renderOptionBody(option: WizardStepOption, presentation?: "channels", selected?: boolean) {
if (presentation === "channels") {
return html`
<span class="channels-wizard__option-label">
${selected === undefined ? nothing : selected ? "☑ " : "☐ "}${option.label}
</span>
${option.hint
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
: nothing}
`;
}
return html`
<span>
<strong>${option.label}</strong>
@@ -59,32 +77,95 @@ function renderDeviceCode(step: WizardStep) {
`;
}
function renderAnswerButton(
props: WizardStepControlsProps,
label: string,
onClick?: () => void,
disabled = props.busy,
) {
const button = html`
<button
type=${onClick ? "button" : "submit"}
class="btn primary"
?disabled=${disabled}
@click=${onClick}
>
${props.answerLabel ?? label}
</button>
`;
return props.presentation === "channels"
? html`<div class="channels-wizard__footer">${button}</div>`
: button;
}
function renderOption(
props: WizardStepControlsProps,
option: WizardStepOption,
index: number,
selected: unknown[],
) {
const checked = selected.some((value) => Object.is(value, option.value));
if (props.presentation === "channels") {
return props.step.type === "select"
? html`<wa-radio
class="channels-wizard__option"
appearance="button"
value=${String(index)}
.checked=${checked}
>
${renderOptionBody(option, props.presentation)}
</wa-radio>`
: html`<button
type="button"
class="channels-wizard__option"
aria-pressed=${checked ? "true" : "false"}
?disabled=${props.busy}
@click=${() => props.onValueChange(option.value)}
>
${renderOptionBody(option, props.presentation, checked)}
</button>`;
}
return html`<label class="wizard-step__option">
<input
type=${props.step.type === "select" ? "radio" : "checkbox"}
name=${props.step.type === "select" ? "wizard-option" : nothing}
.checked=${checked}
?disabled=${props.busy}
@change=${(event: Event) => {
const nextValue =
props.step.type === "select"
? option.value
: (event.currentTarget as HTMLInputElement).checked
? [...selected, option.value]
: selected.filter((value) => !Object.is(value, option.value));
props.onValueChange(nextValue);
}}
/>
${renderOptionBody(option)}
</label>`;
}
function renderContinueStep(props: WizardStepControlsProps) {
const step = props.step;
return html`
${renderMessage(step)}
${renderMessage(props)}
${step.externalUrl
? html`<a class="btn btn--sm" href=${step.externalUrl} target="_blank" rel="noreferrer">
${t("modelSetup.wizard.openSignIn")}
</a>`
: nothing}
${renderDeviceCode(step)}
<button
type="button"
class="btn primary"
?disabled=${props.busy}
@click=${() => props.onAnswer(undefined, false)}
>
${t("modelSetup.wizard.continue")}
</button>
${renderAnswerButton(props, t("modelSetup.wizard.continue"), () =>
props.onAnswer(undefined, false),
)}
`;
}
function renderProgressStep(step: WizardStep) {
function renderProgressStep(props: WizardStepControlsProps) {
return html`
<div class="wizard-step__progress" role="status" aria-live="polite">
<span class="wizard-step__spinner" aria-hidden="true"></span>
${renderMessage(step)}
${renderMessage(props)}
</div>
`;
}
@@ -97,11 +178,14 @@ function renderTextStep(props: WizardStepControlsProps) {
class="wizard-step__form"
@submit=${(event: Event) => {
event.preventDefault();
props.onAnswer(value);
const input = (event.currentTarget as HTMLFormElement).elements.namedItem(
"wizard-text",
) as HTMLInputElement | null;
props.onAnswer(props.presentation === "channels" ? (input?.value ?? "") : value);
}}
>
${step.message
? html`<div class="wizard-step__message">
? html`<div class=${stepClass(props, "message")}>
<label for=${props.inputId}>${step.message}</label>
</div>`
: nothing}
@@ -115,101 +199,74 @@ function renderTextStep(props: WizardStepControlsProps) {
.value=${value}
?disabled=${props.busy}
@input=${(event: Event) =>
props.presentation !== "channels" &&
props.onValueChange((event.currentTarget as HTMLInputElement).value)}
/>
<button type="submit" class="btn primary" ?disabled=${props.busy}>
${t("modelSetup.wizard.submit")}
</button>
${renderAnswerButton(props, t("modelSetup.wizard.submit"))}
</form>
`;
}
function renderSelectStep(props: WizardStepControlsProps) {
function renderOptionsStep(props: WizardStepControlsProps) {
const options = props.step.options ?? [];
const multiple = props.step.type === "multiselect";
const selected = multiple ? (Array.isArray(props.value) ? props.value : []) : [props.value];
if (props.presentation === "channels" && !multiple) {
const selectedIndex = options.findIndex((option) => Object.is(option.value, props.value));
return html`
<wa-radio-group
class="channels-wizard__options"
label=${props.step.message ?? ""}
orientation="vertical"
.value=${selectedIndex >= 0 ? String(selectedIndex) : null}
?disabled=${props.busy}
@change=${(event: Event) => {
const index = (event.currentTarget as HTMLElement & { value?: string | number | null })
.value;
const option = options[Number(index)];
if (option) {
props.onAnswer(option.value);
}
}}
>
${options.map((option, index) => renderOption(props, option, index, selected))}
</wa-radio-group>
`;
}
const answer = multiple
? props.presentation === "channels"
? [...selected]
: selected
: props.value;
return html`
${renderMessage(props.step)}
<div class="wizard-step__options" role="radiogroup">
${(props.step.options ?? []).map(
(option) => html`
<label class="wizard-step__option">
<input
type="radio"
name="wizard-option"
.checked=${Object.is(props.value, option.value)}
@change=${() => props.onValueChange(option.value)}
/>
${renderOptionBody(option)}
</label>
`,
)}
${renderMessage(props)}
<div class=${stepClass(props, "options")} role=${multiple ? nothing : "radiogroup"}>
${options.map((option, index) => renderOption(props, option, index, selected))}
</div>
<button
type="button"
class="btn primary"
?disabled=${props.busy || props.value === undefined}
@click=${() => props.onAnswer(props.value)}
>
${t("modelSetup.wizard.continue")}
</button>
${renderAnswerButton(
props,
t("modelSetup.wizard.continue"),
() => props.onAnswer(answer),
props.busy || (!multiple && props.value === undefined),
)}
`;
}
function renderConfirmStep(props: WizardStepControlsProps) {
return html`
${renderMessage(props.step)}
<div class="wizard-step__actions">
<button
type="button"
class="btn"
?disabled=${props.busy}
@click=${() => props.onAnswer(false)}
>
${t("common.no")}
</button>
<button
type="button"
class="btn primary"
?disabled=${props.busy}
@click=${() => props.onAnswer(true)}
>
${t("common.yes")}
</button>
</div>
`;
}
function renderMultiselectStep(props: WizardStepControlsProps) {
const selected = Array.isArray(props.value) ? props.value : [];
return html`
${renderMessage(props.step)}
<div class="wizard-step__options">
${(props.step.options ?? []).map(
(option) => html`
<label class="wizard-step__option">
<input
type="checkbox"
.checked=${selected.some((value) => Object.is(value, option.value))}
@change=${(event: Event) => {
const checked = (event.currentTarget as HTMLInputElement).checked;
props.onValueChange(
checked
? [...selected, option.value]
: selected.filter((value) => !Object.is(value, option.value)),
);
}}
/>
${renderOptionBody(option)}
</label>
`,
${renderMessage(props)}
<div class=${stepClass(props, props.presentation === "channels" ? "footer" : "actions")}>
${[false, true].map(
(answer) => html`<button
type="button"
class=${answer ? "btn primary" : "btn"}
?disabled=${props.busy}
@click=${() => props.onAnswer(answer)}
>
${t(answer ? "common.yes" : "common.no")}
</button>`,
)}
</div>
<button
type="button"
class="btn primary"
?disabled=${props.busy}
@click=${() => props.onAnswer(selected)}
>
${t("modelSetup.wizard.continue")}
</button>
`;
}
@@ -225,14 +282,13 @@ export function renderWizardStepControls(
case "text":
return renderTextStep(props);
case "select":
return renderSelectStep(props);
case "multiselect":
return renderOptionsStep(props);
case "confirm":
return renderConfirmStep(props);
case "multiselect":
return renderMultiselectStep(props);
case "progress":
return props.step.executor === "gateway"
? renderProgressStep(props.step)
? renderProgressStep(props)
: renderContinueStep(props);
// These show whatever the step supplies behind a single Continue.
case "note":
@@ -106,4 +106,146 @@ describeControlUiE2e("Control UI WhatsApp logout mocked Gateway E2E", () => {
await context.close();
}
});
it("preserves standard channel details and the complete Telegram setup wizard", async () => {
const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" });
const page = await context.newPage();
const channelEntries = [
["discord", "Discord"],
["googlechat", "Google Chat"],
["imessage", "iMessage"],
["signal", "Signal"],
["slack", "Slack"],
["telegram", "Telegram"],
] as const;
const running = { configured: true, running: true };
const details: Record<string, Record<string, unknown>> = {
googlechat: {
credentialSource: "service-account",
audienceType: "url",
audience: "https://chat.example.test",
},
signal: { baseUrl: "https://signal.example.test" },
};
const bot = (accountId: string, username: string) => ({
accountId,
...running,
probe: { bot: { username } },
});
const step = (id: string, type: string, values: Record<string, unknown> = {}) => ({
done: false,
status: "running",
step: { id, type, ...values },
});
const gateway = await installMockGateway(page, {
featureMethods: ["channels.status", "channels.pairing.list", "wizard.start", "wizard.next"],
methodResponses: {
"channels.status": {
ts: Date.now(),
channelOrder: channelEntries.map(([id]) => id),
channelLabels: Object.fromEntries(channelEntries),
channelMeta: channelEntries.map(([id, label]) => ({ id, label })),
channels: Object.fromEntries(
channelEntries.map(([id]) => [id, { ...running, ...details[id] }]),
),
channelAccounts: { telegram: [bot("personal", "alpha_bot"), bot("work", "work_bot")] },
channelDefaultAccountId: { telegram: "personal" },
},
"channels.pairing.list": {
accounts: [],
requests: [],
commandOwnerConfigured: true,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
},
"wizard.start": {
sessionId: "channel-standard-proof",
...step("account", "select", {
message: "Choose Telegram account",
initialValue: "personal",
options: ["personal", "work"].map((value) => ({
value,
label: value === "work" ? "Work bot" : "Personal bot",
})),
}),
},
"wizard.next": {
sequence: [
step("token", "text", { message: "Telegram bot token", sensitive: true }),
step("features", "multiselect", {
initialValue: ["alpha"],
options: ["alpha", "beta"].map((value) => ({
value,
label: value === "alpha" ? "Alpha" : "Beta",
})),
}),
step("confirm", "confirm", { message: "Apply Telegram settings?" }),
step("progress", "progress", { executor: "gateway", message: "Finish preparation" }),
{ done: true, status: "done", channels: ["telegram"], accounts: [] },
],
},
},
});
try {
await page.goto(`${server.baseUrl}settings/channels`);
const expectedFields: Record<string, string[]> = {
googlechat: ["service-account", "url · https://chat.example.test"],
signal: ["https://signal.example.test"],
telegram: ["@alpha_bot", "@work_bot", "2"],
};
for (const [channelId, label] of channelEntries) {
await page.locator(".channels-item", { hasText: label }).first().click();
const detail = page.locator(".channels-detail");
await expect
.poll(() => detail.locator("h2.settings-section__heading").textContent())
.toContain(label);
await detail.getByRole("button", { name: "Probe" }).waitFor();
for (const value of expectedFields[channelId] ?? []) {
await detail.getByText(value, { exact: true }).waitFor();
}
if (channelId !== "telegram") {
await detail.getByRole("button", { name: "Close" }).click();
}
}
await page.locator(".channels-detail").getByRole("button", { name: "Run setup" }).click();
const wizard = page.locator(".channels-wizard");
await gateway.deferNext("wizard.next");
await wizard.getByRole("radio", { name: "Work bot" }).click();
await expect.poll(async () => gateway.getRequests("wizard.next")).toHaveLength(1);
await expect
.poll(() => wizard.locator("wa-radio-group").getAttribute("disabled"))
.not.toBeNull();
await gateway.resolveDeferred("wizard.next");
const token = wizard.getByLabel("Telegram bot token");
await expect.poll(() => token.getAttribute("type")).toBe("password");
await token.fill("123456:proof-secret");
await wizard.getByRole("button", { name: "Continue" }).click();
const beta = wizard.getByRole("button", { name: /Beta/u });
await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("false");
await beta.click();
await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("true");
await wizard.getByRole("button", { name: "Continue" }).click();
await wizard.getByRole("button", { name: "Yes" }).click();
await wizard.getByRole("button", { name: "Continue" }).click();
await wizard.getByRole("button", { name: "Finish" }).waitFor();
const answers = [
["account", "work"],
["token", "123456:proof-secret"],
["features", ["alpha", "beta"]],
["confirm", true],
["progress", null],
] as const;
expect((await gateway.getRequests("wizard.next")).map(({ params }) => params)).toEqual(
answers.map(([stepId, value]) => ({
sessionId: "channel-standard-proof",
answer: { stepId, value },
})),
);
} finally {
await context.close();
}
});
});
+19 -12
View File
@@ -1,6 +1,7 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { roleScopesAllow } from "../../../../src/shared/operator-scope-compat.ts";
import type {
ChannelAccountSnapshot,
ChannelsPairingApproveResult,
ChannelsPairingListResult,
ChannelsStatusSnapshot,
@@ -81,6 +82,15 @@ export type ChannelCapability = {
dispose: () => void;
};
export function resolveChannelAccounts(
channelAccounts: ChannelsStatusSnapshot["channelAccounts"] | null | undefined,
channelId: string,
): ChannelAccountSnapshot[] {
const accounts =
channelAccounts && Object.hasOwn(channelAccounts, channelId) && channelAccounts[channelId];
return Array.isArray(accounts) ? accounts : [];
}
export function channelSnapshotEntryIsActive(
snapshot: ChannelsStatusSnapshot | null,
channelId: string,
@@ -88,11 +98,13 @@ export function channelSnapshotEntryIsActive(
if (!snapshot) {
return false;
}
const status = asRecord(snapshot.channels[channelId]);
const status = asRecord(
Object.hasOwn(snapshot.channels, channelId) ? snapshot.channels[channelId] : undefined,
);
if (status?.configured === true || status?.running === true || status?.connected === true) {
return true;
}
return (snapshot.channelAccounts[channelId] ?? []).some(
return resolveChannelAccounts(snapshot.channelAccounts, channelId).some(
(account) =>
account.configured === true || account.running === true || account.connected === true,
);
@@ -159,9 +171,9 @@ function createInitialChannelsState(snapshot: Partial<ChannelGatewaySnapshot> =
};
}
function delay(ms: number): Promise<"timeout"> {
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => resolve("timeout"), ms);
setTimeout(resolve, ms);
});
}
@@ -220,14 +232,9 @@ async function loadChannels(
})();
const softTimeoutMs = options.softTimeoutMs;
if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) {
const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]);
if (outcome === "timeout") {
return;
}
return;
}
await refresh;
await (typeof softTimeoutMs === "number" && softTimeoutMs > 0
? Promise.race([refresh, delay(softTimeoutMs)])
: refresh);
}
function isCurrentPairingRefresh(
+149 -105
View File
@@ -1,32 +1,172 @@
// Channel detail overlay: full status + advanced schema config form for one
// channel, reusing the per-channel settings-language renderers.
import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce";
import { html, nothing, type TemplateResult } from "lit";
import type { ChannelAccountSnapshot, NostrProfile } from "../../api/types.ts";
import type { NostrProfile } from "../../api/types.ts";
import { renderSettingsSection } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import "../../components/modal-dialog.ts";
import { resolveChannelAccounts } from "../../lib/channels/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { channelDocsUrl, renderChannelArt } from "./hub-meta.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import { renderDiscordCard } from "./view.discord.ts";
import { renderGoogleChatCard } from "./view.googlechat.ts";
import { renderIMessageCard } from "./view.imessage.ts";
import { renderNostrCard } from "./view.nostr.ts";
import { renderChannelPairingDetail } from "./view.pairing.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelAccountRow,
renderChannelActionRow,
renderChannelErrorRow,
renderChannelFacts,
renderChannelProbeRow,
resolveChannelAccountCount,
resolveChannelDisplayState,
} from "./view.shared.ts";
import { renderSignalCard } from "./view.signal.ts";
import { renderSlackCard } from "./view.slack.ts";
import { renderTelegramCard } from "./view.telegram.ts";
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts";
import { renderWhatsAppCard } from "./view.whatsapp.ts";
const STANDARD_CHANNEL_LOCALE_KEYS = {
discord: "discord",
googlechat: "googleChat",
imessage: "imessage",
signal: "signal",
slack: "slack",
telegram: "telegram",
} as const;
type StandardChannelKey = keyof typeof STANDARD_CHANNEL_LOCALE_KEYS;
function isStandardChannel(key: ChannelKey): key is StandardChannelKey {
return Object.hasOwn(STANDARD_CHANNEL_LOCALE_KEYS, key);
}
function renderChannelStatusBody(
key: ChannelKey,
props: ChannelsProps,
data: ChannelsChannelData,
accountCount: number | undefined,
) {
const standardKey = isStandardChannel(key) ? key : null;
const localeKey = standardKey ? STANDARD_CHANNEL_LOCALE_KEYS[standardKey] : null;
const status = standardKey ? data[standardKey] : undefined;
const displayState = resolveChannelDisplayState(key, props);
const configured = displayState.configured;
const accounts = resolveChannelAccounts(data.channelAccounts, key);
const showAccounts =
standardKey === "telegram" ? accounts.length > 1 : !standardKey && accounts.length > 0;
const extraRows =
standardKey === "googlechat"
? [
{
label: t("common.credential"),
value: data.googlechat?.credentialSource ?? t("common.na"),
},
{
label: t("common.audience"),
value: data.googlechat?.audienceType
? `${data.googlechat.audienceType}${data.googlechat.audience ? ` · ${data.googlechat.audience}` : ""}`
: t("common.na"),
},
]
: standardKey === "signal"
? [{ label: t("common.baseUrl"), value: data.signal?.baseUrl ?? t("common.na") }]
: standardKey === "telegram"
? [{ label: t("common.mode"), value: data.telegram?.mode ?? t("common.na") }]
: [];
const statusRows = [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: !standardKey
? formatNullableBoolean(displayState.running)
: standardKey === "googlechat" && !status
? t("common.na")
: formatNullableBoolean(status?.running ?? false),
kind: boolStatusKind(standardKey ? status?.running : displayState.running),
},
...(standardKey
? [
...extraRows,
...(["lastStartAt", "lastProbeAt"] as const).map((field) => ({
label: t(field === "lastStartAt" ? "common.lastStart" : "common.lastProbe"),
value: status?.[field] ? formatRelativeTimestamp(status[field]) : t("common.na"),
})),
]
: [
{
label: t("common.connected"),
value: formatNullableBoolean(displayState.connected),
kind: boolStatusKind(displayState.connected),
},
]),
];
const lastError = readStringField(
asNullableRecord(standardKey ? status : displayState.status),
"lastError",
);
return renderSettingsSection(
{
title: localeKey
? t(`channels.${localeKey}.title`)
: (readStringField(props.snapshot?.channelLabels, key) ?? key),
description: localeKey ? t(`channels.${localeKey}.subtitle`) : t("channels.generic.subtitle"),
...(accountCount !== undefined ? { count: accountCount } : {}),
},
html`
${showAccounts
? accounts.map((account) => {
const username =
standardKey === "telegram"
? readStringField(
asNullableRecord(asNullableRecord(account.probe)?.bot),
"username",
)
: undefined;
return renderChannelAccountRow({
title: username ? `@${username}` : account.name || account.accountId,
accountId: account.accountId,
...(standardKey === "telegram"
? {
facts: [
`${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`,
],
}
: {}),
status: {
kind: boolStatusKind(
standardKey === "telegram"
? account.running
: (account.running ?? account.configured),
),
label: account.running
? t("common.running")
: !standardKey && account.configured
? t("common.configured")
: t("common.no"),
},
lastInboundAt: account.lastInboundAt,
lastError: account.lastError,
});
})
: renderChannelFacts(statusRows)}
${lastError ? renderChannelErrorRow(lastError) : nothing}
${standardKey && status?.probe ? renderChannelProbeRow(status.probe) : nothing}
${renderChannelConfigSection({ channelId: key, props })}
${standardKey
? renderChannelActionRow(html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`)
: nothing}
`,
);
}
function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) {
const accountCount = resolveChannelAccountCount(key, data.channelAccounts);
switch (key) {
@@ -36,45 +176,8 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels
whatsapp: data.whatsapp,
accountCount,
});
case "telegram":
return renderTelegramCard({
props,
telegram: data.telegram,
telegramAccounts: data.channelAccounts?.telegram ?? [],
accountCount,
});
case "discord":
return renderDiscordCard({
props,
discord: data.discord,
accountCount,
});
case "googlechat":
return renderGoogleChatCard({
props,
googleChat: data.googlechat,
accountCount,
});
case "slack":
return renderSlackCard({
props,
slack: data.slack,
accountCount,
});
case "signal":
return renderSignalCard({
props,
signal: data.signal,
accountCount,
});
case "imessage":
return renderIMessageCard({
props,
imessage: data.imessage,
accountCount,
});
case "nostr": {
const nostrAccounts = data.channelAccounts?.nostr ?? [];
const nostrAccounts = resolveChannelAccounts(data.channelAccounts, "nostr");
const primaryAccount = nostrAccounts[0];
const accountId = primaryAccount?.accountId ?? "default";
const profile =
@@ -101,69 +204,10 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels
});
}
default:
return renderGenericChannelBody(key, props, data.channelAccounts ?? {});
return renderChannelStatusBody(key, props, data, accountCount);
}
}
function renderGenericChannelBody(
key: ChannelKey,
props: ChannelsProps,
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
) {
const label = props.snapshot?.channelLabels?.[key] ?? key;
const displayState = resolveChannelDisplayState(key, props);
const lastError =
typeof displayState.status?.lastError === "string" ? displayState.status.lastError : undefined;
const accounts = channelAccounts[key] ?? [];
const accountCount = resolveChannelAccountCount(key, channelAccounts);
return renderSettingsSection(
{
title: label,
description: t("channels.generic.subtitle"),
...(accountCount !== undefined ? { count: accountCount } : {}),
},
html`
${accounts.length > 0
? accounts.map((account) =>
renderChannelAccountRow({
title: account.name || account.accountId,
accountId: account.accountId,
status: {
kind: boolStatusKind(account.running ?? account.configured),
label: account.running
? t("common.running")
: account.configured
? t("common.configured")
: t("common.no"),
},
lastInboundAt: account.lastInboundAt,
lastError: account.lastError,
}),
)
: renderChannelFacts([
{
label: t("common.configured"),
value: formatNullableBoolean(displayState.configured),
kind: boolStatusKind(displayState.configured),
},
{
label: t("common.running"),
value: formatNullableBoolean(displayState.running),
kind: boolStatusKind(displayState.running),
},
{
label: t("common.connected"),
value: formatNullableBoolean(displayState.connected),
kind: boolStatusKind(displayState.connected),
},
])}
${lastError ? renderChannelErrorRow(lastError) : nothing}
${renderChannelConfigSection({ channelId: key, props })}
`,
);
}
export function renderChannelDetail(params: {
channelId: string;
label: string;
-55
View File
@@ -1,55 +0,0 @@
// Channels page renders Discord status.
import { html, nothing } from "lit";
import type { DiscordStatus } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelProbeRow,
renderSingleAccountChannelCard,
resolveChannelConfigured,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
export function renderDiscordCard(params: {
props: ChannelsProps;
discord?: DiscordStatus | null;
accountCount?: number;
}) {
const { props, discord, accountCount } = params;
const configured = resolveChannelConfigured("discord", props);
return renderSingleAccountChannelCard({
title: t("channels.discord.title"),
subtitle: t("channels.discord.subtitle"),
accountCount,
statusRows: [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: discord?.running ? t("common.yes") : t("common.no"),
kind: boolStatusKind(discord?.running),
},
{
label: t("common.lastStart"),
value: discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : t("common.na"),
},
{
label: t("common.lastProbe"),
value: discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : t("common.na"),
},
],
lastError: discord?.lastError,
secondaryCallout: discord?.probe ? renderChannelProbeRow(discord.probe) : nothing,
configSection: renderChannelConfigSection({ channelId: "discord", props }),
footer: html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
});
}
-70
View File
@@ -1,70 +0,0 @@
// Channels page renders Google Chat status.
import { html, nothing } from "lit";
import type { GoogleChatStatus } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelProbeRow,
renderSingleAccountChannelCard,
resolveChannelConfigured,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
export function renderGoogleChatCard(params: {
props: ChannelsProps;
googleChat?: GoogleChatStatus | null;
accountCount?: number;
}) {
const { props, googleChat, accountCount } = params;
const configured = resolveChannelConfigured("googlechat", props);
return renderSingleAccountChannelCard({
title: t("channels.googleChat.title"),
subtitle: t("channels.googleChat.subtitle"),
accountCount,
statusRows: [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: googleChat
? googleChat.running
? t("common.yes")
: t("common.no")
: t("common.na"),
kind: boolStatusKind(googleChat?.running),
},
{ label: t("common.credential"), value: googleChat?.credentialSource ?? t("common.na") },
{
label: t("common.audience"),
value: googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: t("common.na"),
},
{
label: t("common.lastStart"),
value: googleChat?.lastStartAt
? formatRelativeTimestamp(googleChat.lastStartAt)
: t("common.na"),
},
{
label: t("common.lastProbe"),
value: googleChat?.lastProbeAt
? formatRelativeTimestamp(googleChat.lastProbeAt)
: t("common.na"),
},
],
lastError: googleChat?.lastError,
secondaryCallout: googleChat?.probe ? renderChannelProbeRow(googleChat.probe) : nothing,
configSection: renderChannelConfigSection({ channelId: "googlechat", props }),
footer: html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
});
}
-59
View File
@@ -1,59 +0,0 @@
// Channels page renders iMessage status.
import { html, nothing } from "lit";
import type { IMessageStatus } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelProbeRow,
renderSingleAccountChannelCard,
resolveChannelConfigured,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
export function renderIMessageCard(params: {
props: ChannelsProps;
imessage?: IMessageStatus | null;
accountCount?: number;
}) {
const { props, imessage, accountCount } = params;
const configured = resolveChannelConfigured("imessage", props);
return renderSingleAccountChannelCard({
title: t("channels.imessage.title"),
subtitle: t("channels.imessage.subtitle"),
accountCount,
statusRows: [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: imessage?.running ? t("common.yes") : t("common.no"),
kind: boolStatusKind(imessage?.running),
},
{
label: t("common.lastStart"),
value: imessage?.lastStartAt
? formatRelativeTimestamp(imessage.lastStartAt)
: t("common.na"),
},
{
label: t("common.lastProbe"),
value: imessage?.lastProbeAt
? formatRelativeTimestamp(imessage.lastProbeAt)
: t("common.na"),
},
],
lastError: imessage?.lastError,
secondaryCallout: imessage?.probe ? renderChannelProbeRow(imessage.probe) : nothing,
configSection: renderChannelConfigSection({ channelId: "imessage", props }),
footer: html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
});
}
+11 -13
View File
@@ -1,9 +1,10 @@
// Channels page shared view helpers.
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../../api/types.ts";
import { renderSettingsSection, renderSettingsStatus } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { channelSnapshotEntryIsActive } from "../../lib/channels/index.ts";
import { channelSnapshotEntryIsActive, resolveChannelAccounts } from "../../lib/channels/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import type { ChannelKey, ChannelsProps } from "./view.types.ts";
@@ -28,16 +29,20 @@ function resolveChannelStatus(
key: ChannelKey,
props: ChannelsProps,
): Record<string, unknown> | undefined {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
return channels?.[key] as Record<string, unknown> | undefined;
const channels = props.snapshot?.channels;
return channels && Object.hasOwn(channels, key)
? (asNullableRecord(channels[key]) ?? undefined)
: undefined;
}
function resolveDefaultChannelAccount(
key: ChannelKey,
props: ChannelsProps,
): ChannelAccountSnapshot | null {
const accounts = props.snapshot?.channelAccounts?.[key] ?? [];
const defaultAccountId = props.snapshot?.channelDefaultAccountId?.[key];
const accounts = resolveChannelAccounts(props.snapshot?.channelAccounts, key);
const defaultAccountIds = props.snapshot?.channelDefaultAccountId;
const defaultAccountId =
defaultAccountIds && Object.hasOwn(defaultAccountIds, key) ? defaultAccountIds[key] : undefined;
return (
(defaultAccountId
? accounts.find((account) => account.accountId === defaultAccountId)
@@ -219,18 +224,11 @@ export function renderSingleAccountChannelCard(params: {
);
}
function getChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number {
return channelAccounts?.[key]?.length ?? 0;
}
/** Multi-account channels surface the account count next to the heading. */
export function resolveChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number | undefined {
const count = getChannelAccountCount(key, channelAccounts);
const count = resolveChannelAccounts(channelAccounts, key).length;
return count >= 2 ? count : undefined;
}
-56
View File
@@ -1,56 +0,0 @@
// Channels page renders Signal status.
import { html, nothing } from "lit";
import type { SignalStatus } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelProbeRow,
renderSingleAccountChannelCard,
resolveChannelConfigured,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
export function renderSignalCard(params: {
props: ChannelsProps;
signal?: SignalStatus | null;
accountCount?: number;
}) {
const { props, signal, accountCount } = params;
const configured = resolveChannelConfigured("signal", props);
return renderSingleAccountChannelCard({
title: t("channels.signal.title"),
subtitle: t("channels.signal.subtitle"),
accountCount,
statusRows: [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: signal?.running ? t("common.yes") : t("common.no"),
kind: boolStatusKind(signal?.running),
},
{ label: t("common.baseUrl"), value: signal?.baseUrl ?? t("common.na") },
{
label: t("common.lastStart"),
value: signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : t("common.na"),
},
{
label: t("common.lastProbe"),
value: signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : t("common.na"),
},
],
lastError: signal?.lastError,
secondaryCallout: signal?.probe ? renderChannelProbeRow(signal.probe) : nothing,
configSection: renderChannelConfigSection({ channelId: "signal", props }),
footer: html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
});
}
-55
View File
@@ -1,55 +0,0 @@
// Channels page renders Slack status.
import { html, nothing } from "lit";
import type { SlackStatus } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelProbeRow,
renderSingleAccountChannelCard,
resolveChannelConfigured,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
export function renderSlackCard(params: {
props: ChannelsProps;
slack?: SlackStatus | null;
accountCount?: number;
}) {
const { props, slack, accountCount } = params;
const configured = resolveChannelConfigured("slack", props);
return renderSingleAccountChannelCard({
title: t("channels.slack.title"),
subtitle: t("channels.slack.subtitle"),
accountCount,
statusRows: [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: slack?.running ? t("common.yes") : t("common.no"),
kind: boolStatusKind(slack?.running),
},
{
label: t("common.lastStart"),
value: slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : t("common.na"),
},
{
label: t("common.lastProbe"),
value: slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : t("common.na"),
},
],
lastError: slack?.lastError,
secondaryCallout: slack?.probe ? renderChannelProbeRow(slack.probe) : nothing,
configSection: renderChannelConfigSection({ channelId: "slack", props }),
footer: html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
});
}
-106
View File
@@ -1,106 +0,0 @@
// Channels page renders Telegram status.
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot, TelegramStatus } from "../../api/types.ts";
import { renderSettingsSection } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
boolStatusKind,
formatNullableBoolean,
renderChannelAccountRow,
renderChannelActionRow,
renderChannelErrorRow,
renderChannelProbeRow,
renderSingleAccountChannelCard,
resolveChannelConfigured,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
export function renderTelegramCard(params: {
props: ChannelsProps;
telegram?: TelegramStatus;
telegramAccounts: ChannelAccountSnapshot[];
accountCount?: number;
}) {
const { props, telegram, telegramAccounts, accountCount } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
const configured = resolveChannelConfigured("telegram", props);
const renderAccountRow = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
const botUsername = probe?.bot?.username;
const label = account.name || account.accountId;
return renderChannelAccountRow({
title: botUsername ? `@${botUsername}` : label,
accountId: account.accountId,
facts: [
`${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`,
],
status: {
kind: boolStatusKind(account.running),
label: account.running ? t("common.running") : t("common.no"),
},
lastInboundAt: account.lastInboundAt,
lastError: account.lastError,
});
};
if (hasMultipleAccounts) {
return renderSettingsSection(
{
title: t("channels.telegram.title"),
description: t("channels.telegram.subtitle"),
...(accountCount !== undefined ? { count: accountCount } : {}),
},
html`
${telegramAccounts.map((account) => renderAccountRow(account))}
${telegram?.lastError ? renderChannelErrorRow(telegram.lastError) : nothing}
${telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
${renderChannelActionRow(
html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
)}
`,
);
}
return renderSingleAccountChannelCard({
title: t("channels.telegram.title"),
subtitle: t("channels.telegram.subtitle"),
accountCount,
statusRows: [
{
label: t("common.configured"),
value: formatNullableBoolean(configured),
kind: boolStatusKind(configured),
},
{
label: t("common.running"),
value: telegram?.running ? t("common.yes") : t("common.no"),
kind: boolStatusKind(telegram?.running),
},
{ label: t("common.mode"), value: telegram?.mode ?? t("common.na") },
{
label: t("common.lastStart"),
value: telegram?.lastStartAt
? formatRelativeTimestamp(telegram.lastStartAt)
: t("common.na"),
},
{
label: t("common.lastProbe"),
value: telegram?.lastProbeAt
? formatRelativeTimestamp(telegram.lastProbeAt)
: t("common.na"),
},
],
lastError: telegram?.lastError,
secondaryCallout: telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing,
configSection: renderChannelConfigSection({ channelId: "telegram", props }),
footer: html`<button class="btn" @click=${() => props.onRefresh(true)}>
${t("common.probe")}
</button>`,
});
}
+133 -1
View File
@@ -8,7 +8,8 @@ import {
resolveChannelConfigured,
resolveChannelDisplayState,
} from "./view.shared.ts";
import type { ChannelsProps } from "./view.types.ts";
import { renderChannels } from "./view.ts";
import type { ChannelsChannelData, ChannelsProps } from "./view.types.ts";
import { renderWhatsAppCard } from "./view.whatsapp.ts";
function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
@@ -122,6 +123,40 @@ function renderWhatsAppButtons(params: {
};
}
function renderChannelDetailFixture(
channelId: string,
data: ChannelsChannelData,
options: { label?: string; onRefresh?: ChannelsProps["onRefresh"] } = {},
) {
const status = Object.entries(data).find(([key]) => key === channelId)?.[1] ?? {};
const channelAccounts = data.channelAccounts ?? {};
const accounts = Object.hasOwn(channelAccounts, channelId) ? channelAccounts[channelId] : [];
const props = createProps({
ts: Date.now(),
channelOrder: [channelId],
channelLabels: { [channelId]: options.label ?? channelId },
channels: { [channelId]: status },
channelAccounts,
channelDefaultAccountId: accounts?.length ? { [channelId]: accounts[0]!.accountId } : {},
});
if (options.onRefresh) {
props.onRefresh = options.onRefresh;
}
const container = document.createElement("div");
render(
renderChannelDetail({
channelId,
label: options.label ?? channelId,
props,
data: { ...data, channelAccounts },
onClose: () => {},
onSetup: () => {},
}),
container,
);
return container;
}
// Mirrors the tiers the gateway materializes on every channel schema path.
const CHANNEL_TIER_SCHEMA = {
type: "object",
@@ -257,6 +292,103 @@ describe("channel detail", () => {
expect(docs?.href).toBe("https://docs.openclaw.ai/channels/telegram");
expect(docs?.textContent?.trim()).toBe("Docs");
});
it.each([
["discord", "Discord", []],
["slack", "Slack", []],
["signal", "Signal", [["Base URL", "https://signal.example"]]],
["imessage", "iMessage", []],
[
"googlechat",
"Google Chat",
[
["Credential", "service-account"],
["Audience", "url · https://chat.example"],
],
],
["telegram", "Telegram", [["Mode", "polling"]]],
] satisfies Array<[string, string, Array<[string, string]>]>)(
"preserves localized status facts and probe actions for %s",
(channelId, title, extraFacts) => {
const onRefresh = vi.fn();
const status = {
configured: true,
running: true,
baseUrl: "https://signal.example",
credentialSource: "service-account",
audienceType: "url",
audience: "https://chat.example",
mode: "polling",
};
const data: ChannelsChannelData = { channelAccounts: {}, [channelId]: status };
const container = renderChannelDetailFixture(channelId, data, { onRefresh });
const facts = Array.from(container.querySelectorAll("dt"), (node) => [
node.textContent?.trim(),
node.nextElementSibling?.textContent?.trim(),
]);
expect(container.querySelector(".settings-section__heading")?.textContent?.trim()).toBe(
title,
);
expect(facts).toEqual([
["Configured", "Yes"],
["Running", "Yes"],
...extraFacts,
["Last start", "n/a"],
["Last probe", "n/a"],
]);
container.querySelector<HTMLButtonElement>(".settings-row--actions button")!.click();
expect(onRefresh).toHaveBeenCalledWith(true);
},
);
it("keeps missing Google Chat status unknown while other known channels are stopped", () => {
const google = renderChannelDetailFixture("googlechat", { googlechat: null });
const discord = renderChannelDetailFixture("discord", { discord: null });
const fact = (container: HTMLElement, label: string) =>
Array.from(container.querySelectorAll("dt"))
.find((node) => node.textContent?.trim() === label)
?.nextElementSibling?.textContent?.trim();
expect(fact(google, "Running")).toBe("n/a");
expect(fact(discord, "Running")).toBe("No");
});
it.each(["guildchat", "constructor", "__proto__"])(
"opens accountless plugin %s from its actual hub row without inherited account values",
(channelId) => {
for (const configured of [false, true]) {
const props = createProps({
ts: Date.now(),
channelOrder: [channelId],
channelLabels: { [channelId]: "Custom channel" },
channels: { [channelId]: { configured, running: configured } },
channelAccounts: {},
channelDefaultAccountId: {},
});
const container = document.createElement("div");
props.onShowDetail = (selected) => {
props.selectedChannel = selected;
render(renderChannels(props), container);
};
render(renderChannels(props), container);
const trigger = container.querySelector<HTMLButtonElement>(
configured ? "button.channels-item" : ".channels-item__detail",
);
expect(trigger).toBeInstanceOf(HTMLButtonElement);
trigger!.click();
const detail = container.querySelector(".channels-detail");
expect(detail?.querySelector(".settings-section__heading")?.textContent?.trim()).toBe(
"Custom channel",
);
expect(detail?.textContent).toContain("Channel status and configuration.");
expect(
Array.from(detail!.querySelectorAll("dt"), (node) => node.textContent?.trim()),
).toEqual(["Configured", "Running", "Connected"]);
}
},
);
});
describe("channel display selectors", () => {
+18 -21
View File
@@ -3,9 +3,7 @@
import { html, nothing } from "lit";
import "../../styles/channels.css";
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ChannelUiMetaEntry,
DiscordStatus,
GoogleChatStatus,
IMessageStatus,
@@ -24,6 +22,7 @@ import {
renderSettingsStatus,
} from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { resolveChannelAccounts } from "../../lib/channels/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelArt } from "./hub-meta.ts";
import { renderChannelDetail } from "./view.detail.ts";
@@ -156,26 +155,23 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"];
}
function resolveChannelMetaMap(
snapshot: ChannelsStatusSnapshot | null,
): Record<string, ChannelUiMetaEntry> {
if (!snapshot?.channelMeta?.length) {
return {};
}
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
}
function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string {
const meta = resolveChannelMetaMap(snapshot)[key];
return meta?.label ?? snapshot?.channelLabels?.[key] ?? key;
const labels = snapshot?.channelLabels;
return (
snapshot?.channelMeta?.find((entry) => entry.id === key)?.label ??
(labels && Object.hasOwn(labels, key) ? labels[key] : undefined) ??
key
);
}
function resolveChannelDetailLabel(
snapshot: ChannelsStatusSnapshot | null,
key: string,
): string | null {
const meta = resolveChannelMetaMap(snapshot)[key];
const detail = meta?.detailLabel ?? snapshot?.channelDetailLabels?.[key] ?? null;
const labels = snapshot?.channelDetailLabels;
const detail =
snapshot?.channelMeta?.find((entry) => entry.id === key)?.detailLabel ??
(labels && Object.hasOwn(labels, key) ? labels[key] : null);
return detail && detail !== resolveChannelLabel(snapshot, key) ? detail : null;
}
@@ -184,8 +180,9 @@ function resolveRowState(key: ChannelKey, props: ChannelsProps): ChannelCardStat
const lastError =
typeof displayState.status?.lastError === "string" && displayState.status.lastError.trim()
? displayState.status.lastError
: (props.snapshot?.channelAccounts?.[key] ?? []).find((account) => account.lastError)
?.lastError;
: resolveChannelAccounts(props.snapshot?.channelAccounts, key).find(
(account) => account.lastError,
)?.lastError;
if (lastError) {
return "attention";
}
@@ -209,10 +206,10 @@ function rowStatus(state: ChannelCardState) {
}
function lastActivityLine(key: ChannelKey, props: ChannelsProps): string | null {
const accounts: ChannelAccountSnapshot[] = props.snapshot?.channelAccounts?.[key] ?? [];
const lastInbound = accounts
.map((account) => account.lastInboundAt ?? 0)
.reduce((a, b) => Math.max(a, b), 0);
const lastInbound = resolveChannelAccounts(props.snapshot?.channelAccounts, key).reduce(
(latest, account) => Math.max(latest, account.lastInboundAt ?? 0),
0,
);
if (!lastInbound) {
return null;
}
@@ -38,7 +38,6 @@ async function requestWithTimeout<T>(
}
}
export type ChannelWizardStepOption = NonNullable<WizardStep["options"]>[number];
export type ChannelWizardStep = WizardStep;
type WizardNextResult = {
+22 -166
View File
@@ -3,15 +3,12 @@
import "@awesome.me/webawesome/dist/components/radio/radio.js";
import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js";
import { html, nothing, type TemplateResult } from "lit";
import { renderWizardStepControls } from "../../components/wizard-step-controls.ts";
import { t } from "../../i18n/index.ts";
import "../../components/modal-dialog.ts";
import { copyToClipboard } from "../../lib/clipboard.ts";
import { channelDocsUrl, channelHubMeta, renderChannelArt } from "./hub-meta.ts";
import type {
ChannelWizardState,
ChannelWizardStep,
ChannelWizardStepOption,
} from "./wizard-controller.ts";
import type { ChannelWizardState, ChannelWizardStep } from "./wizard-controller.ts";
type ChannelWizardViewProps = {
wizard: ChannelWizardState;
@@ -30,10 +27,6 @@ type ChannelWizardViewProps = {
onWhatsAppWait: () => void;
};
function stepKeyboardValue(step: ChannelWizardStep): string {
return typeof step.initialValue === "string" ? step.initialValue : "";
}
function stepIsBusy(props: ChannelWizardViewProps): boolean {
return props.wizard.phase === "step" && props.wizard.busy;
}
@@ -72,151 +65,20 @@ function renderNoteStep(step: ChannelWizardStep, props: ChannelWizardViewProps)
`;
}
function renderSelectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
const options = step.options ?? [];
const selectedIndex = options.findIndex((option) => option.value === step.initialValue);
return html`
<wa-radio-group
class="channels-wizard__options"
label=${step.message ?? ""}
orientation="vertical"
.value=${selectedIndex >= 0 ? String(selectedIndex) : null}
?disabled=${stepIsBusy(props)}
@change=${(event: Event) => {
const rawIndex = (event.currentTarget as HTMLElement & { value?: string | number | null })
.value;
const option = options[Number(rawIndex)];
if (option) {
props.onAnswer(option.value);
}
}}
>
${options.map(
(option: ChannelWizardStepOption, index) => html`
<wa-radio
class="channels-wizard__option"
appearance="button"
value=${String(index)}
.checked=${index === selectedIndex}
>
<span class="channels-wizard__option-label">${option.label}</span>
${option.hint
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
: nothing}
</wa-radio>
`,
)}
</wa-radio-group>
`;
}
function renderMultiselectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
const options = step.options ?? [];
const selected = new Set(props.multiselectValues);
return html`
<div class="channels-wizard__message">${step.message ?? ""}</div>
<div class="channels-wizard__options">
${options.map(
(option: ChannelWizardStepOption) => html`
<button
type="button"
class="channels-wizard__option"
aria-pressed=${selected.has(option.value) ? "true" : "false"}
?disabled=${stepIsBusy(props)}
@click=${() => props.onToggleMultiselect(option.value)}
>
<span class="channels-wizard__option-label">
${selected.has(option.value) ? "☑" : "☐"} ${option.label}
</span>
${option.hint
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
: nothing}
</button>
`,
)}
</div>
<div class="channels-wizard__footer">
<button
type="button"
class="btn primary"
?disabled=${stepIsBusy(props)}
@click=${() => props.onAnswer([...props.multiselectValues])}
>
${t("channels.setup.continue")}
</button>
</div>
`;
}
function renderTextStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
const submit = (event: Event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const input = form.elements.namedItem("wizard-text") as HTMLInputElement | null;
props.onAnswer(input?.value ?? "");
};
return html`
<form @submit=${submit}>
<div class="channels-wizard__message">
<label for="channel-wizard-text-input">${step.message ?? ""}</label>
</div>
<input
id="channel-wizard-text-input"
class="input"
style="margin-top: 10px; width: 100%;"
name="wizard-text"
type=${step.sensitive ? "password" : "text"}
autocomplete=${step.sensitive ? "off" : "on"}
placeholder=${step.placeholder ?? ""}
.value=${stepKeyboardValue(step)}
?disabled=${stepIsBusy(props)}
/>
<div class="channels-wizard__footer" style="margin-top: 12px;">
<button type="submit" class="btn primary" ?disabled=${stepIsBusy(props)}>
${t("channels.setup.continue")}
</button>
</div>
</form>
`;
}
function renderConfirmStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
return html`
<div class="channels-wizard__message">${step.message ?? ""}</div>
<div class="channels-wizard__footer">
<button
type="button"
class="btn"
?disabled=${stepIsBusy(props)}
@click=${() => props.onAnswer(false)}
>
${t("common.no")}
</button>
<button
type="button"
class="btn primary"
?disabled=${stepIsBusy(props)}
@click=${() => props.onAnswer(true)}
>
${t("common.yes")}
</button>
</div>
`;
}
function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps) {
switch (step.type) {
case "select":
return renderSelectStep(step, props);
case "multiselect":
return renderMultiselectStep(step, props);
case "text":
return renderTextStep(step, props);
case "confirm":
return renderConfirmStep(step, props);
default:
return renderNoteStep(step, props);
if (step.type === "note" || step.type === "progress" || step.type === "action") {
return renderNoteStep(step, props);
}
return renderWizardStepControls({
step,
value: step.type === "multiselect" ? props.multiselectValues : step.initialValue,
busy: stepIsBusy(props),
inputId: "channel-wizard-text-input",
presentation: "channels",
answerLabel: t("channels.setup.continue"),
onValueChange: props.onToggleMultiselect,
onAnswer: props.onAnswer,
});
}
function renderWhatsAppLinking(props: ChannelWizardViewProps) {
@@ -285,23 +147,17 @@ function renderDoneBody(channels: readonly string[], props: ChannelWizardViewPro
if (channels.includes("whatsapp")) {
return renderWhatsAppLinking(props);
}
if (channels.length === 0) {
return html`
<div class="channels-wizard__message">${t("channels.setup.doneNoChangesTitle")}</div>
<div class="channels-wizard__note">${t("channels.setup.doneNoChangesBody")}</div>
<div class="channels-wizard__footer">
<button type="button" class="btn primary" @click=${() => props.onClose()}>
${t("common.close")}
</button>
</div>
`;
}
const changed = channels.length > 0;
return html`
<div class="channels-wizard__message">${t("channels.setup.doneTitle")}</div>
<div class="channels-wizard__note">${t("channels.setup.doneBody")}</div>
<div class="channels-wizard__message">
${t(changed ? "channels.setup.doneTitle" : "channels.setup.doneNoChangesTitle")}
</div>
<div class="channels-wizard__note">
${t(changed ? "channels.setup.doneBody" : "channels.setup.doneNoChangesBody")}
</div>
<div class="channels-wizard__footer">
<button type="button" class="btn primary" @click=${() => props.onClose()}>
${t("channels.setup.finish")}
${t(changed ? "channels.setup.finish" : "common.close")}
</button>
</div>
`;