refactor: centralize channel ui metadata

This commit is contained in:
Peter Steinberger
2026-01-20 13:10:26 +00:00
parent 6f9861bb9b
commit fdb171cb15
19 changed files with 240 additions and 68 deletions

View File

@@ -426,24 +426,17 @@ extension ChannelsSettings {
} }
private func resolveChannelTitle(_ id: String) -> String { private func resolveChannelTitle(_ id: String) -> String {
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty { let label = self.store.resolveChannelLabel(id)
return label if label != id { return label }
}
return id.prefix(1).uppercased() + id.dropFirst() return id.prefix(1).uppercased() + id.dropFirst()
} }
private func resolveChannelDetailTitle(_ id: String) -> String { private func resolveChannelDetailTitle(_ id: String) -> String {
if let detail = self.store.snapshot?.channelDetailLabels?[id], !detail.isEmpty { return self.store.resolveChannelDetailLabel(id)
return detail
}
return self.resolveChannelTitle(id)
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
if let symbol = self.store.snapshot?.channelSystemImages?[id], !symbol.isEmpty { return self.store.resolveChannelSystemImage(id)
return symbol
}
return "message"
} }
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {

View File

@@ -153,11 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
let application: AnyCodable? let application: AnyCodable?
} }
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double let ts: Double
let channelOrder: [String] let channelOrder: [String]
let channelLabels: [String: String] let channelLabels: [String: String]
let channelDetailLabels: [String: String]? = nil let channelDetailLabels: [String: String]? = nil
let channelSystemImages: [String: String]? = nil let channelSystemImages: [String: String]? = nil
let channelMeta: [ChannelUiMetaEntry]? = nil
let channels: [String: AnyCodable] let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]] let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String] let channelDefaultAccountId: [String: String]
@@ -219,6 +227,47 @@ final class ChannelsStore {
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map { $0.id }
}
return self.snapshot?.channelOrder ?? []
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview
} }

View File

@@ -55,8 +55,7 @@ struct CronJobEditor: View {
@State var postPrefix: String = "Cron" @State var postPrefix: String = "Cron"
var channelOptions: [String] { var channelOptions: [String] {
let snapshot = self.channelsStore.snapshot let ordered = self.channelsStore.orderedChannelIds()
let ordered = snapshot?.channelOrder ?? []
var options = ["last"] + ordered var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) { if !trimmed.isEmpty, !options.contains(trimmed) {
@@ -68,7 +67,7 @@ struct CronJobEditor: View {
func channelLabel(for id: String) -> String { func channelLabel(for id: String) -> String {
if id == "last" { return "last" } if id == "last" { return "last" }
return self.channelsStore.snapshot?.channelLabels[id] ?? id return self.channelsStore.resolveChannelLabel(id)
} }
var body: some View { var body: some View {

View File

@@ -1326,6 +1326,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
public let channellabels: [String: AnyCodable] public let channellabels: [String: AnyCodable]
public let channeldetaillabels: [String: AnyCodable]? public let channeldetaillabels: [String: AnyCodable]?
public let channelsystemimages: [String: AnyCodable]? public let channelsystemimages: [String: AnyCodable]?
public let channelmeta: [[String: AnyCodable]]?
public let channels: [String: AnyCodable] public let channels: [String: AnyCodable]
public let channelaccounts: [String: AnyCodable] public let channelaccounts: [String: AnyCodable]
public let channeldefaultaccountid: [String: AnyCodable] public let channeldefaultaccountid: [String: AnyCodable]
@@ -1336,6 +1337,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
channellabels: [String: AnyCodable], channellabels: [String: AnyCodable],
channeldetaillabels: [String: AnyCodable]?, channeldetaillabels: [String: AnyCodable]?,
channelsystemimages: [String: AnyCodable]?, channelsystemimages: [String: AnyCodable]?,
channelmeta: [[String: AnyCodable]]?,
channels: [String: AnyCodable], channels: [String: AnyCodable],
channelaccounts: [String: AnyCodable], channelaccounts: [String: AnyCodable],
channeldefaultaccountid: [String: AnyCodable] channeldefaultaccountid: [String: AnyCodable]
@@ -1345,6 +1347,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
self.channellabels = channellabels self.channellabels = channellabels
self.channeldetaillabels = channeldetaillabels self.channeldetaillabels = channeldetaillabels
self.channelsystemimages = channelsystemimages self.channelsystemimages = channelsystemimages
self.channelmeta = channelmeta
self.channels = channels self.channels = channels
self.channelaccounts = channelaccounts self.channelaccounts = channelaccounts
self.channeldefaultaccountid = channeldefaultaccountid self.channeldefaultaccountid = channeldefaultaccountid
@@ -1355,6 +1358,7 @@ public struct ChannelsStatusResult: Codable, Sendable {
case channellabels = "channelLabels" case channellabels = "channelLabels"
case channeldetaillabels = "channelDetailLabels" case channeldetaillabels = "channelDetailLabels"
case channelsystemimages = "channelSystemImages" case channelsystemimages = "channelSystemImages"
case channelmeta = "channelMeta"
case channels case channels
case channelaccounts = "channelAccounts" case channelaccounts = "channelAccounts"
case channeldefaultaccountid = "channelDefaultAccountId" case channeldefaultaccountid = "channelDefaultAccountId"

View File

@@ -1,4 +1,6 @@
import { import {
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_ACTIONS,
createActionGate, createActionGate,
jsonResult, jsonResult,
readBooleanParam, readBooleanParam,
@@ -49,19 +51,7 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
} }
/** Supported action names for BlueBubbles */ /** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
"react",
"edit",
"unsend",
"reply",
"sendWithEffect",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
"sendAttachment",
]);
export const bluebubblesMessageActions: ChannelMessageActionAdapter = { export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
@@ -69,19 +59,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
if (!account.enabled || !account.configured) return []; if (!account.enabled || !account.configured) return [];
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions); const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>(); const actions = new Set<ChannelMessageActionName>();
// Check if running on macOS 26+ (edit not supported)
const macOS26 = isMacOS26OrHigher(account.accountId); const macOS26 = isMacOS26OrHigher(account.accountId);
if (gate("reactions")) actions.add("react"); for (const action of BLUEBUBBLES_ACTION_NAMES) {
if (gate("edit") && !macOS26) actions.add("edit"); const spec = BLUEBUBBLES_ACTIONS[action];
if (gate("unsend")) actions.add("unsend"); if (!spec?.gate) continue;
if (gate("reply")) actions.add("reply"); if (spec.unsupportedOnMacOS26 && macOS26) continue;
if (gate("sendWithEffect")) actions.add("sendWithEffect"); if (gate(spec.gate)) actions.add(action);
if (gate("renameGroup")) actions.add("renameGroup"); }
if (gate("setGroupIcon")) actions.add("setGroupIcon");
if (gate("addParticipant")) actions.add("addParticipant");
if (gate("removeParticipant")) actions.add("removeParticipant");
if (gate("leaveGroup")) actions.add("leaveGroup");
if (gate("sendAttachment")) actions.add("sendAttachment");
return Array.from(actions); return Array.from(actions);
}, },
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),

View File

@@ -928,8 +928,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
} }
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
warnedCryptoMissingRooms.add(roomId); warnedCryptoMissingRooms.add(roomId);
const warning = const hint = core.system.formatNativeDependencyHint({
"matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart"; packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
manager: "pnpm",
downloadCommand:
"node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
});
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
logger.warn({ roomId }, warning); logger.warn({ roomId }, warning);
} }
return; return;

View File

@@ -7,6 +7,7 @@ import {
CHANNEL_MESSAGE_ACTION_NAMES, CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName, type ChannelMessageActionName,
} from "../../channels/plugins/types.js"; } from "../../channels/plugins/types.js";
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { import {
@@ -25,14 +26,6 @@ import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>([
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
]);
function buildRoutingSchema() { function buildRoutingSchema() {
return { return {
channel: Type.Optional(Type.String()), channel: Type.Optional(Type.String()),

View File

@@ -0,0 +1,29 @@
import type { ChannelMessageActionName } from "./types.js";
export type BlueBubblesActionSpec = {
gate: string;
groupOnly?: boolean;
unsupportedOnMacOS26?: boolean;
};
export const BLUEBUBBLES_ACTIONS = {
react: { gate: "reactions" },
edit: { gate: "edit", unsupportedOnMacOS26: true },
unsend: { gate: "unsend" },
reply: { gate: "reply" },
sendWithEffect: { gate: "sendWithEffect" },
renameGroup: { gate: "renameGroup", groupOnly: true },
setGroupIcon: { gate: "setGroupIcon", groupOnly: true },
addParticipant: { gate: "addParticipant", groupOnly: true },
removeParticipant: { gate: "removeParticipant", groupOnly: true },
leaveGroup: { gate: "leaveGroup", groupOnly: true },
sendAttachment: { gate: "sendAttachment" },
} as const satisfies Partial<Record<ChannelMessageActionName, BlueBubblesActionSpec>>;
export const BLUEBUBBLES_ACTION_NAMES = Object.keys(
BLUEBUBBLES_ACTIONS,
) as ChannelMessageActionName[];
export const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>(
BLUEBUBBLES_ACTION_NAMES.filter((action) => BLUEBUBBLES_ACTIONS[action]?.groupOnly),
);

View File

@@ -5,6 +5,22 @@ import type { PluginOrigin } from "../../plugins/types.js";
import type { ClawdbotPackageManifest } from "../../plugins/manifest.js"; import type { ClawdbotPackageManifest } from "../../plugins/manifest.js";
import type { ChannelMeta } from "./types.js"; import type { ChannelMeta } from "./types.js";
export type ChannelUiMetaEntry = {
id: string;
label: string;
detailLabel: string;
systemImage?: string;
};
export type ChannelUiCatalog = {
entries: ChannelUiMetaEntry[];
order: string[];
labels: Record<string, string>;
detailLabels: Record<string, string>;
systemImages: Record<string, string>;
byId: Record<string, ChannelUiMetaEntry>;
};
export type ChannelPluginCatalogEntry = { export type ChannelPluginCatalogEntry = {
id: string; id: string;
meta: ChannelMeta; meta: ChannelMeta;
@@ -116,6 +132,34 @@ function buildCatalogEntry(candidate: {
return { id, meta, install }; return { id, meta, install };
} }
export function buildChannelUiCatalog(
plugins: Array<{ id: string; meta: ChannelMeta }>,
): ChannelUiCatalog {
const entries: ChannelUiMetaEntry[] = plugins.map((plugin) => {
const detailLabel = plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label;
return {
id: plugin.id,
label: plugin.meta.label,
detailLabel,
...(plugin.meta.systemImage ? { systemImage: plugin.meta.systemImage } : {}),
};
});
const order = entries.map((entry) => entry.id);
const labels: Record<string, string> = {};
const detailLabels: Record<string, string> = {};
const systemImages: Record<string, string> = {};
const byId: Record<string, ChannelUiMetaEntry> = {};
for (const entry of entries) {
labels[entry.id] = entry.label;
detailLabels[entry.id] = entry.detailLabel;
if (entry.systemImage) {
systemImages[entry.id] = entry.systemImage;
}
byId[entry.id] = entry;
}
return { entries, order, labels, detailLabels, systemImages, byId };
}
export function listChannelPluginCatalogEntries( export function listChannelPluginCatalogEntries(
options: CatalogOptions = {}, options: CatalogOptions = {},
): ChannelPluginCatalogEntry[] { ): ChannelPluginCatalogEntry[] {

View File

@@ -55,6 +55,16 @@ export const ChannelAccountSnapshotSchema = Type.Object(
{ additionalProperties: true }, { additionalProperties: true },
); );
export const ChannelUiMetaSchema = Type.Object(
{
id: NonEmptyString,
label: NonEmptyString,
detailLabel: NonEmptyString,
systemImage: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const ChannelsStatusResultSchema = Type.Object( export const ChannelsStatusResultSchema = Type.Object(
{ {
ts: Type.Integer({ minimum: 0 }), ts: Type.Integer({ minimum: 0 }),
@@ -62,6 +72,7 @@ export const ChannelsStatusResultSchema = Type.Object(
channelLabels: Type.Record(NonEmptyString, NonEmptyString), channelLabels: Type.Record(NonEmptyString, NonEmptyString),
channelDetailLabels: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), channelDetailLabels: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
channelSystemImages: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)), channelSystemImages: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
channelMeta: Type.Optional(Type.Array(ChannelUiMetaSchema)),
channels: Type.Record(NonEmptyString, Type.Unknown()), channels: Type.Record(NonEmptyString, Type.Unknown()),
channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)), channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)),
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString), channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),

View File

@@ -5,6 +5,7 @@ import {
listChannelPlugins, listChannelPlugins,
normalizeChannelId, normalizeChannelId,
} from "../../channels/plugins/index.js"; } from "../../channels/plugins/index.js";
import { buildChannelUiCatalog } from "../../channels/plugins/catalog.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
@@ -188,24 +189,14 @@ export const channelsHandlers: GatewayRequestHandlers = {
return { accounts, defaultAccountId, defaultAccount, resolvedAccounts }; return { accounts, defaultAccountId, defaultAccount, resolvedAccounts };
}; };
const channelLabels = Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.meta.label])); const uiCatalog = buildChannelUiCatalog(plugins);
const channelDetailLabels = Object.fromEntries(
plugins.map((plugin) => [
plugin.id,
plugin.meta.detailLabel ?? plugin.meta.selectionLabel ?? plugin.meta.label,
]),
);
const channelSystemImages = Object.fromEntries(
plugins.flatMap((plugin) =>
plugin.meta.systemImage ? [[plugin.id, plugin.meta.systemImage]] : [],
),
);
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
ts: Date.now(), ts: Date.now(),
channelOrder: plugins.map((plugin) => plugin.id), channelOrder: uiCatalog.order,
channelLabels, channelLabels: uiCatalog.labels,
channelDetailLabels, channelDetailLabels: uiCatalog.detailLabels,
channelSystemImages, channelSystemImages: uiCatalog.systemImages,
channelMeta: uiCatalog.entries,
channels: {} as Record<string, unknown>, channels: {} as Record<string, unknown>,
channelAccounts: {} as Record<string, unknown>, channelAccounts: {} as Record<string, unknown>,
channelDefaultAccountId: {} as Record<string, unknown>, channelDefaultAccountId: {} as Record<string, unknown>,

View File

@@ -1,4 +1,9 @@
export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js"; export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js";
export {
BLUEBUBBLES_ACTIONS,
BLUEBUBBLES_ACTION_NAMES,
BLUEBUBBLES_GROUP_ACTIONS,
} from "../channels/plugins/bluebubbles-actions.js";
export type { export type {
ChannelAccountSnapshot, ChannelAccountSnapshot,
ChannelAccountState, ChannelAccountState,

View File

@@ -106,6 +106,7 @@ import { loginWeb } from "../../web/login.js";
import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { registerMemoryCli } from "../../cli/memory-cli.js"; import { registerMemoryCli } from "../../cli/memory-cli.js";
import { formatNativeDependencyHint } from "./native-deps.js";
import type { PluginRuntime } from "./types.js"; import type { PluginRuntime } from "./types.js";
@@ -134,6 +135,7 @@ export function createPluginRuntime(): PluginRuntime {
system: { system: {
enqueueSystemEvent, enqueueSystemEvent,
runCommandWithTimeout, runCommandWithTimeout,
formatNativeDependencyHint,
}, },
media: { media: {
loadWebMedia, loadWebMedia,

View File

@@ -0,0 +1,28 @@
export type NativeDependencyHintParams = {
packageName: string;
manager?: "pnpm" | "npm" | "yarn";
rebuildCommand?: string;
approveBuildsCommand?: string;
downloadCommand?: string;
};
export function formatNativeDependencyHint(params: NativeDependencyHintParams): string {
const manager = params.manager ?? "pnpm";
const rebuildCommand =
params.rebuildCommand ??
(manager === "npm"
? `npm rebuild ${params.packageName}`
: manager === "yarn"
? `yarn rebuild ${params.packageName}`
: `pnpm rebuild ${params.packageName}`);
const approveBuildsCommand =
params.approveBuildsCommand ??
(manager === "pnpm" ? `pnpm approve-builds (select ${params.packageName})` : undefined);
const steps = [approveBuildsCommand, rebuildCommand, params.downloadCommand].filter(
(step): step is string => Boolean(step),
);
if (steps.length === 0) {
return `Install ${params.packageName} and rebuild its native module.`;
}
return `Install ${params.packageName} and rebuild its native module (${steps.join("; ")}).`;
}

View File

@@ -59,6 +59,7 @@ type RecordChannelActivity = typeof import("../../infra/channel-activity.js").re
type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity; type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity;
type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent; type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent;
type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout; type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout;
type FormatNativeDependencyHint = typeof import("./native-deps.js").formatNativeDependencyHint;
type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia; type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia;
type DetectMime = typeof import("../../media/mime.js").detectMime; type DetectMime = typeof import("../../media/mime.js").detectMime;
type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFromMime; type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFromMime;
@@ -146,6 +147,7 @@ export type PluginRuntime = {
system: { system: {
enqueueSystemEvent: EnqueueSystemEvent; enqueueSystemEvent: EnqueueSystemEvent;
runCommandWithTimeout: RunCommandWithTimeout; runCommandWithTimeout: RunCommandWithTimeout;
formatNativeDependencyHint: FormatNativeDependencyHint;
}; };
media: { media: {
loadWebMedia: LoadWebMedia; loadWebMedia: LoadWebMedia;

View File

@@ -272,8 +272,11 @@ export function renderApp(state: AppViewState) {
error: state.cronError, error: state.cronError,
busy: state.cronBusy, busy: state.cronBusy,
form: state.cronForm, form: state.cronForm,
channels: state.channelsSnapshot?.channelOrder ?? [], channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {}, channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId, runsJobId: state.cronRunsJobId,
runs: state.cronRuns, runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),

View File

@@ -4,11 +4,19 @@ export type ChannelsStatusSnapshot = {
channelLabels: Record<string, string>; channelLabels: Record<string, string>;
channelDetailLabels?: Record<string, string>; channelDetailLabels?: Record<string, string>;
channelSystemImages?: Record<string, string>; channelSystemImages?: Record<string, string>;
channelMeta?: ChannelUiMetaEntry[];
channels: Record<string, unknown>; channels: Record<string, unknown>;
channelAccounts: Record<string, ChannelAccountSnapshot[]>; channelAccounts: Record<string, ChannelAccountSnapshot[]>;
channelDefaultAccountId: Record<string, string>; channelDefaultAccountId: Record<string, string>;
}; };
export type ChannelUiMetaEntry = {
id: string;
label: string;
detailLabel: string;
systemImage?: string;
};
export const CRON_CHANNEL_LAST = "last"; export const CRON_CHANNEL_LAST = "last";
export type ChannelAccountSnapshot = { export type ChannelAccountSnapshot = {

View File

@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format"; import { formatAgo } from "../format";
import type { import type {
ChannelAccountSnapshot, ChannelAccountSnapshot,
ChannelUiMetaEntry,
ChannelsStatusSnapshot, ChannelsStatusSnapshot,
DiscordStatus, DiscordStatus,
IMessageStatus, IMessageStatus,
@@ -85,6 +86,9 @@ ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
} }
function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] { function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] {
if (snapshot?.channelMeta?.length) {
return snapshot.channelMeta.map((entry) => entry.id) as ChannelKey[];
}
if (snapshot?.channelOrder?.length) { if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder; return snapshot.channelOrder;
} }
@@ -148,7 +152,7 @@ function renderGenericChannelCard(
props: ChannelsProps, props: ChannelsProps,
channelAccounts: Record<string, ChannelAccountSnapshot[]>, channelAccounts: Record<string, ChannelAccountSnapshot[]>,
) { ) {
const label = props.snapshot?.channelLabels?.[key] ?? key; const label = resolveChannelLabel(props.snapshot, key);
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined; const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined;
const configured = typeof status?.configured === "boolean" ? status.configured : undefined; const configured = typeof status?.configured === "boolean" ? status.configured : undefined;
const running = typeof status?.running === "boolean" ? status.running : undefined; const running = typeof status?.running === "boolean" ? status.running : undefined;
@@ -197,6 +201,21 @@ function renderGenericChannelCard(
`; `;
} }
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 RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
function hasRecentActivity(account: ChannelAccountSnapshot): boolean { function hasRecentActivity(account: ChannelAccountSnapshot): boolean {

View File

@@ -7,7 +7,7 @@ import {
formatCronState, formatCronState,
formatNextRun, formatNextRun,
} from "../presenter"; } from "../presenter";
import type { CronJob, CronRunLogEntry, CronStatus } from "../types"; import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types";
import type { CronFormState } from "../ui-types"; import type { CronFormState } from "../ui-types";
export type CronProps = { export type CronProps = {
@@ -19,6 +19,7 @@ export type CronProps = {
form: CronFormState; form: CronFormState;
channels: string[]; channels: string[];
channelLabels?: Record<string, string>; channelLabels?: Record<string, string>;
channelMeta?: ChannelUiMetaEntry[];
runsJobId: string | null; runsJobId: string | null;
runs: CronRunLogEntry[]; runs: CronRunLogEntry[];
onFormChange: (patch: Partial<CronFormState>) => void; onFormChange: (patch: Partial<CronFormState>) => void;
@@ -46,6 +47,8 @@ function buildChannelOptions(props: CronProps): string[] {
function resolveChannelLabel(props: CronProps, channel: string): string { function resolveChannelLabel(props: CronProps, channel: string): string {
if (channel === "last") return "last"; if (channel === "last") return "last";
const meta = props.channelMeta?.find((entry) => entry.id === channel);
if (meta?.label) return meta.label;
return props.channelLabels?.[channel] ?? channel; return props.channelLabels?.[channel] ?? channel;
} }