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

-42
View File
@@ -1,7 +1,6 @@
import { request } from "#lib/api/client.js"; import { request } from "#lib/api/client.js";
import type { import type {
Account, Account,
Alert,
Annotation, Annotation,
AvatarHistoryView, AvatarHistoryView,
CallbackView, CallbackView,
@@ -45,7 +44,6 @@ import type {
TrashItem, TrashItem,
TrashOutcome, TrashOutcome,
VolumeBucket, VolumeBucket,
Watch,
WatchedBot, WatchedBot,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -547,46 +545,6 @@ export function transcribeMedia(
}); });
} }
export function listWatches(): Promise<Watch[]> {
return request<Watch[]>("/watches", { account: true });
}
export function createWatch(
kind: string,
params: Record<string, unknown>,
enabled: boolean
): Promise<Watch> {
return request<Watch>("/watches", {
body: { account_id: accounts.selectedId, enabled, kind, params },
method: "POST",
});
}
export function updateWatch(
id: number,
params: Record<string, unknown>,
enabled: boolean
): Promise<Watch> {
return request<Watch>(`/watches/${id}`, {
body: { enabled, params },
method: "PUT",
});
}
export function deleteWatch(id: number): Promise<void> {
return request<void>(`/watches/${id}`, { method: "DELETE" });
}
export function listAlerts(
options: Page & { seen?: boolean } = {}
): Promise<Alert[]> {
return request<Alert[]>("/alerts", { account: true, query: { ...options } });
}
export function markAlertSeen(id: number): Promise<void> {
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
}
export function listAnnotations( export function listAnnotations(
options: { chatId?: number; messageId?: number } = {} options: { chatId?: number; messageId?: number } = {}
): Promise<Annotation[]> { ): Promise<Annotation[]> {
+4 -21
View File
@@ -1,3 +1,5 @@
import type { LiveAlertEvent } from "#lib/api/watches.js";
export type ChatKind = "private" | "group"; export type ChatKind = "private" | "group";
export type PolicyScopeType = export type PolicyScopeType =
| "default_dm" | "default_dm"
@@ -543,26 +545,6 @@ export interface Folder {
title: string; title: string;
} }
export interface Watch {
account_id: number;
created_at: string;
enabled: boolean;
id: number;
kind: string;
params: Record<string, unknown>;
updated_at: string;
}
export interface Alert {
account_id: number;
created_at: string;
id: number;
payload: Record<string, unknown>;
seen: boolean;
ts: string;
watch_id: number;
}
export interface JobView { export interface JobView {
account_id: number; account_id: number;
attempts: number; attempts: number;
@@ -607,7 +589,8 @@ export type LiveEvent =
| LiveMessageEvent | LiveMessageEvent
| LiveDeleteEvent | LiveDeleteEvent
| LivePresenceEvent | LivePresenceEvent
| LiveReceiptEvent; | LiveReceiptEvent
| LiveAlertEvent;
export interface FileShare { export interface FileShare {
account_id: number; account_id: number;
+210
View File
@@ -0,0 +1,210 @@
import { request } from "#lib/api/client.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
export type WatchKind =
| "keyword"
| "new_message"
| "message_deleted"
| "message_edited"
| "peer_online";
export interface WatchParams {
chat_id?: number;
pattern?: string;
peer_id?: number;
regex?: boolean;
sender_id?: number;
}
export type StoredParams = WatchParams & { keyword?: string };
export interface ChatRef {
has_avatar: boolean;
id: number;
kind: "private" | "group" | "channel";
title: string | null;
}
export interface PersonRef {
has_avatar: boolean;
id: number;
name: string | null;
}
export interface Watch {
account_id: number;
alert_count: number;
chat: ChatRef | null;
created_at: string;
enabled: boolean;
id: number;
kind: WatchKind;
last_alert_at: string | null;
name: string | null;
params: StoredParams;
person: PersonRef | null;
updated_at: string;
}
export interface WatchDraft {
enabled: boolean;
kind: WatchKind;
name: string | null;
params: WatchParams;
}
export type AlertEvent =
| "message"
| "edit"
| "transcript"
| "delete"
| "online";
export interface AlertMessage {
date: string | null;
deleted: boolean;
edited: boolean;
id: number;
sender: PersonRef | null;
text: string | null;
}
export interface AlertItem {
account_id: number;
away_since: string | null;
chat: ChatRef | null;
event: AlertEvent;
id: number;
message: AlertMessage | null;
path: string;
peer: PersonRef | null;
seen: boolean;
ts: string;
watch: {
id: number;
kind: WatchKind;
name: string | null;
params: StoredParams;
};
}
export interface AlertPage {
items: AlertItem[];
next_before: number | null;
unseen: number;
}
export interface LiveAlertEvent {
alert: AlertItem | null;
type: "alert";
unseen: number;
}
export interface Webhook {
account_id: number;
created_at: string;
enabled: boolean;
id: number;
last_delivery: Delivery | null;
secret: string;
updated_at: string;
url: string;
watch_ids: number[] | null;
}
export interface WebhookDraft {
enabled: boolean;
url: string;
watch_ids: number[] | null;
}
export interface Delivery {
alert_id: number | null;
attempts: number;
created_at: string;
event: "alert" | "test";
finished_at: string | null;
id: number;
last_error: string | null;
next_attempt_at: string;
response_code: number | null;
status: "pending" | "sending" | "delivered" | "failed";
webhook_id: number;
}
export function listWatches(): Promise<Watch[]> {
return request<Watch[]>("/watches", { account: true });
}
export function createWatch(draft: WatchDraft): Promise<Watch> {
return request<Watch>("/watches", {
body: { account_id: accounts.selectedId, ...draft },
method: "POST",
});
}
export function updateWatch(id: number, draft: WatchDraft): Promise<Watch> {
return request<Watch>(`/watches/${id}`, { body: draft, method: "PUT" });
}
export function deleteWatch(id: number): Promise<void> {
return request<void>(`/watches/${id}`, { method: "DELETE" });
}
export function listAlerts(
options: { before?: number; limit?: number; seen?: boolean } = {}
): Promise<AlertPage> {
return request<AlertPage>("/alerts", { account: true, query: options });
}
export async function unseenAlerts(): Promise<number> {
const { unseen } = await request<{ unseen: number }>("/alerts/unseen", {
account: true,
});
return unseen;
}
export function markAlertSeen(id: number): Promise<void> {
return request<void>(`/alerts/${id}/seen`, { method: "POST" });
}
export function markAllAlertsSeen(upTo?: number): Promise<{ marked: number }> {
return request<{ marked: number }>("/alerts/seen", {
body: { account_id: accounts.selectedId, up_to: upTo ?? null },
method: "POST",
});
}
export function listWebhooks(): Promise<Webhook[]> {
return request<Webhook[]>("/webhooks", { account: true });
}
export function createWebhook(draft: WebhookDraft): Promise<Webhook> {
return request<Webhook>("/webhooks", {
body: { account_id: accounts.selectedId, ...draft },
method: "POST",
});
}
export function updateWebhook(
id: number,
draft: WebhookDraft
): Promise<Webhook> {
return request<Webhook>(`/webhooks/${id}`, { body: draft, method: "PUT" });
}
export function deleteWebhook(id: number): Promise<void> {
return request<void>(`/webhooks/${id}`, { method: "DELETE" });
}
export function rotateWebhookSecret(id: number): Promise<Webhook> {
return request<Webhook>(`/webhooks/${id}/secret`, { method: "POST" });
}
export function testWebhook(id: number): Promise<Delivery> {
return request<Delivery>(`/webhooks/${id}/test`, { method: "POST" });
}
export function listDeliveries(id: number): Promise<Delivery[]> {
return request<Delivery[]>(`/webhooks/${id}/deliveries`);
}
+12 -3
View File
@@ -19,6 +19,7 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
@@ -27,7 +28,7 @@
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = $derived({ const titles: Record<RightPanel, string> = $derived({
alerts: "Алерты", alerts: t("settings.alerts"),
annotations: "Заметки", annotations: "Заметки",
bots: "Боты", bots: "Боты",
callbacks: "Callback-данные", callbacks: "Callback-данные",
@@ -46,12 +47,14 @@
stories: "Сторис", stories: "Сторис",
"stories-all": "Все сторис", "stories-all": "Все сторис",
versions: "Версии", versions: "Версии",
watches: "Отслеживания", watches: t("settings.watches"),
webhooks: t("watches.webhooks"),
}); });
const parents: Partial<Record<RightPanel, RightPanel>> = { const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage", "storage-chat": "storage",
"storage-trash": "storage", "storage-trash": "storage",
webhooks: "watches",
}; };
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined); const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
@@ -118,7 +121,13 @@
{:else if ui.rightPanel === "stories-all"} {:else if ui.rightPanel === "stories-all"}
<AllStoriesArchive /> <AllStoriesArchive />
{:else if ui.rightPanel === "watches"} {:else if ui.rightPanel === "watches"}
<WatchesPanel /> {#key accounts.selectedId}
<WatchesPanel />
{/key}
{:else if ui.rightPanel === "webhooks"}
{#key accounts.selectedId}
<WebhooksPanel />
{/key}
{:else if ui.rightPanel === "alerts"} {:else if ui.rightPanel === "alerts"}
<AlertsPanel /> <AlertsPanel />
{:else if ui.rightPanel === "annotations"} {:else if ui.rightPanel === "annotations"}
@@ -1,82 +0,0 @@
<script lang="ts">
import type { Alert } from "#lib/api/types.js";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
alerts: Alert[];
onseen: (alert: Alert) => void;
}
const { alerts, onseen }: Props = $props();
function text(alert: Alert): string {
const p = alert.payload;
const value = p.text ?? p.message ?? p.keyword;
return typeof value === "string" ? value : JSON.stringify(p);
}
</script>
<ul class="alert-list">
{#each alerts as alert (alert.id)}
<li class="alert-item" class:unseen={!alert.seen}>
<div class="alert-main">
<p class="alert-text">{text(alert)}</p>
<time class="alert-date">{new Date(alert.ts).toLocaleString()}</time>
</div>
{#if !alert.seen}
<Button
aria-label="Отметить прочитанным"
onclick={() => onseen(alert)}
round
smaller
variant="translucent"
>
<Icon name="check" />
</Button>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
.alert-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.alert-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.unseen {
border-color: var(--color-primary);
}
}
.alert-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.alert-text {
margin: 0;
overflow-wrap: anywhere;
}
.alert-date {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,72 +1,640 @@
<script lang="ts"> <script lang="ts">
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js"; import { onMount, untrack } from "svelte";
import type { Alert } from "#lib/api/types.js"; import {
type AlertItem,
listAlerts,
markAlertSeen,
markAllAlertsSeen,
} from "#lib/api/watches.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import {
alertAvatar,
alertLabel,
alertPreview,
alertTitle,
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import Spinner from "../ui/Spinner.svelte"; import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import AlertList from "./AlertList.svelte"; import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let alerts = $state<Alert[]>([]); type Filter = "all" | "unseen";
const PAGE = 50;
let items = $state<AlertItem[]>([]);
let nextBefore = $state<number | null>(null);
let loading = $state(true); let loading = $state(true);
let unseenOnly = $state(false); let loadingMore = $state(false);
let failed = $state(false);
let notifyProblem = $state(false);
let token = 0;
async function load(_account: number | null, unseen: boolean) { let filter = $state<Filter>(
loading = true; untrack(() => page.url.searchParams.get(ALERTS_PARAM)) === "unseen"
? "unseen"
: "all"
);
function writeParam(value: Filter | null) {
const url = new URL(page.url.href);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
}
function setFilter(next: Filter) {
filter = next;
writeParam(next);
}
async function load(current: Filter, before: number | null = null) {
token += 1;
const mine = token;
if (before === null) {
loading = true;
} else {
loadingMore = true;
}
try { try {
alerts = await listAlerts(unseen ? { seen: false } : {}); const result = await listAlerts({
before: before ?? undefined,
limit: PAGE,
seen: current === "unseen" ? false : undefined,
});
if (mine !== token) {
return;
}
items = before === null ? result.items : [...items, ...result.items];
nextBefore = result.next_before;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally { } finally {
loading = false; if (mine === token) {
loading = false;
loadingMore = false;
}
} }
} }
$effect(() => { $effect(() => {
load(accounts.selectedId, unseenOnly); if (accounts.selectedId !== null) {
load(filter);
}
}); });
async function seen(alert: Alert) { onMount(() => {
await markAlertSeen(alert.id); writeParam(filter);
if (unseenOnly) { const stop = alerts.onAlert((alert) => {
alerts = alerts.filter((a) => a.id !== alert.id); if (!items.some((item) => item.id === alert.id)) {
} else { items = [alert, ...items];
alerts = alerts.map((a) => }
a.id === alert.id ? { ...a, seen: true } : a });
); return () => {
stop();
writeParam(null);
};
});
function markLocal(alert: AlertItem) {
if (alert.seen) {
return;
} }
items = items.map((item) =>
item.id === alert.id ? { ...item, seen: true } : item
);
alerts.read();
}
async function seen(alert: AlertItem) {
if (alert.seen) {
return;
}
markLocal(alert);
await markAlertSeen(alert.id).catch(() => undefined);
}
async function openAlert(alert: AlertItem) {
seen(alert);
await goto(alert.path);
if (isMobile()) {
ui.closePanel();
}
}
async function seeAll() {
items = items.map((item) => ({ ...item, seen: true }));
alerts.readAll();
await markAllAlertsSeen().catch(() => undefined);
}
async function toggleNotify() {
notifyProblem = false;
const wanted = !alerts.notify;
const done = await alerts.setNotify(wanted);
notifyProblem = wanted && !done;
} }
</script> </script>
<div class="panel-content"> <div class="tabs">
<label class="toggle"> <button
<input type="checkbox" bind:checked={unseenOnly}> aria-pressed={filter === "all"}
<span>Только непрочитанные</span> class="tab"
</label> onclick={() => setFilter("all")}
type="button"
{#if loading} class:on={filter === "all"}
<Spinner /> >
{:else if alerts.length === 0} {t("alerts.all")}
<p class="empty">Алертов нет.</p> </button>
{:else} <button
<AlertList {alerts} onseen={seen} /> aria-pressed={filter === "unseen"}
{/if} class="tab"
onclick={() => setFilter("unseen")}
type="button"
class:on={filter === "unseen"}
>
{t("alerts.unseen")}
</button>
</div> </div>
{#if alerts.unseen > 0}
<button class="act" onclick={seeAll} type="button">
<Icon name="readchats" size="1.375rem" />
<span class="label">{t("alerts.markAll")}</span>
<span class="value">{alerts.unseen}</span>
</button>
{/if}
{#if alerts.notifySupported}
<button
aria-checked={alerts.notify}
class="toggle"
onclick={toggleNotify}
role="switch"
type="button"
>
<Icon name="unmute" size="1.375rem" />
<span class="label">{t("alerts.notify")}</span>
<span class="switch" class:on={alerts.notify}
><span class="knob"></span></span
>
</button>
{#if notifyProblem}
<p class="problem">{t("alerts.notifyDenied")}</p>
{/if}
{/if}
{#if loading && items.length === 0}
<div class="state"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("alerts.loadFailed")}</p>
<Button onclick={() => load(filter)} smaller variant="text">
{t("alerts.retry")}
</Button>
</div>
{:else if items.length === 0}
<div class="empty">
<EmptyState
description={filter === "unseen"
? undefined
: t("alerts.empty.description")}
title={filter === "unseen"
? t("alerts.emptyUnseen.title")
: t("alerts.empty.title")}
/>
{#if filter === "all"}
<Button onclick={() => ui.openPanel("watches")} smaller variant="text">
{t("alerts.setup")}
</Button>
{/if}
</div>
{:else}
<ul class="list">
{#each items as alert (alert.id)}
{@const avatar = alertAvatar(alert)}
{@const preview = alertPreview(alert)}
<li>
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
class="alert"
onclick={() => openAlert(alert)}
type="button"
class:unseen={!alert.seen}
>
<span class="lead">
<Avatar
avatar={{ id: avatar.id, kind: avatar.kind }}
colorKey={avatar.id}
hasAvatar={avatar.hasAvatar}
name={avatar.name}
size={3}
/>
<span class="kind {alert.event}">
<Icon name={EVENT_ICONS[alert.event]} size="0.75rem" />
</span>
</span>
<span class="text">
<span class="top">
<span class="title">{alertTitle(alert)}</span>
<time datetime={alert.ts} title={formatFull(alert.ts)}>
{formatListDate(alert.ts)}
</time>
</span>
<span class="bottom">
<span class="line">
<span class="what" class:alone={!preview}>
{alertLabel(alert)}
</span>
{preview}
</span>
{#if !alert.seen}
<span class="dot"></span>
{/if}
</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem
icon={alert.message ? "reply" : "message"}
onselect={() => openAlert(alert)}
>
{alert.message ? t("alerts.open") : t("alerts.openChat")}
</ContextMenuItem>
{#if !alert.seen}
<ContextMenuItem icon="check" onselect={() => seen(alert)}>
{t("alerts.markSeen")}
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<button
class="more"
disabled={loadingMore}
onclick={() => load(filter, nextBefore)}
type="button"
>
{t("alerts.more")}
</button>
{/if}
{/if}
<style lang="scss"> <style lang="scss">
.panel-content { .tabs {
display: flex; display: flex;
flex-direction: column; flex-shrink: 0;
gap: 1rem; border-bottom: 1px solid var(--color-borders);
padding: 1rem; }
.tab {
cursor: pointer;
flex: 1;
height: 2.75rem;
border: 0;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
transition: color 0.15s ease-out;
&.on {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.on):hover {
color: var(--color-text);
}
}
}
.act,
.toggle {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
.label {
flex: 1;
}
.value {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid currentcolor;
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
} }
.toggle { .toggle {
color: var(--color-text);
:global(.icon) {
color: var(--color-icon-secondary);
}
}
.switch {
display: flex; display: flex;
flex-shrink: 0;
align-items: center; align-items: center;
gap: 0.5rem;
font-size: 0.875rem; width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.problem {
margin: 0;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-borders);
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
p {
margin: 0;
}
} }
.empty { .empty {
color: var(--color-text-secondary); display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2.5rem 1rem 2rem;
text-align: center; text-align: center;
} }
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.alert {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.lead {
position: relative;
flex-shrink: 0;
}
.kind {
position: absolute;
right: -0.125rem;
bottom: -0.125rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-background);
&.delete {
background-color: var(--color-error);
}
&.online {
background-color: var(--color-green);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.top,
.bottom {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.title {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
.unseen & {
font-weight: var(--font-weight-medium);
}
}
time {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.line {
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.what {
color: var(--color-text);
&:not(.alone)::after {
content: ":";
}
}
.dot {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-primary);
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0 0.5rem 1rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
@media (pointer: coarse) {
min-height: 2.75rem;
}
}
</style> </style>
@@ -0,0 +1,305 @@
<script lang="ts">
import type { Chat } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import type { Picked } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
interface Props {
empty: string;
icon: string;
label: string;
onpick: (value: Picked | null) => void;
people?: boolean;
required?: boolean;
value: Picked | null;
}
const {
empty,
icon,
label,
onpick,
people = false,
required = false,
value,
}: Props = $props();
const SHOWN = 8;
let open = $state(false);
let query = $state("");
const picker = createChatPicker();
const results = $derived(
picker.results
.filter((chat) => !people || (chat.chat_id > 0 && !chat.is_bot))
.slice(0, SHOWN)
);
function toggle() {
open = !open;
if (open) {
picker.search(query);
}
}
function choose(chat: Chat) {
onpick({
hasAvatar: chat.has_avatar,
id: chat.chat_id,
title: chat.title ?? t("watches.unknownChat", { id: chat.chat_id }),
});
open = false;
query = "";
}
function focus(node: HTMLInputElement) {
node.focus();
}
</script>
<div class="field" class:open>
<div class="line">
<button aria-expanded={open} class="main" onclick={toggle} type="button">
{#if value}
<Avatar
avatar={{ id: value.id, kind: value.id > 0 ? "peer" : "chat" }}
colorKey={value.id}
hasAvatar={value.hasAvatar}
name={value.title}
size={2.25}
/>
{:else}
<span class="glyph"><Icon name={icon} size="1.5rem" /></span>
{/if}
<span class="text">
<span class="label">{label}</span>
<span class="value" class:placeholder={!value}>
{value?.title ?? (required ? t("watches.choose") : empty)}
</span>
</span>
</button>
{#if value && !required}
<button
aria-label={t("watches.clear")}
class="clear"
onclick={() => onpick(null)}
title={t("watches.clear")}
type="button"
>
<Icon name="close" size="1.125rem" />
</button>
{/if}
</div>
{#if open}
<div class="picker">
<input
aria-label={people ? t("watches.searchPeople") : t("watches.search")}
class="filter"
oninput={() => picker.search(query)}
placeholder={people ? t("watches.searchPeople") : t("watches.search")}
type="text"
bind:value={query}
{@attach focus}
>
{#each results as chat (chat.chat_id)}
<button class="pick" onclick={() => choose(chat)} type="button">
<Avatar
avatar={{
id: chat.chat_id,
kind: chat.chat_id > 0 ? "peer" : "chat",
}}
colorKey={chat.chat_id}
hasAvatar={chat.has_avatar}
name={chat.title ?? ""}
size={2}
/>
<span class="pick-title">
{chat.title ?? t("watches.unknownChat", { id: chat.chat_id })}
</span>
</button>
{:else}
<p class="nothing">{t("watches.nothing")}</p>
{/each}
</div>
{/if}
</div>
<style lang="scss">
.line {
display: flex;
align-items: center;
padding-right: 0.5rem;
}
.open .line {
background-color: var(--color-chat-hover);
}
.main {
cursor: pointer;
display: flex;
flex: 1;
gap: 0.875rem;
align-items: center;
min-width: 0;
min-height: 3.25rem;
padding: 0.375rem 0.5rem 0.375rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
color: var(--color-icon-secondary);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.value {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
&.placeholder {
color: var(--color-primary);
}
}
.clear {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-interactive-element-hover);
}
}
}
.picker {
display: flex;
flex-direction: column;
padding: 0.25rem 0.5rem 0.5rem;
}
.filter {
margin: 0.25rem 0.5rem 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
background-color: var(--color-background);
&:focus {
border-color: var(--color-primary);
outline: none;
}
}
.pick {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
padding: 0.375rem 0.75rem;
border: 0;
border-radius: var(--border-radius-default-small, 0.625rem);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.pick-title {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.nothing {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,121 +0,0 @@
<script lang="ts">
import Button from "../ui/Button.svelte";
interface Props {
onsave: (data: {
kind: string;
params: Record<string, unknown>;
enabled: boolean;
}) => void;
saving?: boolean;
}
const { saving = false, onsave }: Props = $props();
const KINDS = [
{ label: "Keyword", value: "keyword" },
{ label: "Peer online", value: "peer_online" },
];
let kind = $state("keyword");
let keyword = $state("");
let peerId = $state("");
let enabled = $state(true);
const isKeyword = $derived(kind === "keyword");
const valid = $derived(
isKeyword ? keyword.trim() !== "" : peerId.trim() !== ""
);
function submit() {
if (!valid) {
return;
}
const params: Record<string, unknown> = isKeyword
? { keyword: keyword.trim() }
: { peer_id: Number(peerId.trim()) };
onsave({ enabled, kind, params });
keyword = "";
peerId = "";
enabled = true;
}
</script>
<form
class="watch-editor"
onsubmit={(e) => {
e.preventDefault();
submit();
}}
>
<label class="field">
<span class="field-label">Type</span>
<select bind:value={kind}>
{#each KINDS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
{#if isKeyword}
<label class="field">
<span class="field-label">Keyword</span>
<input placeholder="Text to match…" type="text" bind:value={keyword}>
</label>
{:else}
<label class="field">
<span class="field-label">Peer ID</span>
<input
inputmode="numeric"
placeholder="123456789"
type="text"
bind:value={peerId}
>
</label>
{/if}
<label class="field-inline">
<input type="checkbox" bind:checked={enabled}>
<span>Enabled</span>
</label>
<Button disabled={saving || !valid} type="submit">Add watch</Button>
</form>
<style lang="scss">
.watch-editor {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.field-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="text"],
select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-borders);
border-radius: 0.5rem;
background: var(--color-background);
color: var(--color-text);
}
</style>
@@ -0,0 +1,388 @@
<script lang="ts">
import { untrack } from "svelte";
import { ApiError } from "#lib/api/client.js";
import {
createWatch,
updateWatch,
type Watch,
type WatchDraft,
type WatchKind,
type WatchParams,
} from "#lib/api/watches.js";
import Button from "#lib/components/ui/Button.svelte";
import RadioList from "#lib/components/ui/RadioList.svelte";
import {
chatTitle,
type Picked,
patternOf,
personName,
WATCH_KINDS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import ChatPickerField from "./ChatPickerField.svelte";
interface Props {
oncancel: () => void;
onsaved: (watch: Watch) => void;
watch?: Watch | null;
}
const { watch = null, oncancel, onsaved }: Props = $props();
const INVALID_REGEX = "invalid regex: ";
const NESTED_REGEX = "regex repeats inside a repeat";
function pickedChat(source: Watch | null): Picked | null {
const id = source?.params.chat_id;
if (id === undefined || !source) {
return null;
}
return {
hasAvatar: source.chat?.has_avatar ?? false,
id,
title: chatTitle(source.chat, id),
};
}
function pickedPerson(source: Watch | null): Picked | null {
const id = source?.params.sender_id ?? source?.params.peer_id;
if (id === undefined || !source) {
return null;
}
return {
hasAvatar: source.person?.has_avatar ?? false,
id,
title: personName(source.person, id),
};
}
function initial(source: Watch | null) {
return {
chat: pickedChat(source),
kind: source?.kind ?? "keyword",
name: source?.name ?? "",
pattern: source ? patternOf(source.params) : "",
person: pickedPerson(source),
regex: source?.params.regex ?? false,
};
}
const start = untrack(() => initial(watch));
let kind = $state<WatchKind>(start.kind);
let pattern = $state(start.pattern);
let regex = $state(start.regex);
let chat = $state<Picked | null>(start.chat);
let person = $state<Picked | null>(start.person);
let name = $state(start.name);
let issue = $state<string | null>(null);
let saving = $state(false);
const kinds = $derived(
WATCH_KINDS.map((value) => ({ label: t(`watches.kind.${value}`), value }))
);
const scoped = $derived(kind === "keyword" || kind === "new_message");
const chatEmpty = $derived(
kind === "message_deleted" || kind === "message_edited"
? t("watches.anyDm")
: t("watches.anyChat")
);
function params(): WatchParams {
if (kind === "peer_online") {
return { peer_id: person?.id };
}
const scope: WatchParams = {};
if (chat) {
scope.chat_id = chat.id;
}
if (scoped && person) {
scope.sender_id = person.id;
}
if (kind === "keyword") {
return { ...scope, pattern: pattern.trim(), regex };
}
return scope;
}
function validate(): string | null {
if (kind === "keyword" && pattern.trim() === "") {
return t("watches.error.needPattern");
}
if (kind === "new_message" && !(chat || person)) {
return t("watches.error.needScope");
}
if (kind === "peer_online" && !person) {
return t("watches.error.needPerson");
}
return null;
}
function explain(failure: unknown): string {
const detail =
failure instanceof ApiError ? failure.detail : String(failure);
if (detail.startsWith(INVALID_REGEX)) {
return t("watches.error.badRegex", {
detail: detail.slice(INVALID_REGEX.length),
});
}
if (detail.startsWith(NESTED_REGEX)) {
return t("watches.error.slowRegex");
}
return t("watches.error.failed", { detail });
}
async function save() {
issue = validate();
if (issue) {
return;
}
const draft: WatchDraft = {
enabled: watch?.enabled ?? true,
kind,
name: name.trim() || null,
params: params(),
};
saving = true;
try {
onsaved(
watch ? await updateWatch(watch.id, draft) : await createWatch(draft)
);
} catch (failure) {
issue = explain(failure);
} finally {
saving = false;
}
}
</script>
<form
class="watch-form"
onsubmit={(event) => {
event.preventDefault();
save();
}}
>
{#if !watch}
<div class="section-title">{t("watches.kind")}</div>
<RadioList
onselect={(value) => {
kind = value;
issue = null;
}}
options={kinds}
selected={kind}
/>
{/if}
<div class="fields">
{#if kind === "keyword"}
<div class="input-group" class:touched={pattern !== ""}>
<input
class="form-control"
id="watch-pattern-{watch?.id ?? "new"}"
maxlength="200"
spellcheck="false"
type="text"
bind:value={pattern}
>
<label for="watch-pattern-{watch?.id ?? "new"}">
{regex ? t("watches.expression") : t("watches.pattern")}
</label>
</div>
<button
aria-checked={regex}
class="toggle"
onclick={() => {
regex = !regex;
issue = null;
}}
role="switch"
type="button"
>
<span class="toggle-label">{t("watches.regex")}</span>
<span class="switch" class:on={regex}><span class="knob"></span></span>
</button>
{/if}
</div>
{#if kind === "peer_online"}
<ChatPickerField
empty={t("watches.choose")}
icon="user"
label={t("watches.person")}
onpick={(value) => {
person = value;
issue = null;
}}
people
required
value={person}
/>
{:else}
<ChatPickerField
empty={chatEmpty}
icon="group"
label={t("watches.chat")}
onpick={(value) => {
chat = value;
issue = null;
}}
value={chat}
/>
{#if scoped}
<ChatPickerField
empty={t("watches.anySender")}
icon="user"
label={t("watches.sender")}
onpick={(value) => {
person = value;
issue = null;
}}
people
value={person}
/>
{/if}
{/if}
<div class="fields">
<div class="input-group name" class:touched={name !== ""}>
<input
class="form-control"
id="watch-name-{watch?.id ?? "new"}"
maxlength="120"
type="text"
bind:value={name}
>
<label for="watch-name-{watch?.id ?? "new"}">{t("watches.name")}</label>
</div>
</div>
{#if issue}
<p class="error">{issue}</p>
{/if}
<div class="actions">
<Button onclick={oncancel} smaller variant="text">
{t("common.cancel")}
</Button>
<Button disabled={saving} pill smaller type="submit">
{t("common.save")}
</Button>
</div>
</form>
<style lang="scss">
.watch-form {
display: flex;
flex-direction: column;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.section-title {
padding: 0.875rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.fields {
padding: 0 1rem;
}
.input-group {
margin: 0.75rem 0 0.25rem;
&.name {
margin: 0.75rem 0 0.5rem;
}
}
.toggle {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: calc(100% + 2rem);
min-height: 2.75rem;
margin: 0 -1rem;
padding: 0 1rem;
border: 0;
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.toggle-label {
flex: 1;
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.error {
margin: 0;
padding: 0.25rem 1rem;
font-size: 0.8125rem;
color: var(--color-error);
overflow-wrap: anywhere;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding: 0.5rem 1rem 0;
:global(.Button) {
padding-inline: 1rem;
}
}
</style>
@@ -1,111 +0,0 @@
<script lang="ts">
import type { Watch } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js";
import { peers } from "#lib/stores/peers.svelte.js";
import Button from "../ui/Button.svelte";
import Icon from "../ui/Icon.svelte";
interface Props {
ondelete: (watch: Watch) => void;
ontoggle: (watch: Watch) => void;
watches: Watch[];
}
const { watches, ontoggle, ondelete }: Props = $props();
$effect(() => {
const ids = watches
.filter((w) => w.kind === "peer_online")
.map((w) => Number(w.params.peer_id))
.filter((id) => Number.isFinite(id));
peers.ensure(ids);
});
function summary(watch: Watch): string {
if (watch.kind === "keyword") {
return `“${String(watch.params.keyword ?? "")}”`;
}
const id = Number(watch.params.peer_id);
return peerName(peers.get(id) ?? null) || `#${id}`;
}
</script>
<ul class="watch-list">
{#each watches as watch (watch.id)}
<li class="watch-item" class:disabled={!watch.enabled}>
<div class="watch-main">
<span class="watch-kind">{watch.kind}</span>
<span class="watch-summary">{summary(watch)}</span>
</div>
<div class="row-actions">
<Button
aria-label="Переключить"
onclick={() => ontoggle(watch)}
round
smaller
variant="translucent"
>
<Icon name={watch.enabled ? "eye" : "eye-crossed"} />
</Button>
<Button
aria-label="Удалить"
onclick={() => ondelete(watch)}
round
smaller
variant="translucent"
>
<Icon name="delete" />
</Button>
</div>
</li>
{/each}
</ul>
<style lang="scss">
.watch-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.watch-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
&.disabled {
opacity: 0.55;
}
}
.watch-main {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.watch-kind {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.watch-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions {
display: flex;
flex-shrink: 0;
}
</style>
@@ -1,80 +1,475 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { import {
createWatch,
deleteWatch, deleteWatch,
listWatches, listWatches,
listWebhooks,
updateWatch, updateWatch,
} from "#lib/api/endpoints.js"; type Watch,
import type { Watch } from "#lib/api/types.js"; } from "#lib/api/watches.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Spinner from "../ui/Spinner.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import WatchEditor from "./WatchEditor.svelte"; import Button from "#lib/components/ui/Button.svelte";
import WatchList from "./WatchList.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import {
chatTitle,
describeWatch,
KIND_ICONS,
personName,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import WatchForm from "./WatchForm.svelte";
let watches = $state<Watch[]>([]); let watches = $state<Watch[]>([]);
let hookCount = $state(0);
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let failed = $state(false);
let editing = $state<number | "new" | null>(null);
let confirming = $state<number | null>(null);
let problem = $state<string | null>(null);
let token = 0;
async function load(_account: number | null) { async function load() {
loading = true; token += 1;
const current = token;
try { try {
watches = await listWatches(); const [rows, hooks] = await Promise.all([listWatches(), listWebhooks()]);
if (current === token) {
watches = rows;
hookCount = hooks.length;
failed = false;
}
} catch {
if (current === token) {
failed = true;
}
} finally { } finally {
loading = false; if (current === token) {
loading = false;
}
} }
} }
$effect(() => { onMount(load);
load(accounts.selectedId);
});
async function add(data: { function stats(watch: Watch): string {
kind: string; if (watch.alert_count === 0 || watch.last_alert_at === null) {
params: Record<string, unknown>; return t("watches.never");
enabled: boolean;
}) {
saving = true;
try {
const created = await createWatch(data.kind, data.params, data.enabled);
watches = [created, ...watches];
} finally {
saving = false;
} }
return t("watches.fired", {
count: watch.alert_count,
date: formatListDate(watch.last_alert_at),
});
}
function subtitle(watch: Watch): string {
const line = watch.name ? describeWatch(watch) : stats(watch);
return watch.enabled ? line : `${t("watches.off")} · ${line}`;
}
function saved(watch: Watch) {
const known = watches.some((item) => item.id === watch.id);
watches = known
? watches.map((item) => (item.id === watch.id ? watch : item))
: [watch, ...watches];
editing = null;
} }
async function toggle(watch: Watch) { async function toggle(watch: Watch) {
const updated = await updateWatch(watch.id, watch.params, !watch.enabled); problem = null;
watches = watches.map((w) => (w.id === updated.id ? updated : w)); try {
saved(
await updateWatch(watch.id, {
enabled: !watch.enabled,
kind: watch.kind,
name: watch.name,
params: watch.params,
})
);
} catch {
problem = t("watches.error.action");
}
} }
async function remove(watch: Watch) { async function remove(watch: Watch) {
await deleteWatch(watch.id); problem = null;
watches = watches.filter((w) => w.id !== watch.id); try {
await deleteWatch(watch.id);
watches = watches.filter((item) => item.id !== watch.id);
} catch {
problem = t("watches.error.action");
} finally {
confirming = null;
}
}
function edit(watch: Watch) {
confirming = null;
editing = editing === watch.id ? null : watch.id;
}
function reveal(node: HTMLElement) {
node.scrollIntoView({ block: "nearest" });
} }
</script> </script>
<div class="panel-content"> {#snippet lead(
<WatchEditor onsave={add} {saving} /> watch: Watch
)}
{@const chatId = watch.params.chat_id}
{@const personId = watch.params.sender_id ?? watch.params.peer_id}
{#if chatId !== undefined}
<Avatar
avatar={{ id: chatId, kind: chatId > 0 ? "peer" : "chat" }}
colorKey={chatId}
hasAvatar={watch.chat?.has_avatar ?? false}
name={chatTitle(watch.chat, chatId)}
size={2.625}
/>
{:else if personId !== undefined}
<Avatar
avatar={{ id: personId, kind: "peer" }}
colorKey={personId}
hasAvatar={watch.person?.has_avatar ?? false}
name={personName(watch.person, personId)}
size={2.625}
/>
{:else}
<span class="glyph"
><Icon name={KIND_ICONS[watch.kind]} size="1.5rem" /></span
>
{/if}
{/snippet}
<div class="watches">
<button
aria-expanded={editing === "new"}
class="add"
onclick={() => {
editing = editing === "new" ? null : "new";
}}
type="button"
>
<Icon name="add" size="1.25rem" />
<span>{t("watches.add")}</span>
</button>
{#if editing === "new"}
<div {@attach reveal}>
<WatchForm
oncancel={() => {
editing = null;
}}
onsaved={saved}
/>
</div>
{/if}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if loading} {#if loading}
<Spinner /> <div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{t("watches.loadFailed")}</p>
<Button onclick={load} smaller variant="text"
>{t("watches.retry")}</Button
>
</div>
{:else if watches.length === 0} {:else if watches.length === 0}
<p class="empty">Пока нет отслеживаний.</p> <div class="empty">
<EmptyState
description={t("watches.empty.description")}
title={t("watches.empty.title")}
/>
</div>
{:else} {:else}
<WatchList ondelete={remove} ontoggle={toggle} {watches} /> <ul class="list">
{#each watches as watch (watch.id)}
<li
class="item"
class:off={!watch.enabled}
class:open={editing === watch.id}
>
{#if confirming === watch.id}
<div class="confirm">
<p>{t("watches.deleteConfirm")}</p>
<div class="confirm-actions">
<Button
onclick={() => {
confirming = null;
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
<Button onclick={() => remove(watch)} smaller variant="danger">
{t("watches.delete")}
</Button>
</div>
</div>
{:else}
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
aria-expanded={editing === watch.id}
class="row"
onclick={() => edit(watch)}
type="button"
>
<span class="lead">{@render lead(watch)}</span>
<span class="text">
<span class="title"
>{watch.name ?? describeWatch(watch)}</span
>
<span class="sub">{subtitle(watch)}</span>
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="edit" onselect={() => edit(watch)}>
{t("watches.edit")}
</ContextMenuItem>
<ContextMenuItem
icon={watch.enabled ? "eye-crossed" : "eye"}
onselect={() => toggle(watch)}
>
{watch.enabled ? t("watches.disable") : t("watches.enable")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => {
editing = null;
confirming = watch.id;
}}
>
{t("watches.delete")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
{#if editing === watch.id}
<div {@attach reveal}>
<WatchForm
oncancel={() => {
editing = null;
}}
onsaved={saved}
{watch}
/>
</div>
{/if}
</li>
{/each}
</ul>
{/if} {/if}
<div class="section">
<SettingsItem
icon="web"
label={t("watches.webhooks")}
onclick={() => ui.openPanel("webhooks")}
value={hookCount > 0 ? String(hookCount) : undefined}
/>
</div>
</div> </div>
<style lang="scss"> <style lang="scss">
.panel-content { .watches {
padding-bottom: 1rem;
}
.add {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.5rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.problem {
margin: 0;
padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.25rem;
padding: 1rem; align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
} }
.empty { .empty {
color: var(--color-text-secondary); padding: 2.5rem 1rem 2rem;
text-align: center; text-align: center;
} }
.list {
margin: 0;
padding: 0.5rem 0;
list-style: none;
}
.item.open {
.row {
background-color: var(--color-chat-hover);
}
}
.row {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
min-height: 3.75rem;
padding: 0.5rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.lead {
display: flex;
flex-shrink: 0;
}
.off .lead,
.off .title {
opacity: 0.55;
}
.glyph {
display: flex;
align-items: center;
justify-content: center;
width: 2.625rem;
height: 2.625rem;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title,
.sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
font-size: 0.9375rem;
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.confirm {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 3.75rem;
padding: 0.625rem 1rem 0.375rem;
background-color: var(--color-chat-hover);
p {
margin: 0;
font-size: 0.9375rem;
}
}
.confirm-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
:global(.Button) {
padding-inline: 1rem;
}
}
.section {
padding: 0.5rem 0;
border-top: 1px solid var(--color-borders);
}
</style> </style>
@@ -0,0 +1,676 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ApiError } from "#lib/api/client.js";
import {
type Delivery,
listDeliveries,
rotateWebhookSecret,
testWebhook,
updateWebhook,
type Watch,
type Webhook,
type WebhookDraft,
} from "#lib/api/watches.js";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { deliveryStatus, describeWatch } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { alerts } from "#lib/stores/alerts.svelte.js";
interface Props {
intent?: "test" | "delete" | null;
onchange: (webhook: Webhook) => void;
ondelete: () => void;
watches: Watch[];
webhook: Webhook;
}
const {
webhook,
watches,
onchange,
ondelete,
intent = null,
}: Props = $props();
const COPIED_MS = 1500;
const LOG_DELAY_MS = 1500;
const STATUS_ICONS: Record<Delivery["status"], string> = {
delivered: "check",
failed: "warning",
pending: "recent",
sending: "recent",
};
let url = $state(untrack(() => webhook.url));
let log = $state<Delivery[]>([]);
let logLoaded = $state(false);
let copied = $state(false);
let testing = $state(false);
let result = $state<Delivery | null>(null);
let problem = $state<string | null>(null);
let confirming = $state(false);
const dirty = $derived(url.trim() !== webhook.url);
const chosen = $derived(new Set(webhook.watch_ids ?? []));
async function loadLog() {
try {
log = await listDeliveries(webhook.id);
} finally {
logLoaded = true;
}
}
function draft(changes: Partial<WebhookDraft>): WebhookDraft {
return {
enabled: webhook.enabled,
url: webhook.url,
watch_ids: webhook.watch_ids,
...changes,
};
}
async function save(changes: Partial<WebhookDraft>) {
problem = null;
try {
onchange(await updateWebhook(webhook.id, draft(changes)));
} catch (failure) {
problem =
failure instanceof ApiError && failure.status === 422
? t("webhooks.badUrl")
: t("watches.error.action");
}
}
function pick(watchId: number | null) {
if (watchId === null) {
save({ watch_ids: null });
return;
}
const current = webhook.watch_ids ?? [];
const next = current.includes(watchId)
? current.filter((id) => id !== watchId)
: [...current, watchId];
save({ watch_ids: next.length > 0 ? next : null });
}
async function copy() {
await navigator.clipboard.writeText(webhook.secret);
copied = true;
setTimeout(() => {
copied = false;
}, COPIED_MS);
}
async function rotate() {
problem = null;
try {
onchange(await rotateWebhookSecret(webhook.id));
} catch {
problem = t("watches.error.action");
}
}
async function test() {
testing = true;
result = null;
problem = null;
try {
result = await testWebhook(webhook.id);
await loadLog();
} catch {
problem = t("watches.error.action");
} finally {
testing = false;
}
}
function detail(delivery: Delivery): string {
const line = deliveryStatus(delivery);
return delivery.attempts > 1
? `${line} · ${t("webhooks.attempts", { count: delivery.attempts })}`
: line;
}
onMount(() => {
loadLog();
if (intent === "test") {
test();
}
confirming = intent === "delete";
let timer: ReturnType<typeof setTimeout> | undefined;
const stop = alerts.onAlert(() => {
clearTimeout(timer);
timer = setTimeout(loadLog, LOG_DELAY_MS);
});
return () => {
clearTimeout(timer);
stop();
};
});
</script>
{#snippet choice(
label: string,
checked: boolean,
watchId: number | null
)}
<label class="choice">
<input
{checked}
class="native"
onchange={() => pick(watchId)}
type="checkbox"
>
<span class="circle" class:on={checked}>
<Icon name="check" size="0.875rem" />
</span>
<span class="choice-label">{label}</span>
</label>
{/snippet}
<div class="card">
<div class="fields">
<div class="input-group touched">
<input
class="form-control"
id="webhook-url-{webhook.id}"
inputmode="url"
spellcheck="false"
type="url"
bind:value={url}
>
<label for="webhook-url-{webhook.id}">{t("webhooks.url")}</label>
</div>
{#if dirty}
<div class="row-actions">
<Button
onclick={() => {
({ url } = webhook);
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
<Button onclick={() => save({ url: url.trim() })} pill smaller>
{t("common.save")}
</Button>
</div>
{/if}
</div>
<div class="secret">
<div class="secret-text">
<span class="secret-label">{t("webhooks.secret")}</span>
<code class="secret-value">{webhook.secret}</code>
</div>
<button
aria-label={t("webhooks.copy")}
class="icon-button"
onclick={copy}
title={copied ? t("webhooks.copied") : t("webhooks.copy")}
type="button"
class:done={copied}
>
<Icon name={copied ? "check" : "copy"} size="1.25rem" />
</button>
</div>
<p class="hint">
{copied ? t("webhooks.copied") : t("webhooks.signature")}
</p>
<div class="row-actions start">
<Button onclick={rotate} smaller variant="text">
{t("webhooks.rotate")}
</Button>
</div>
<button
aria-checked={webhook.enabled}
class="toggle"
onclick={() => save({ enabled: !webhook.enabled })}
role="switch"
type="button"
>
<span class="toggle-label">{t("webhooks.enabled")}</span>
<span class="switch" class:on={webhook.enabled}>
<span class="knob"></span>
</span>
</button>
<div class="section-title">{t("webhooks.filter")}</div>
{@render choice(t("webhooks.allWatches"), chosen.size === 0, null)}
{#each watches as watch (watch.id)}
{@render choice(
watch.name ?? describeWatch(watch),
chosen.has(watch.id),
watch.id
)}
{/each}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if confirming}
<div class="confirm">
<p>{t("webhooks.deleteConfirm")}</p>
<div class="row-actions">
<Button
onclick={() => {
confirming = false;
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
<Button onclick={ondelete} smaller variant="danger">
{t("watches.delete")}
</Button>
</div>
</div>
{:else}
<div class="row-actions split">
<Button
onclick={() => {
confirming = true;
}}
smaller
variant="text"
>
<span class="danger-text">{t("watches.delete")}</span>
</Button>
<Button disabled={testing} onclick={test} pill smaller>
{testing ? t("webhooks.testing") : t("webhooks.test")}
</Button>
</div>
{/if}
{#if result}
<p class="result" class:failed={result.status !== "delivered"}>
{result.status === "delivered"
? t("webhooks.testOk", { code: result.response_code ?? "" })
: t("webhooks.testFailed", {
error: result.last_error ?? String(result.response_code ?? ""),
})}
</p>
{/if}
<div class="section-title">{t("webhooks.log")}</div>
{#if !logLoaded}
<div class="state"><Spinner /></div>
{:else if log.length === 0}
<p class="nothing">{t("webhooks.logEmpty")}</p>
{:else}
<ul class="log">
{#each log as delivery (delivery.id)}
<li class="entry {delivery.status}">
<span class="entry-icon">
<Icon name={STATUS_ICONS[delivery.status]} size="1.125rem" />
</span>
<span class="entry-text">
<span class="entry-title">
{t(`webhooks.event.${delivery.event}`)}
{#if delivery.alert_id !== null}
<span class="entry-id">#{delivery.alert_id}</span>
{/if}
</span>
<span class="entry-detail">{detail(delivery)}</span>
</span>
<time class="entry-time" datetime={delivery.created_at}>
{formatListDate(delivery.created_at)}
</time>
</li>
{/each}
</ul>
{/if}
</div>
<style lang="scss">
.card {
display: flex;
flex-direction: column;
padding: 0.5rem 0 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.fields {
padding: 0.5rem 1rem 0;
}
.input-group {
margin-bottom: 0.5rem;
}
.row-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding: 0 1rem;
&.start {
justify-content: flex-start;
padding: 0 0.5rem;
}
&.split {
justify-content: space-between;
padding: 0.5rem 1rem 0 0.5rem;
}
:global(.Button) {
padding-inline: 1rem;
}
}
.fields .row-actions {
padding: 0 0 0.5rem;
}
.danger-text {
color: var(--color-error);
}
.secret {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.25rem 1rem 0;
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: var(--color-background-secondary);
}
.secret-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.secret-label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.secret-value {
overflow: hidden;
font-family: var(--font-family-monospace, monospace);
font-size: 0.8125rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-button {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background: none;
&.done {
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-interactive-element-hover);
}
}
}
.hint {
margin: 0;
padding: 0.375rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.toggle,
.choice {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 2.75rem;
padding: 0 1rem;
border: 0;
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.native {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.choice {
position: relative;
&:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.toggle-label,
.choice-label {
overflow: hidden;
flex: 1;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.circle {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border: 2px solid var(--color-borders-input);
border-radius: 50%;
color: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&.on {
border-color: var(--color-primary);
color: var(--color-white);
background-color: var(--color-primary);
}
}
.section-title {
padding: 0.875rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.problem,
.result {
margin: 0;
padding: 0.375rem 1rem 0;
font-size: 0.8125rem;
overflow-wrap: anywhere;
}
.problem,
.result.failed {
color: var(--color-error);
}
.result {
text-align: end;
color: var(--color-text-green);
}
.confirm {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0 0;
p {
margin: 0;
padding: 0 1rem;
font-size: 0.9375rem;
}
}
.state {
display: flex;
justify-content: center;
padding: 0.75rem 0;
}
.nothing {
margin: 0;
padding: 0.25rem 1rem 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.log {
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 0.75rem;
align-items: center;
min-height: 2.75rem;
padding: 0.25rem 1rem;
}
.entry-icon {
display: flex;
flex-shrink: 0;
color: var(--color-text-secondary);
.delivered & {
color: var(--color-text-green);
}
.failed & {
color: var(--color-error);
}
}
.entry-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.entry-title {
font-size: 0.875rem;
}
.entry-id {
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.entry-detail {
overflow: hidden;
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-time {
flex-shrink: 0;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,443 @@
<script lang="ts">
import { onMount } from "svelte";
import { ApiError } from "#lib/api/client.js";
import {
createWebhook,
deleteWebhook,
listWatches,
listWebhooks,
updateWebhook,
type Watch,
type Webhook,
} from "#lib/api/watches.js";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { deliveryStatus } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import WebhookCard from "./WebhookCard.svelte";
type Intent = "test" | "delete";
const SCHEME = /^https?:\/\//;
let hooks = $state<Webhook[]>([]);
let watches = $state<Watch[]>([]);
let loading = $state(true);
let failed = $state(false);
let expanded = $state<number | null>(null);
let intent = $state<{ action: Intent; id: number } | null>(null);
let adding = $state(false);
let newUrl = $state("");
let addError = $state<string | null>(null);
let problem = $state<string | null>(null);
async function load() {
try {
[hooks, watches] = await Promise.all([listWebhooks(), listWatches()]);
failed = false;
} catch {
failed = true;
} finally {
loading = false;
}
}
function replace(next: Webhook) {
hooks = hooks.map((hook) => (hook.id === next.id ? next : hook));
}
function status(hook: Webhook): string {
const last = hook.last_delivery;
const line = last
? `${deliveryStatus(last)} · ${formatListDate(last.created_at)}`
: t("webhooks.never");
return hook.enabled ? line : `${t("webhooks.off")} · ${line}`;
}
async function add() {
addError = null;
try {
const hook = await createWebhook({
enabled: true,
url: newUrl.trim(),
watch_ids: null,
});
hooks = [...hooks, hook];
adding = false;
newUrl = "";
expanded = hook.id;
} catch (failure) {
addError =
failure instanceof ApiError && failure.status === 422
? t("webhooks.badUrl")
: t("watches.error.action");
}
}
async function toggle(hook: Webhook) {
problem = null;
try {
replace(
await updateWebhook(hook.id, {
enabled: !hook.enabled,
url: hook.url,
watch_ids: hook.watch_ids,
})
);
} catch {
problem = t("watches.error.action");
}
}
async function remove(hook: Webhook) {
problem = null;
try {
await deleteWebhook(hook.id);
hooks = hooks.filter((item) => item.id !== hook.id);
expanded = null;
} catch {
problem = t("watches.error.action");
}
}
function open(hook: Webhook, action: Intent | null = null) {
intent = action ? { action, id: hook.id } : null;
expanded = expanded === hook.id && !action ? null : hook.id;
}
function reveal(node: HTMLElement) {
node.parentElement?.scrollIntoView({ block: "nearest" });
}
onMount(load);
</script>
<div class="webhooks">
<button
aria-expanded={adding}
class="add"
onclick={() => {
adding = !adding;
addError = null;
}}
type="button"
>
<Icon name="add" size="1.25rem" />
<span>{t("webhooks.add")}</span>
</button>
{#if adding}
<form
class="new"
onsubmit={(event) => {
event.preventDefault();
add();
}}
>
<div
class="input-group"
class:error={addError}
class:touched={newUrl !== ""}
>
<input
class="form-control"
id="webhook-new-url"
inputmode="url"
placeholder="https://"
spellcheck="false"
type="url"
bind:value={newUrl}
>
<label for="webhook-new-url">{addError ?? t("webhooks.url")}</label>
</div>
<div class="actions">
<Button
onclick={() => {
adding = false;
newUrl = "";
}}
smaller
variant="text"
>
{t("common.cancel")}
</Button>
{#if newUrl.trim() !== ""}
<Button pill smaller type="submit">{t("common.save")}</Button>
{/if}
</div>
</form>
{/if}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{t("watches.loadFailed")}</p>
<Button onclick={load} smaller variant="text"
>{t("watches.retry")}</Button
>
</div>
{:else if hooks.length === 0 && !adding}
<div class="empty">
<EmptyState
description={t("webhooks.empty.description")}
title={t("webhooks.empty.title")}
/>
</div>
{:else}
<ul class="list">
{#each hooks as hook (hook.id)}
<li
class="item"
class:off={!hook.enabled}
class:open={expanded === hook.id}
>
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
aria-expanded={expanded === hook.id}
class="row"
onclick={() => open(hook)}
type="button"
>
<span class="glyph"><Icon name="web" size="1.5rem" /></span>
<span class="text">
<span class="title">{hook.url.replace(SCHEME, "")}</span>
<span
class="sub"
class:failed={hook.last_delivery?.status === "failed"}
>
{status(hook)}
</span>
</span>
<Icon
name={expanded === hook.id ? "up" : "down"}
size="1.125rem"
/>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="send" onselect={() => open(hook, "test")}>
{t("webhooks.test")}
</ContextMenuItem>
<ContextMenuItem
icon={hook.enabled ? "eye-crossed" : "eye"}
onselect={() => toggle(hook)}
>
{hook.enabled ? t("watches.disable") : t("watches.enable")}
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => open(hook, "delete")}
>
{t("watches.delete")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
{#if expanded === hook.id}
{#key intent}
<div {@attach reveal}>
<WebhookCard
intent={intent?.id === hook.id ? intent.action : null}
onchange={replace}
ondelete={() => remove(hook)}
{watches}
webhook={hook}
/>
</div>
{/key}
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style lang="scss">
.webhooks {
padding-bottom: 1rem;
}
.add {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.5rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.new {
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
.input-group {
margin-bottom: 0.5rem;
}
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
:global(.Button) {
padding-inline: 1rem;
}
}
.problem {
margin: 0;
padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.empty {
padding: 2.5rem 1rem 2rem;
text-align: center;
}
.list {
margin: 0;
padding: 0.5rem 0;
list-style: none;
}
.item.open .row {
background-color: var(--color-chat-hover);
}
.row {
cursor: pointer;
display: flex;
gap: 0.875rem;
align-items: center;
width: 100%;
min-height: 3.75rem;
padding: 0.5rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
transition: background-color 0.15s ease;
:global(.icon) {
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.625rem;
height: 2.625rem;
border-radius: 50%;
background-color: var(--color-background-secondary);
}
.off .glyph,
.off .title {
opacity: 0.55;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title,
.sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
font-size: 0.9375rem;
}
.sub {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
&.failed {
color: var(--color-error);
}
}
</style>
+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;
}
+4
View File
@@ -1,15 +1,19 @@
import { account } from "#lib/i18n/messages/account.js"; import { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.js";
import { common } from "#lib/i18n/messages/common.js"; import { common } from "#lib/i18n/messages/common.js";
import { menu } from "#lib/i18n/messages/menu.js"; import { menu } from "#lib/i18n/messages/menu.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
import { settings } from "#lib/i18n/messages/settings.js"; import { settings } from "#lib/i18n/messages/settings.js";
import { watches } from "#lib/i18n/messages/watches.js";
export const messages = { export const messages = {
...account, ...account,
...alerts,
...common, ...common,
...menu, ...menu,
...read, ...read,
...settings, ...settings,
...watches,
}; };
export type MessageKey = keyof typeof messages; export type MessageKey = keyof typeof messages;
+47
View File
@@ -0,0 +1,47 @@
import type { Messages } from "#lib/i18n/types.js";
export const alerts = {
"alerts.all": { en: "All", ru: "Все" },
"alerts.badge": {
en: { one: "{count} unread alert", other: "{count} unread alerts" },
ru: {
few: "{count} непрочитанных оповещения",
many: "{count} непрочитанных оповещений",
one: "{count} непрочитанное оповещение",
other: "{count} непрочитанных оповещения",
},
},
"alerts.empty.description": {
en: "They show up here when a watch fires",
ru: "Они появятся здесь, когда сработает наблюдение",
},
"alerts.empty.title": { en: "No alerts", ru: "Оповещений нет" },
"alerts.emptyUnseen.title": { en: "All read", ru: "Всё прочитано" },
"alerts.event.delete": { en: "Deleted", ru: "Удалено" },
"alerts.event.edit": { en: "Edited", ru: "Изменено" },
"alerts.event.message": { en: "New", ru: "Новое" },
"alerts.event.online": { en: "Online", ru: "В сети" },
"alerts.event.transcript": { en: "In a voice message", ru: "В голосовом" },
"alerts.loadFailed": {
en: "Couldn't load alerts",
ru: "Не удалось загрузить оповещения",
},
"alerts.markAll": { en: "Mark all as read", ru: "Отметить все прочитанными" },
"alerts.markSeen": { en: "Mark as read", ru: "Отметить прочитанным" },
"alerts.more": { en: "Show more", ru: "Показать ещё" },
"alerts.noText": { en: "No text", ru: "Без текста" },
"alerts.notify": {
en: "Browser notifications",
ru: "Уведомления в браузере",
},
"alerts.notifyDenied": {
en: "The browser blocked notifications for this site",
ru: "Браузер запретил уведомления для этого сайта",
},
"alerts.onlineAfter": { en: "after {away} away", ru: "не было {away}" },
"alerts.open": { en: "Go to message", ru: "Перейти к сообщению" },
"alerts.openChat": { en: "Open chat", ru: "Открыть чат" },
"alerts.retry": { en: "Retry", ru: "Повторить" },
"alerts.setup": { en: "Set up watches", ru: "Настроить наблюдения" },
"alerts.unseen": { en: "Unread", ru: "Непрочитанные" },
} satisfies Messages;
+206
View File
@@ -0,0 +1,206 @@
import type { Messages } from "#lib/i18n/types.js";
export const watches = {
"watches.add": { en: "New watch", ru: "Новое наблюдение" },
"watches.anyChat": { en: "Any chat", ru: "Любой чат" },
"watches.anyDm": { en: "Any private chat", ru: "Любая личка" },
"watches.anySender": { en: "Anyone", ru: "Кто угодно" },
"watches.chat": { en: "Chat", ru: "Чат" },
"watches.choose": { en: "Choose", ru: "Выбрать" },
"watches.clear": { en: "Clear", ru: "Убрать" },
"watches.delete": { en: "Delete", ru: "Удалить" },
"watches.deleteConfirm": {
en: "Delete the watch with its alerts?",
ru: "Удалить наблюдение вместе с оповещениями?",
},
"watches.describe.deletedDm": {
en: "Deleted messages in private chats",
ru: "Удалённые сообщения в личке",
},
"watches.describe.deletedIn": {
en: "Deleted messages in {chat}",
ru: "Удалённые сообщения в чате «{chat}»",
},
"watches.describe.editedDm": {
en: "Edited messages in private chats",
ru: "Изменённые сообщения в личке",
},
"watches.describe.editedIn": {
en: "Edited messages in {chat}",
ru: "Изменённые сообщения в чате «{chat}»",
},
"watches.describe.keyword": {
en: "{what} in any chat",
ru: "{what} в любом чате",
},
"watches.describe.keywordFrom": {
en: "{what} from {person}",
ru: "{what}, отправитель {person}",
},
"watches.describe.keywordFromIn": {
en: "{what} from {person} in {chat}",
ru: "{what} в чате «{chat}», отправитель {person}",
},
"watches.describe.keywordIn": {
en: "{what} in {chat}",
ru: "{what} в чате «{chat}»",
},
"watches.describe.newFrom": {
en: "New messages from {person}",
ru: "Новые сообщения, отправитель {person}",
},
"watches.describe.newFromIn": {
en: "New messages from {person} in {chat}",
ru: "Новые сообщения в чате «{chat}», отправитель {person}",
},
"watches.describe.newIn": {
en: "New messages in {chat}",
ru: "Новые сообщения в чате «{chat}»",
},
"watches.describe.online": {
en: "{person} is online",
ru: "{person} в сети",
},
"watches.describe.regex": {
en: "Pattern “{pattern}”",
ru: "Шаблон «{pattern}»",
},
"watches.describe.word": { en: "Word “{pattern}”", ru: "Слово «{pattern}»" },
"watches.disable": { en: "Turn off", ru: "Выключить" },
"watches.edit": { en: "Edit", ru: "Изменить" },
"watches.empty.description": {
en: "A watch looks for a word, a person or deleted messages and raises an alert",
ru: "Наблюдение ищет слово, человека или удалённые сообщения и присылает оповещение",
},
"watches.empty.title": { en: "No watches yet", ru: "Наблюдений пока нет" },
"watches.enable": { en: "Turn on", ru: "Включить" },
"watches.error.action": {
en: "That didn't work, try again",
ru: "Не получилось, попробуйте ещё раз",
},
"watches.error.badRegex": {
en: "Can't read the expression: {detail}",
ru: "Не получилось разобрать выражение: {detail}",
},
"watches.error.failed": {
en: "Couldn't save: {detail}",
ru: "Не удалось сохранить: {detail}",
},
"watches.error.needPattern": {
en: "Enter a word or a phrase",
ru: "Впишите слово или фразу",
},
"watches.error.needPerson": {
en: "Choose a person",
ru: "Выберите человека",
},
"watches.error.needScope": {
en: "Choose a chat or a sender",
ru: "Выберите чат или отправителя",
},
"watches.error.slowRegex": {
en: "A repeat inside a repeat, like (a+)+, can hang. Make the expression simpler",
ru: "Повтор внутри повтора, вроде (a+)+, может зависнуть. Упростите выражение",
},
"watches.expression": { en: "Expression", ru: "Выражение" },
"watches.fired": {
en: {
one: "{count} alert, last {date}",
other: "{count} alerts, last {date}",
},
ru: {
few: "{count} оповещения, последнее {date}",
many: "{count} оповещений, последнее {date}",
one: "{count} оповещение, последнее {date}",
other: "{count} оповещения, последнее {date}",
},
},
"watches.kind": { en: "What to watch", ru: "Что ищем" },
"watches.kind.keyword": { en: "A word or a pattern", ru: "Слово или шаблон" },
"watches.kind.message_deleted": {
en: "Deleted messages",
ru: "Удалённые сообщения",
},
"watches.kind.message_edited": {
en: "Edited messages",
ru: "Изменённые сообщения",
},
"watches.kind.new_message": { en: "New messages", ru: "Новые сообщения" },
"watches.kind.peer_online": { en: "Coming online", ru: "Появление в сети" },
"watches.loadFailed": {
en: "Couldn't load watches",
ru: "Не удалось загрузить наблюдения",
},
"watches.name": { en: "Name, optional", ru: "Название, если нужно" },
"watches.never": { en: "Hasn't fired yet", ru: "Ещё не срабатывало" },
"watches.nothing": { en: "Nothing found", ru: "Ничего не найдено" },
"watches.off": { en: "Off", ru: "Выключено" },
"watches.pattern": { en: "Word or phrase", ru: "Слово или фраза" },
"watches.person": { en: "Person", ru: "Человек" },
"watches.regex": { en: "Regular expression", ru: "Регулярное выражение" },
"watches.retry": { en: "Retry", ru: "Повторить" },
"watches.search": { en: "Search chats", ru: "Поиск чата" },
"watches.searchPeople": { en: "Search people", ru: "Поиск человека" },
"watches.sender": { en: "Sender", ru: "Отправитель" },
"watches.unknownChat": { en: "Chat {id}", ru: "Чат {id}" },
"watches.unknownPerson": { en: "User {id}", ru: "Пользователь {id}" },
"watches.webhooks": { en: "Webhooks", ru: "Вебхуки" },
"webhooks.add": { en: "Add a webhook", ru: "Добавить вебхук" },
"webhooks.allWatches": { en: "All watches", ru: "Все наблюдения" },
"webhooks.attempts": {
en: { one: "{count} attempt", other: "{count} attempts" },
ru: {
few: "{count} попытки",
many: "{count} попыток",
one: "{count} попытка",
other: "{count} попытки",
},
},
"webhooks.badUrl": {
en: "Needs an http or https address",
ru: "Нужен адрес http или https",
},
"webhooks.copied": { en: "Copied", ru: "Скопировано" },
"webhooks.copy": { en: "Copy", ru: "Скопировать" },
"webhooks.deleteConfirm": {
en: "Delete the webhook?",
ru: "Удалить вебхук?",
},
"webhooks.empty.description": {
en: "Every alert goes out as a signed POST to your address",
ru: "Каждое оповещение уйдёт POST-запросом с подписью на ваш адрес",
},
"webhooks.empty.title": { en: "No webhooks", ru: "Вебхуков нет" },
"webhooks.enabled": { en: "Send alerts", ru: "Отправлять оповещения" },
"webhooks.event.alert": { en: "Alert", ru: "Оповещение" },
"webhooks.event.test": { en: "Test", ru: "Проверка" },
"webhooks.filter": { en: "Which watches", ru: "Какие наблюдения" },
"webhooks.log": { en: "Recent deliveries", ru: "Последние доставки" },
"webhooks.logEmpty": {
en: "Nothing sent yet",
ru: "Пока ничего не отправлено",
},
"webhooks.never": { en: "Nothing sent yet", ru: "Ещё не отправлялся" },
"webhooks.off": { en: "Off", ru: "Выключен" },
"webhooks.rotate": { en: "New secret", ru: "Новый секрет" },
"webhooks.secret": { en: "Signing secret", ru: "Секрет подписи" },
"webhooks.signature": {
en: "Header X-Beavergram-Signature: sha256=HMAC of the body",
ru: "Заголовок X-Beavergram-Signature: sha256=HMAC тела",
},
"webhooks.status.delivered": { en: "Delivered", ru: "Доставлено" },
"webhooks.status.failed": { en: "Not delivered", ru: "Не доставлено" },
"webhooks.status.pending": { en: "Retry at {time}", ru: "Повтор в {time}" },
"webhooks.status.sending": { en: "Sending", ru: "Отправляется" },
"webhooks.test": { en: "Send a test", ru: "Проверить" },
"webhooks.testFailed": {
en: "Not delivered: {error}",
ru: "Не доставлено: {error}",
},
"webhooks.testing": { en: "Sending…", ru: "Отправляю…" },
"webhooks.testOk": {
en: "Delivered, response {code}",
ru: "Доставлено, ответ {code}",
},
"webhooks.url": { en: "Address", ru: "Адрес" },
} satisfies Messages;
+120
View File
@@ -0,0 +1,120 @@
import {
type AlertItem,
type LiveAlertEvent,
unseenAlerts,
} from "#lib/api/watches.js";
import { alertLine, alertTitle } from "#lib/format/watches.js";
import { events } from "#lib/stores/events.svelte.js";
import { browser } from "$app/env";
import { goto } from "$app/navigation";
const NOTIFY_KEY = "bg.alerts.notify";
export const ALERTS_PARAM = "alerts";
type Listener = (alert: AlertItem) => void;
function notifySupported(): boolean {
return browser && "Notification" in window;
}
function createAlerts() {
const listeners = new Set<Listener>();
let unseen = $state(0);
let notify = $state(
notifySupported() &&
localStorage.getItem(NOTIFY_KEY) === "1" &&
Notification.permission === "granted"
);
let account: number | null = null;
async function refresh() {
if (account === null) {
return;
}
const current = account;
const count = await unseenAlerts().catch(() => null);
if (count !== null && current === account) {
unseen = count;
}
}
function popUp(alert: AlertItem) {
if (!(notify && document.hidden)) {
return;
}
const popup = new Notification(alertTitle(alert), {
body: alertLine(alert),
tag: `bg-alert-${alert.id}`,
});
popup.onclick = () => {
window.focus();
goto(alert.path);
popup.close();
};
}
function apply(event: LiveAlertEvent) {
({ unseen } = event);
if (event.alert === null) {
return;
}
for (const listener of listeners) {
listener(event.alert);
}
popUp(event.alert);
}
events.subscribe((event) => {
if (event.type === "alert") {
apply(event);
}
});
events.onReconnect(() => {
refresh();
});
return {
get notify(): boolean {
return notify;
},
get notifySupported(): boolean {
return notifySupported();
},
onAlert(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
read(count = 1) {
unseen = Math.max(0, unseen - count);
},
readAll() {
unseen = 0;
},
async setNotify(on: boolean): Promise<boolean> {
if (!notifySupported()) {
return false;
}
const allowed =
on &&
(Notification.permission === "granted" ||
(await Notification.requestPermission()) === "granted");
notify = allowed;
localStorage.setItem(NOTIFY_KEY, allowed ? "1" : "0");
return allowed === on;
},
track(accountId: number | null) {
if (accountId === account) {
return;
}
account = accountId;
unseen = 0;
refresh();
},
get unseen(): number {
return unseen;
},
};
}
export const alerts = createAlerts();
+1
View File
@@ -13,6 +13,7 @@ export type RightPanel =
| "stories-all" | "stories-all"
| "policy" | "policy"
| "watches" | "watches"
| "webhooks"
| "alerts" | "alerts"
| "shares" | "shares"
| "bots" | "bots"