feat(frontend): watches, webhooks and alerts panels read humanly, with chat picker, url filter, test send and delivery log
This commit is contained in:
1 parent
6a4fd6ad47
commit
ea3a7c0a61
19 files changed
+3696
-455
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import { request } from "#lib/api/client.js";
|
||||
import type {
|
||||
Account,
|
||||
Alert,
|
||||
Annotation,
|
||||
AvatarHistoryView,
|
||||
CallbackView,
|
||||
@@ -45,7 +44,6 @@ import type {
|
||||
TrashItem,
|
||||
TrashOutcome,
|
||||
VolumeBucket,
|
||||
Watch,
|
||||
WatchedBot,
|
||||
} from "#lib/api/types.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(
|
||||
options: { chatId?: number; messageId?: number } = {}
|
||||
): Promise<Annotation[]> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LiveAlertEvent } from "#lib/api/watches.js";
|
||||
|
||||
export type ChatKind = "private" | "group";
|
||||
export type PolicyScopeType =
|
||||
| "default_dm"
|
||||
@@ -543,26 +545,6 @@ export interface Folder {
|
||||
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 {
|
||||
account_id: number;
|
||||
attempts: number;
|
||||
@@ -607,7 +589,8 @@ export type LiveEvent =
|
||||
| LiveMessageEvent
|
||||
| LiveDeleteEvent
|
||||
| LivePresenceEvent
|
||||
| LiveReceiptEvent;
|
||||
| LiveReceiptEvent
|
||||
| LiveAlertEvent;
|
||||
|
||||
export interface FileShare {
|
||||
account_id: number;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.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 { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
@@ -27,7 +28,7 @@
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const titles: Record<RightPanel, string> = $derived({
|
||||
alerts: "Алерты",
|
||||
alerts: t("settings.alerts"),
|
||||
annotations: "Заметки",
|
||||
bots: "Боты",
|
||||
callbacks: "Callback-данные",
|
||||
@@ -46,12 +47,14 @@
|
||||
stories: "Сторис",
|
||||
"stories-all": "Все сторис",
|
||||
versions: "Версии",
|
||||
watches: "Отслеживания",
|
||||
watches: t("settings.watches"),
|
||||
webhooks: t("watches.webhooks"),
|
||||
});
|
||||
|
||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||
"storage-chat": "storage",
|
||||
"storage-trash": "storage",
|
||||
webhooks: "watches",
|
||||
};
|
||||
|
||||
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
||||
@@ -118,7 +121,13 @@
|
||||
{:else if ui.rightPanel === "stories-all"}
|
||||
<AllStoriesArchive />
|
||||
{:else if ui.rightPanel === "watches"}
|
||||
{#key accounts.selectedId}
|
||||
<WatchesPanel />
|
||||
{/key}
|
||||
{:else if ui.rightPanel === "webhooks"}
|
||||
{#key accounts.selectedId}
|
||||
<WebhooksPanel />
|
||||
{/key}
|
||||
{:else if ui.rightPanel === "alerts"}
|
||||
<AlertsPanel />
|
||||
{: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">
|
||||
import { listAlerts, markAlertSeen } from "#lib/api/endpoints.js";
|
||||
import type { Alert } from "#lib/api/types.js";
|
||||
import { onMount, untrack } from "svelte";
|
||||
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 Spinner from "../ui/Spinner.svelte";
|
||||
import AlertList from "./AlertList.svelte";
|
||||
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
||||
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 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>(
|
||||
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 {
|
||||
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 {
|
||||
if (mine === token) {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(accounts.selectedId, unseenOnly);
|
||||
if (accounts.selectedId !== null) {
|
||||
load(filter);
|
||||
}
|
||||
});
|
||||
|
||||
async function seen(alert: Alert) {
|
||||
await markAlertSeen(alert.id);
|
||||
if (unseenOnly) {
|
||||
alerts = alerts.filter((a) => a.id !== alert.id);
|
||||
} else {
|
||||
alerts = alerts.map((a) =>
|
||||
a.id === alert.id ? { ...a, seen: true } : a
|
||||
);
|
||||
onMount(() => {
|
||||
writeParam(filter);
|
||||
const stop = alerts.onAlert((alert) => {
|
||||
if (!items.some((item) => item.id === alert.id)) {
|
||||
items = [alert, ...items];
|
||||
}
|
||||
});
|
||||
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>
|
||||
|
||||
<div class="panel-content">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={unseenOnly}>
|
||||
<span>Только непрочитанные</span>
|
||||
</label>
|
||||
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
{:else if alerts.length === 0}
|
||||
<p class="empty">Алертов нет.</p>
|
||||
{:else}
|
||||
<AlertList {alerts} onseen={seen} />
|
||||
{/if}
|
||||
<div class="tabs">
|
||||
<button
|
||||
aria-pressed={filter === "all"}
|
||||
class="tab"
|
||||
onclick={() => setFilter("all")}
|
||||
type="button"
|
||||
class:on={filter === "all"}
|
||||
>
|
||||
{t("alerts.all")}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={filter === "unseen"}
|
||||
class="tab"
|
||||
onclick={() => setFilter("unseen")}
|
||||
type="button"
|
||||
class:on={filter === "unseen"}
|
||||
>
|
||||
{t("alerts.unseen")}
|
||||
</button>
|
||||
</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">
|
||||
.panel-content {
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: var(--color-text);
|
||||
|
||||
:global(.icon) {
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
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);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
createWatch,
|
||||
deleteWatch,
|
||||
listWatches,
|
||||
listWebhooks,
|
||||
updateWatch,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type { Watch } from "#lib/api/types.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import Spinner from "../ui/Spinner.svelte";
|
||||
import WatchEditor from "./WatchEditor.svelte";
|
||||
import WatchList from "./WatchList.svelte";
|
||||
type Watch,
|
||||
} from "#lib/api/watches.js";
|
||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||
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 { 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 hookCount = $state(0);
|
||||
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) {
|
||||
loading = true;
|
||||
async function load() {
|
||||
token += 1;
|
||||
const current = token;
|
||||
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 {
|
||||
if (current === token) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(accounts.selectedId);
|
||||
});
|
||||
|
||||
async function add(data: {
|
||||
kind: string;
|
||||
params: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
saving = true;
|
||||
try {
|
||||
const created = await createWatch(data.kind, data.params, data.enabled);
|
||||
watches = [created, ...watches];
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function stats(watch: Watch): string {
|
||||
if (watch.alert_count === 0 || watch.last_alert_at === null) {
|
||||
return t("watches.never");
|
||||
}
|
||||
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) {
|
||||
const updated = await updateWatch(watch.id, watch.params, !watch.enabled);
|
||||
watches = watches.map((w) => (w.id === updated.id ? updated : w));
|
||||
problem = null;
|
||||
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) {
|
||||
problem = null;
|
||||
try {
|
||||
await deleteWatch(watch.id);
|
||||
watches = watches.filter((w) => w.id !== 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>
|
||||
|
||||
<div class="panel-content">
|
||||
<WatchEditor onsave={add} {saving} />
|
||||
{#snippet lead(
|
||||
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}
|
||||
<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}
|
||||
<p class="empty">Пока нет отслеживаний.</p>
|
||||
<div class="empty">
|
||||
<EmptyState
|
||||
description={t("watches.empty.description")}
|
||||
title={t("watches.empty.title")}
|
||||
/>
|
||||
</div>
|
||||
{: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}
|
||||
|
||||
<div class="section">
|
||||
<SettingsItem
|
||||
icon="web"
|
||||
label={t("watches.webhooks")}
|
||||
onclick={() => ui.openPanel("webhooks")}
|
||||
value={hookCount > 0 ? String(hookCount) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
padding: 2rem 1rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-secondary);
|
||||
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;
|
||||
|
||||
&: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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
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 { menu } from "#lib/i18n/messages/menu.js";
|
||||
import { read } from "#lib/i18n/messages/read.js";
|
||||
import { settings } from "#lib/i18n/messages/settings.js";
|
||||
import { watches } from "#lib/i18n/messages/watches.js";
|
||||
|
||||
export const messages = {
|
||||
...account,
|
||||
...alerts,
|
||||
...common,
|
||||
...menu,
|
||||
...read,
|
||||
...settings,
|
||||
...watches,
|
||||
};
|
||||
|
||||
export type MessageKey = keyof typeof messages;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -13,6 +13,7 @@ export type RightPanel =
|
||||
| "stories-all"
|
||||
| "policy"
|
||||
| "watches"
|
||||
| "webhooks"
|
||||
| "alerts"
|
||||
| "shares"
|
||||
| "bots"
|
||||
|
||||
Reference in new issue
Block a user