refactor(ui): split config and connections views
This commit is contained in:
@@ -19,8 +19,5 @@
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "4.0.17"
|
||||
},
|
||||
"pnpm": {
|
||||
"minimumReleaseAge": 2880
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import {
|
||||
cloneConfigObject,
|
||||
removePathValue,
|
||||
serializeConfigForm,
|
||||
setPathValue,
|
||||
} from "./config/form-utils";
|
||||
|
||||
export type ConfigState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -477,84 +483,3 @@ export function removeConfigFormValue(
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneConfigObject<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function serializeConfigForm(form: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const nextKey = path[i + 1];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
if (current[key] == null) {
|
||||
current[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record[key] == null) {
|
||||
record[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = record[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {
|
||||
current[lastKey] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
(current as Record<string, unknown>)[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function removePathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
current = (current as Record<string, unknown>)[key] as
|
||||
| Record<string, unknown>
|
||||
| unknown[];
|
||||
}
|
||||
if (current == null) return;
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {
|
||||
current.splice(lastKey, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
delete (current as Record<string, unknown>)[lastKey];
|
||||
}
|
||||
}
|
||||
|
||||
77
ui/src/ui/controllers/config/form-utils.ts
Normal file
77
ui/src/ui/controllers/config/form-utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export function cloneConfigObject<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function serializeConfigForm(form: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const nextKey = path[i + 1];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
if (current[key] == null) {
|
||||
current[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record[key] == null) {
|
||||
record[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = record[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) current[lastKey] = value;
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
(current as Record<string, unknown>)[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function removePathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
current = (current as Record<string, unknown>)[key] as
|
||||
| Record<string, unknown>
|
||||
| unknown[];
|
||||
}
|
||||
if (current == null) return;
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) current.splice(lastKey, 1);
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
delete (current as Record<string, unknown>)[lastKey];
|
||||
}
|
||||
}
|
||||
|
||||
28
ui/src/ui/views/connections.action-options.ts
Normal file
28
ui/src/ui/views/connections.action-options.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { DiscordActionForm, SlackActionForm } from "../ui-types";
|
||||
|
||||
export const discordActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
|
||||
|
||||
export const slackActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "emojiList", label: "Emoji list" },
|
||||
] satisfies Array<{ key: keyof SlackActionForm; label: string }>;
|
||||
|
||||
71
ui/src/ui/views/connections.shared.ts
Normal file
71
ui/src/ui/views/connections.shared.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type {
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type { ChannelAccountSnapshot } from "../types";
|
||||
import type { ChannelKey, ConnectionsProps } from "./connections.types";
|
||||
|
||||
export function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
}
|
||||
|
||||
export function channelEnabled(key: ChannelKey, props: ConnectionsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
if (!snapshot || !channels) return false;
|
||||
const whatsapp = channels.whatsapp as WhatsAppStatus | undefined;
|
||||
const telegram = channels.telegram as TelegramStatus | undefined;
|
||||
const discord = (channels.discord ?? null) as DiscordStatus | null;
|
||||
const slack = (channels.slack ?? null) as SlackStatus | null;
|
||||
const signal = (channels.signal ?? null) as SignalStatus | null;
|
||||
const imessage = (channels.imessage ?? null) as IMessageStatus | null;
|
||||
switch (key) {
|
||||
case "whatsapp":
|
||||
return (
|
||||
Boolean(whatsapp?.configured) ||
|
||||
Boolean(whatsapp?.linked) ||
|
||||
Boolean(whatsapp?.running)
|
||||
);
|
||||
case "telegram":
|
||||
return Boolean(telegram?.configured) || Boolean(telegram?.running);
|
||||
case "discord":
|
||||
return Boolean(discord?.configured || discord?.running);
|
||||
case "slack":
|
||||
return Boolean(slack?.configured || slack?.running);
|
||||
case "signal":
|
||||
return Boolean(signal?.configured || signal?.running);
|
||||
case "imessage":
|
||||
return Boolean(imessage?.configured || imessage?.running);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
): number {
|
||||
return channelAccounts?.[key]?.length ?? 0;
|
||||
}
|
||||
|
||||
export function renderChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
) {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
if (count < 2) return nothing;
|
||||
return html`<div class="account-count">Accounts (${count})</div>`;
|
||||
}
|
||||
|
||||
248
ui/src/ui/views/connections.telegram.ts
Normal file
248
ui/src/ui/views/connections.telegram.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
|
||||
export function renderTelegramCard(params: {
|
||||
props: ConnectionsProps;
|
||||
telegram?: TelegramStatus;
|
||||
telegramAccounts: ChannelAccountSnapshot[];
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, telegram, telegramAccounts, accountCountLabel } = params;
|
||||
const hasMultipleAccounts = telegramAccounts.length > 1;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
||||
const botUsername = probe?.bot?.username;
|
||||
const label = account.name || account.accountId;
|
||||
return html`
|
||||
<div class="account-card">
|
||||
<div class="account-card-header">
|
||||
<div class="account-card-title">
|
||||
${botUsername ? `@${botUsername}` : label}
|
||||
</div>
|
||||
<div class="account-card-id">${account.accountId}</div>
|
||||
</div>
|
||||
<div class="status-list account-card-status">
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${account.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${account.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${account.lastError
|
||||
? html`
|
||||
<div class="account-card-error">
|
||||
${account.lastError}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot token and delivery options.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${telegram?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Mode</span>
|
||||
<span>${telegram?.mode ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${telegram?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${telegram.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${telegram?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Bot token</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.telegramForm.token}
|
||||
?disabled=${props.telegramTokenLocked}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
token: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Apply default group rules</span>
|
||||
<select
|
||||
.value=${props.telegramForm.groupsWildcardEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
groupsWildcardEnabled:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="no">No</option>
|
||||
<option value="yes">Yes (allow all groups)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention in groups</span>
|
||||
<select
|
||||
.value=${props.telegramForm.requireMention ? "yes" : "no"}
|
||||
?disabled=${!props.telegramForm.groupsWildcardEnabled}
|
||||
@change=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
requireMention: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow from</span>
|
||||
<input
|
||||
.value=${props.telegramForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="123456789, @team, tg:123"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Proxy</span>
|
||||
<input
|
||||
.value=${props.telegramForm.proxy}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
proxy: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="socks5://localhost:9050"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook URL</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookUrl}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookUrl: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="https://example.com/telegram-webhook"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook secret</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookSecret}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookSecret: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="secret"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook path</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookPath}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookPath: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="/telegram-webhook"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="callout" style="margin-top: 12px;">
|
||||
Allow from supports numeric user IDs (recommended) or @usernames. DM the bot
|
||||
to get your ID, or run /whoami.
|
||||
</div>
|
||||
|
||||
${props.telegramTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.telegramForm.groupsWildcardEnabled
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
This writes telegram.groups["*"] and allows all groups. Remove it
|
||||
if you only want specific groups.
|
||||
<div class="row" style="margin-top: 8px;">
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => props.onTelegramChange({ groupsWildcardEnabled: false })}
|
||||
>
|
||||
Remove wildcard
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.telegramStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.telegramStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.telegramSaving}
|
||||
@click=${() => props.onTelegramSave()}
|
||||
>
|
||||
${props.telegramSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -12,85 +12,21 @@ import type {
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type {
|
||||
DiscordActionForm,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SlackActionForm,
|
||||
SlackForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
const discordActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
|
||||
|
||||
const slackActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "emojiList", label: "Emoji list" },
|
||||
] satisfies Array<{ key: keyof SlackActionForm; label: string }>;
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
snapshot: ChannelsStatusSnapshot | null;
|
||||
lastError: string | null;
|
||||
lastSuccessAt: number | null;
|
||||
whatsappMessage: string | null;
|
||||
whatsappQrDataUrl: string | null;
|
||||
whatsappConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
telegramForm: TelegramForm;
|
||||
telegramTokenLocked: boolean;
|
||||
telegramSaving: boolean;
|
||||
telegramStatus: string | null;
|
||||
discordForm: DiscordForm;
|
||||
discordTokenLocked: boolean;
|
||||
discordSaving: boolean;
|
||||
discordStatus: string | null;
|
||||
slackForm: SlackForm;
|
||||
slackTokenLocked: boolean;
|
||||
slackAppTokenLocked: boolean;
|
||||
slackSaving: boolean;
|
||||
slackStatus: string | null;
|
||||
signalForm: SignalForm;
|
||||
signalSaving: boolean;
|
||||
signalStatus: string | null;
|
||||
imessageForm: IMessageForm;
|
||||
imessageSaving: boolean;
|
||||
imessageStatus: string | null;
|
||||
onRefresh: (probe: boolean) => void;
|
||||
onWhatsAppStart: (force: boolean) => void;
|
||||
onWhatsAppWait: () => void;
|
||||
onWhatsAppLogout: () => void;
|
||||
onTelegramChange: (patch: Partial<TelegramForm>) => void;
|
||||
onTelegramSave: () => void;
|
||||
onDiscordChange: (patch: Partial<DiscordForm>) => void;
|
||||
onDiscordSave: () => void;
|
||||
onSlackChange: (patch: Partial<SlackForm>) => void;
|
||||
onSlackSave: () => void;
|
||||
onSignalChange: (patch: Partial<SignalForm>) => void;
|
||||
onSignalSave: () => void;
|
||||
onIMessageChange: (patch: Partial<IMessageForm>) => void;
|
||||
onIMessageSave: () => void;
|
||||
};
|
||||
import type {
|
||||
ChannelKey,
|
||||
ConnectionsChannelData,
|
||||
ConnectionsProps,
|
||||
} from "./connections.types";
|
||||
import { channelEnabled, formatDuration, renderChannelAccountCount } from "./connections.shared";
|
||||
import { discordActionOptions, slackActionOptions } from "./connections.action-options";
|
||||
import { renderTelegramCard } from "./connections.telegram";
|
||||
import { renderWhatsAppCard } from "./connections.whatsapp";
|
||||
|
||||
export function renderConnections(props: ConnectionsProps) {
|
||||
const channels = props.snapshot?.channels as Record<string, unknown> | null;
|
||||
@@ -158,426 +94,29 @@ ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
}
|
||||
|
||||
type ChannelKey =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
|
||||
function channelEnabled(key: ChannelKey, props: ConnectionsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
if (!snapshot || !channels) return false;
|
||||
const whatsapp = channels.whatsapp as WhatsAppStatus | undefined;
|
||||
const telegram = channels.telegram as TelegramStatus | undefined;
|
||||
const discord = (channels.discord ?? null) as DiscordStatus | null;
|
||||
const slack = (channels.slack ?? null) as SlackStatus | null;
|
||||
const signal = (channels.signal ?? null) as SignalStatus | null;
|
||||
const imessage = (channels.imessage ?? null) as IMessageStatus | null;
|
||||
switch (key) {
|
||||
case "whatsapp":
|
||||
return (
|
||||
Boolean(whatsapp?.configured) ||
|
||||
Boolean(whatsapp?.linked) ||
|
||||
Boolean(whatsapp?.running)
|
||||
);
|
||||
case "telegram":
|
||||
return Boolean(telegram?.configured) || Boolean(telegram?.running);
|
||||
case "discord":
|
||||
return Boolean(discord?.configured || discord?.running);
|
||||
case "slack":
|
||||
return Boolean(slack?.configured || slack?.running);
|
||||
case "signal":
|
||||
return Boolean(signal?.configured || signal?.running);
|
||||
case "imessage":
|
||||
return Boolean(imessage?.configured || imessage?.running);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
): number {
|
||||
return channelAccounts?.[key]?.length ?? 0;
|
||||
}
|
||||
|
||||
function renderChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
) {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
if (count < 2) return nothing;
|
||||
return html`<div class="account-count">Accounts (${count})</div>`;
|
||||
}
|
||||
|
||||
function renderChannel(
|
||||
key: ChannelKey,
|
||||
props: ConnectionsProps,
|
||||
data: {
|
||||
whatsapp?: WhatsAppStatus;
|
||||
telegram?: TelegramStatus;
|
||||
discord?: DiscordStatus | null;
|
||||
slack?: SlackStatus | null;
|
||||
signal?: SignalStatus | null;
|
||||
imessage?: IMessageStatus | null;
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
|
||||
},
|
||||
data: ConnectionsChannelData,
|
||||
) {
|
||||
const accountCountLabel = renderChannelAccountCount(
|
||||
key,
|
||||
data.channelAccounts,
|
||||
);
|
||||
switch (key) {
|
||||
case "whatsapp": {
|
||||
const whatsapp = data.whatsapp;
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">WhatsApp</div>
|
||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Linked</span>
|
||||
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Connected</span>
|
||||
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last connect</span>
|
||||
<span>
|
||||
${whatsapp?.lastConnectedAt
|
||||
? formatAgo(whatsapp.lastConnectedAt)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last message</span>
|
||||
<span>
|
||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Auth age</span>
|
||||
<span>
|
||||
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${whatsapp?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${whatsapp.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappMessage
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.whatsappMessage}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappQrDataUrl
|
||||
? html`<div class="qr-wrap">
|
||||
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(false)}
|
||||
>
|
||||
${props.whatsappBusy ? "Working…" : "Show QR"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(true)}
|
||||
>
|
||||
Relink
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppWait()}
|
||||
>
|
||||
Wait for scan
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppLogout()}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
case "telegram": {
|
||||
const telegram = data.telegram;
|
||||
const telegramAccounts = data.channelAccounts?.telegram ?? [];
|
||||
const hasMultipleAccounts = telegramAccounts.length > 1;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const probe = account.probe as { bot?: { username?: string } } | undefined;
|
||||
const botUsername = probe?.bot?.username;
|
||||
const label = account.name || account.accountId;
|
||||
return html`
|
||||
<div class="account-card">
|
||||
<div class="account-card-header">
|
||||
<div class="account-card-title">
|
||||
${botUsername ? `@${botUsername}` : label}
|
||||
</div>
|
||||
<div class="account-card-id">${account.accountId}</div>
|
||||
</div>
|
||||
<div class="status-list account-card-status">
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${account.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${account.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${account.lastError ? html`
|
||||
<div class="account-card-error">
|
||||
${account.lastError}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Telegram</div>
|
||||
<div class="card-sub">Bot token and delivery options.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts ? html`
|
||||
<div class="account-card-list">
|
||||
${telegramAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${telegram?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${telegram?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Mode</span>
|
||||
<span>${telegram?.mode ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${telegram?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${telegram.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${telegram?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
|
||||
${telegram.probe.status ?? ""}
|
||||
${telegram.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>Bot token</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${props.telegramForm.token}
|
||||
?disabled=${props.telegramTokenLocked}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
token: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Apply default group rules</span>
|
||||
<select
|
||||
.value=${props.telegramForm.groupsWildcardEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
groupsWildcardEnabled:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="no">No</option>
|
||||
<option value="yes">Yes (allow all groups)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention in groups</span>
|
||||
<select
|
||||
.value=${props.telegramForm.requireMention ? "yes" : "no"}
|
||||
?disabled=${!props.telegramForm.groupsWildcardEnabled}
|
||||
@change=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
requireMention: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow from</span>
|
||||
<input
|
||||
.value=${props.telegramForm.allowFrom}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
allowFrom: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="123456789, @team, tg:123"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Proxy</span>
|
||||
<input
|
||||
.value=${props.telegramForm.proxy}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
proxy: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="socks5://localhost:9050"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook URL</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookUrl}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookUrl: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="https://example.com/telegram-webhook"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook secret</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookSecret}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookSecret: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="secret"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Webhook path</span>
|
||||
<input
|
||||
.value=${props.telegramForm.webhookPath}
|
||||
@input=${(e: Event) =>
|
||||
props.onTelegramChange({
|
||||
webhookPath: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="/telegram-webhook"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="callout" style="margin-top: 12px;">
|
||||
Allow from supports numeric user IDs (recommended) or @usernames. DM the bot
|
||||
to get your ID, or run /whoami.
|
||||
</div>
|
||||
|
||||
${props.telegramTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.telegramForm.groupsWildcardEnabled
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
This writes telegram.groups["*"] and allows all groups. Remove it
|
||||
if you only want specific groups.
|
||||
<div class="row" style="margin-top: 8px;">
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() =>
|
||||
props.onTelegramChange({ groupsWildcardEnabled: false })}
|
||||
>
|
||||
Remove wildcard
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.telegramStatus
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.telegramStatus}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.telegramSaving}
|
||||
@click=${() => props.onTelegramSave()}
|
||||
>
|
||||
${props.telegramSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
case "whatsapp":
|
||||
return renderWhatsAppCard({
|
||||
props,
|
||||
whatsapp: data.whatsapp,
|
||||
accountCountLabel,
|
||||
});
|
||||
case "telegram":
|
||||
return renderTelegramCard({
|
||||
props,
|
||||
telegram: data.telegram,
|
||||
telegramAccounts: data.channelAccounts?.telegram ?? [],
|
||||
accountCountLabel,
|
||||
});
|
||||
case "discord": {
|
||||
const discord = data.discord;
|
||||
const botName = discord?.probe?.bot?.username;
|
||||
|
||||
81
ui/src/ui/views/connections.types.ts
Normal file
81
ui/src/ui/views/connections.types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelsStatusSnapshot,
|
||||
DiscordStatus,
|
||||
IMessageStatus,
|
||||
SignalStatus,
|
||||
SlackStatus,
|
||||
TelegramStatus,
|
||||
WhatsAppStatus,
|
||||
} from "../types";
|
||||
import type {
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SignalForm,
|
||||
SlackForm,
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
export type ChannelKey =
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage";
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
snapshot: ChannelsStatusSnapshot | null;
|
||||
lastError: string | null;
|
||||
lastSuccessAt: number | null;
|
||||
whatsappMessage: string | null;
|
||||
whatsappQrDataUrl: string | null;
|
||||
whatsappConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
telegramForm: TelegramForm;
|
||||
telegramTokenLocked: boolean;
|
||||
telegramSaving: boolean;
|
||||
telegramStatus: string | null;
|
||||
discordForm: DiscordForm;
|
||||
discordTokenLocked: boolean;
|
||||
discordSaving: boolean;
|
||||
discordStatus: string | null;
|
||||
slackForm: SlackForm;
|
||||
slackTokenLocked: boolean;
|
||||
slackAppTokenLocked: boolean;
|
||||
slackSaving: boolean;
|
||||
slackStatus: string | null;
|
||||
signalForm: SignalForm;
|
||||
signalSaving: boolean;
|
||||
signalStatus: string | null;
|
||||
imessageForm: IMessageForm;
|
||||
imessageSaving: boolean;
|
||||
imessageStatus: string | null;
|
||||
onRefresh: (probe: boolean) => void;
|
||||
onWhatsAppStart: (force: boolean) => void;
|
||||
onWhatsAppWait: () => void;
|
||||
onWhatsAppLogout: () => void;
|
||||
onTelegramChange: (patch: Partial<TelegramForm>) => void;
|
||||
onTelegramSave: () => void;
|
||||
onDiscordChange: (patch: Partial<DiscordForm>) => void;
|
||||
onDiscordSave: () => void;
|
||||
onSlackChange: (patch: Partial<SlackForm>) => void;
|
||||
onSlackSave: () => void;
|
||||
onSignalChange: (patch: Partial<SignalForm>) => void;
|
||||
onSignalSave: () => void;
|
||||
onIMessageChange: (patch: Partial<IMessageForm>) => void;
|
||||
onIMessageSave: () => void;
|
||||
};
|
||||
|
||||
export type ConnectionsChannelData = {
|
||||
whatsapp?: WhatsAppStatus;
|
||||
telegram?: TelegramStatus;
|
||||
discord?: DiscordStatus | null;
|
||||
slack?: SlackStatus | null;
|
||||
signal?: SignalStatus | null;
|
||||
imessage?: IMessageStatus | null;
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
|
||||
};
|
||||
|
||||
116
ui/src/ui/views/connections.whatsapp.ts
Normal file
116
ui/src/ui/views/connections.whatsapp.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { WhatsAppStatus } from "../types";
|
||||
import type { ConnectionsProps } from "./connections.types";
|
||||
import { formatDuration } from "./connections.shared";
|
||||
|
||||
export function renderWhatsAppCard(params: {
|
||||
props: ConnectionsProps;
|
||||
whatsapp?: WhatsAppStatus;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, whatsapp, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">WhatsApp</div>
|
||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Linked</span>
|
||||
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Connected</span>
|
||||
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last connect</span>
|
||||
<span>
|
||||
${whatsapp?.lastConnectedAt
|
||||
? formatAgo(whatsapp.lastConnectedAt)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last message</span>
|
||||
<span>
|
||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Auth age</span>
|
||||
<span>
|
||||
${whatsapp?.authAgeMs != null
|
||||
? formatDuration(whatsapp.authAgeMs)
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${whatsapp?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${whatsapp.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappMessage
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
${props.whatsappMessage}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${props.whatsappQrDataUrl
|
||||
? html`<div class="qr-wrap">
|
||||
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(false)}
|
||||
>
|
||||
${props.whatsappBusy ? "Working…" : "Show QR"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppStart(true)}
|
||||
>
|
||||
Relink
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppWait()}
|
||||
>
|
||||
Wait for scan
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
?disabled=${props.whatsappBusy}
|
||||
@click=${() => props.onWhatsAppLogout()}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user