feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log

This commit is contained in:
hh committed 2026-10-19 02:57:27 +00:00
1 parent 6a4fd6ad47
commit ea3a7c0a61
19 files changed
+3699 -458

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
import type {
AlertEvent,
AlertItem,
ChatRef,
Delivery,
PersonRef,
StoredParams,
WatchKind,
} from "#lib/api/watches.js";
import { formatTime } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
export const KIND_ICONS: Record<WatchKind, string> = {
keyword: "search",
message_deleted: "delete",
message_edited: "edit",
new_message: "message",
peer_online: "user-online",
};
export const EVENT_ICONS: Record<AlertEvent, string> = {
delete: "delete",
edit: "edit",
message: "message",
online: "user-online",
transcript: "transcribe",
};
export const WATCH_KINDS: WatchKind[] = [
"keyword",
"new_message",
"message_deleted",
"message_edited",
"peer_online",
];
export interface Picked {
hasAvatar: boolean;
id: number;
title: string;
}
interface Described {
chat: ChatRef | null;
kind: WatchKind;
params: StoredParams;
person: PersonRef | null;
}
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const HOURS_AS_DAYS = 48;
const WHITESPACE = /\s+/g;
export function chatTitle(chat: ChatRef | null, id?: number): string {
if (chat?.title) {
return chat.title;
}
return t("watches.unknownChat", { id: chat?.id ?? id ?? "" });
}
export function personName(person: PersonRef | null, id?: number): string {
if (person?.name) {
return person.name;
}
return t("watches.unknownPerson", { id: person?.id ?? id ?? "" });
}
export function patternOf(params: StoredParams): string {
return params.pattern ?? params.keyword ?? "";
}
function what(params: StoredParams): string {
const key = params.regex ? "watches.describe.regex" : "watches.describe.word";
return t(key, { pattern: patternOf(params) });
}
function describeKeyword(watch: Described): string {
const { params } = watch;
const values = {
chat: chatTitle(watch.chat, params.chat_id),
person: personName(watch.person, params.sender_id),
what: what(params),
};
if (params.chat_id !== undefined && params.sender_id !== undefined) {
return t("watches.describe.keywordFromIn", values);
}
if (params.chat_id !== undefined) {
return t("watches.describe.keywordIn", values);
}
if (params.sender_id !== undefined) {
return t("watches.describe.keywordFrom", values);
}
return t("watches.describe.keyword", values);
}
function describeNew(watch: Described): string {
const { params } = watch;
const values = {
chat: chatTitle(watch.chat, params.chat_id),
person: personName(watch.person, params.sender_id),
};
if (params.chat_id !== undefined && params.sender_id !== undefined) {
return t("watches.describe.newFromIn", values);
}
if (params.chat_id !== undefined) {
return t("watches.describe.newIn", values);
}
return t("watches.describe.newFrom", values);
}
export function describeWatch(watch: Described): string {
const { params } = watch;
const chat = { chat: chatTitle(watch.chat, params.chat_id) };
switch (watch.kind) {
case "keyword":
return describeKeyword(watch);
case "new_message":
return describeNew(watch);
case "message_deleted":
return params.chat_id === undefined
? t("watches.describe.deletedDm")
: t("watches.describe.deletedIn", chat);
case "message_edited":
return params.chat_id === undefined
? t("watches.describe.editedDm")
: t("watches.describe.editedIn", chat);
default:
return t("watches.describe.online", {
person: personName(watch.person, params.peer_id),
});
}
}
function unitFormat(unit: "minute" | "hour" | "day", value: number): string {
return new Intl.NumberFormat(locale.tag, {
style: "unit",
unit,
unitDisplay: "short",
}).format(value);
}
export function awayFor(since: string, until: string): string {
const gap = Math.max(
0,
new Date(until).getTime() - new Date(since).getTime()
);
if (gap < HOUR) {
return unitFormat("minute", Math.max(1, Math.round(gap / MINUTE)));
}
if (gap < HOURS_AS_DAYS * HOUR) {
return unitFormat("hour", Math.round(gap / HOUR));
}
return unitFormat("day", Math.round(gap / DAY));
}
export function alertTitle(alert: AlertItem): string {
if (alert.chat) {
return chatTitle(alert.chat);
}
return personName(alert.peer);
}
export function alertLabel(alert: AlertItem): string {
if (alert.watch.name) {
return alert.watch.name;
}
if (alert.watch.kind === "keyword") {
return what(alert.watch.params);
}
return t(`alerts.event.${alert.event}`);
}
export function alertPreview(alert: AlertItem): string {
if (alert.event === "online") {
return alert.away_since
? t("alerts.onlineAfter", { away: awayFor(alert.away_since, alert.ts) })
: "";
}
const { message } = alert;
const text = message?.text?.replace(WHITESPACE, " ") || t("alerts.noText");
const sender = message?.sender;
if (
!sender ||
sender.id === alert.chat?.id ||
alert.chat?.kind === "private"
) {
return text;
}
return `${personName(sender)}: ${text}`;
}
export function alertLine(alert: AlertItem): string {
const preview = alertPreview(alert);
return preview ? `${alertLabel(alert)}: ${preview}` : alertLabel(alert);
}
export function alertAvatar(alert: AlertItem): {
hasAvatar: boolean;
id: number;
kind: "chat" | "peer";
name: string;
} {
if (alert.chat) {
return {
hasAvatar: alert.chat.has_avatar,
id: alert.chat.id,
kind: alert.chat.id > 0 ? "peer" : "chat",
name: chatTitle(alert.chat),
};
}
return {
hasAvatar: alert.peer?.has_avatar ?? false,
id: alert.peer?.id ?? 0,
kind: "peer",
name: personName(alert.peer),
};
}
function deliveryReason(delivery: Delivery): string {
return delivery.response_code === null
? (delivery.last_error ?? "")
: String(delivery.response_code);
}
export function deliveryStatus(delivery: Delivery): string {
const waiting =
delivery.status === "sending" ||
(delivery.status === "pending" && delivery.attempts === 0);
if (waiting) {
return t("webhooks.status.sending");
}
const head =
delivery.status === "pending"
? t("webhooks.status.pending", {
time: formatTime(delivery.next_attempt_at),
})
: t(`webhooks.status.${delivery.status}`);
const reason = deliveryReason(delivery);
return reason ? `${head} · ${reason}` : head;
}