`
: 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)}
/>
-
+ ${renderAnswerButton(props, t("modelSetup.wizard.submit"))}
`;
}
-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`
+
+ `;
+ }
+ const answer = multiple
+ ? props.presentation === "channels"
+ ? [...selected]
+ : selected
+ : props.value;
return html`
- ${renderMessage(props.step)}
-
- ${(props.step.options ?? []).map(
- (option) => html`
-
- `,
- )}
+ ${renderMessage(props)}
+
+ ${options.map((option, index) => renderOption(props, option, index, selected))}
-
+ ${renderAnswerButton(
+ props,
+ t("modelSetup.wizard.continue"),
+ () => props.onAnswer(answer),
+ props.busy || (!multiple && props.value === undefined),
+ )}
`;
}
function renderConfirmStep(props: WizardStepControlsProps) {
return html`
- ${renderMessage(props.step)}
-
-
-
-
- `;
-}
-
-function renderMultiselectStep(props: WizardStepControlsProps) {
- const selected = Array.isArray(props.value) ? props.value : [];
- return html`
- ${renderMessage(props.step)}
-
- ${(props.step.options ?? []).map(
- (option) => html`
-
- `,
+ ${renderMessage(props)}
+
+ ${[false, true].map(
+ (answer) => html``,
)}
-
`;
}
@@ -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":
diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts
index b8a8572221db..086bf1e2fe2c 100644
--- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts
+++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts
@@ -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
> = {
+ 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 = {}) => ({
+ 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 = {
+ 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();
+ }
+ });
});
diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts
index ee0a5b188267..68cf7e7f94be 100644
--- a/ui/src/lib/channels/index.ts
+++ b/ui/src/lib/channels/index.ts
@@ -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 =
};
}
-function delay(ms: number): Promise<"timeout"> {
+function delay(ms: number): Promise {
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(
diff --git a/ui/src/pages/channels/view.detail.ts b/ui/src/pages/channels/view.detail.ts
index 5108524cf93e..84d565f87d69 100644
--- a/ui/src/pages/channels/view.detail.ts
+++ b/ui/src/pages/channels/view.detail.ts
@@ -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``)
+ : 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,
-) {
- 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;
diff --git a/ui/src/pages/channels/view.discord.ts b/ui/src/pages/channels/view.discord.ts
deleted file mode 100644
index 78d6047bd467..000000000000
--- a/ui/src/pages/channels/view.discord.ts
+++ /dev/null
@@ -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``,
- });
-}
diff --git a/ui/src/pages/channels/view.googlechat.ts b/ui/src/pages/channels/view.googlechat.ts
deleted file mode 100644
index 2f06c0fadb8d..000000000000
--- a/ui/src/pages/channels/view.googlechat.ts
+++ /dev/null
@@ -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``,
- });
-}
diff --git a/ui/src/pages/channels/view.imessage.ts b/ui/src/pages/channels/view.imessage.ts
deleted file mode 100644
index ad4e05169054..000000000000
--- a/ui/src/pages/channels/view.imessage.ts
+++ /dev/null
@@ -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``,
- });
-}
diff --git a/ui/src/pages/channels/view.shared.ts b/ui/src/pages/channels/view.shared.ts
index 54c3342980a8..40fc8bfd8a41 100644
--- a/ui/src/pages/channels/view.shared.ts
+++ b/ui/src/pages/channels/view.shared.ts
@@ -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 | undefined {
- const channels = props.snapshot?.channels as Record | null;
- return channels?.[key] as Record | 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 | 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 | null,
): number | undefined {
- const count = getChannelAccountCount(key, channelAccounts);
+ const count = resolveChannelAccounts(channelAccounts, key).length;
return count >= 2 ? count : undefined;
}
diff --git a/ui/src/pages/channels/view.signal.ts b/ui/src/pages/channels/view.signal.ts
deleted file mode 100644
index e94b32de2b5f..000000000000
--- a/ui/src/pages/channels/view.signal.ts
+++ /dev/null
@@ -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``,
- });
-}
diff --git a/ui/src/pages/channels/view.slack.ts b/ui/src/pages/channels/view.slack.ts
deleted file mode 100644
index 4c9457af36ac..000000000000
--- a/ui/src/pages/channels/view.slack.ts
+++ /dev/null
@@ -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``,
- });
-}
diff --git a/ui/src/pages/channels/view.telegram.ts b/ui/src/pages/channels/view.telegram.ts
deleted file mode 100644
index 2a32597e39c3..000000000000
--- a/ui/src/pages/channels/view.telegram.ts
+++ /dev/null
@@ -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``,
- )}
- `,
- );
- }
-
- 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``,
- });
-}
diff --git a/ui/src/pages/channels/view.test.ts b/ui/src/pages/channels/view.test.ts
index 6c3386f7878c..5285dfea8a94 100644
--- a/ui/src/pages/channels/view.test.ts
+++ b/ui/src/pages/channels/view.test.ts
@@ -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(".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(
+ 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", () => {
diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts
index 2276f10e0842..0e9caaf5c992 100644
--- a/ui/src/pages/channels/view.ts
+++ b/ui/src/pages/channels/view.ts
@@ -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 {
- 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;
}
diff --git a/ui/src/pages/channels/wizard-controller.ts b/ui/src/pages/channels/wizard-controller.ts
index 2cba18ccbff5..474febd85610 100644
--- a/ui/src/pages/channels/wizard-controller.ts
+++ b/ui/src/pages/channels/wizard-controller.ts
@@ -38,7 +38,6 @@ async function requestWithTimeout(
}
}
-export type ChannelWizardStepOption = NonNullable[number];
export type ChannelWizardStep = WizardStep;
type WizardNextResult = {
diff --git a/ui/src/pages/channels/wizard-view.ts b/ui/src/pages/channels/wizard-view.ts
index 2feabe472b07..04e1eca53f88 100644
--- a/ui/src/pages/channels/wizard-view.ts
+++ b/ui/src/pages/channels/wizard-view.ts
@@ -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`
- = 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`
-
- ${option.label}
- ${option.hint
- ? html`${option.hint}`
- : nothing}
-
- `,
- )}
-
- `;
-}
-
-function renderMultiselectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
- const options = step.options ?? [];
- const selected = new Set(props.multiselectValues);
- return html`
- ${step.message ?? ""}
-
- ${options.map(
- (option: ChannelWizardStepOption) => html`
-
- `,
- )}
-
-
- `;
-}
-
-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`
-
- `;
-}
-
-function renderConfirmStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
- return html`
- ${step.message ?? ""}
-
- `;
-}
-
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`
- ${t("channels.setup.doneNoChangesTitle")}
- ${t("channels.setup.doneNoChangesBody")}
-
- `;
- }
+ const changed = channels.length > 0;
return html`
- ${t("channels.setup.doneTitle")}
- ${t("channels.setup.doneBody")}
+
+ ${t(changed ? "channels.setup.doneTitle" : "channels.setup.doneNoChangesTitle")}
+
+
+ ${t(changed ? "channels.setup.doneBody" : "channels.setup.doneNoChangesBody")}
+
`;