From 848d2aca1aa5de550a65d1505685d055d91354b4 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 03:49:53 +0000 Subject: [PATCH] feat(frontend): right panel lives in browser history and back closes it first, message panels close on chat switch, hotkeys for search, chats, in-chat search and closing --- frontend/src/app.d.ts | 11 +- frontend/src/lib/api/capture.ts | 32 +++++ frontend/src/lib/api/notes.ts | 31 +++++ frontend/src/lib/api/types.ts | 3 + frontend/src/lib/components/Hotkeys.svelte | 104 +++++++++++++++ .../lib/components/alerts/AlertsPanel.svelte | 15 +-- frontend/src/lib/format/capture.ts | 14 ++ frontend/src/lib/format/datetime.ts | 48 +++++++ frontend/src/lib/i18n/messages.ts | 6 + frontend/src/lib/i18n/messages/capture.ts | 24 ++++ frontend/src/lib/i18n/messages/chat.ts | 116 +++++++++++++++++ frontend/src/lib/i18n/messages/notes.ts | 29 ++++- frontend/src/lib/i18n/messages/panels.ts | 14 ++ frontend/src/lib/params.ts | 17 +++ frontend/src/lib/stores/capture.svelte.ts | 59 +++++++++ frontend/src/lib/stores/notes.svelte.ts | 59 +++++++++ frontend/src/lib/stores/online.svelte.ts | 94 ++++++++++++++ frontend/src/lib/stores/ui.svelte.ts | 120 ++++++++++++++---- frontend/src/routes/app/+layout.svelte | 63 ++++++++- 19 files changed, 813 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/api/capture.ts create mode 100644 frontend/src/lib/api/notes.ts create mode 100644 frontend/src/lib/components/Hotkeys.svelte create mode 100644 frontend/src/lib/format/capture.ts create mode 100644 frontend/src/lib/i18n/messages/capture.ts create mode 100644 frontend/src/lib/i18n/messages/chat.ts create mode 100644 frontend/src/lib/i18n/messages/panels.ts create mode 100644 frontend/src/lib/params.ts create mode 100644 frontend/src/lib/stores/capture.svelte.ts create mode 100644 frontend/src/lib/stores/notes.svelte.ts create mode 100644 frontend/src/lib/stores/online.svelte.ts diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 520c421..5f6c2ca 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,13 +1,14 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces +import type { PanelState } from "#lib/stores/ui.svelte.js"; + declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} - // interface PageState {} + interface PageState { + inApp?: boolean; + panel?: PanelState; + } // interface Platform {} } } - -export {}; diff --git a/frontend/src/lib/api/capture.ts b/frontend/src/lib/api/capture.ts new file mode 100644 index 0000000..9600a9c --- /dev/null +++ b/frontend/src/lib/api/capture.ts @@ -0,0 +1,32 @@ +import { request } from "#lib/api/client.js"; +import type { PresenceSample } from "#lib/api/types.js"; + +export interface CaptureStatus { + last_seen: string | null; + live: boolean; + since: string | null; +} + +export interface CaptureGap { + end: string; + start: string; +} + +export function getCaptureStatus(accountId: number): Promise { + return request("/capture/status", { + query: { account_id: accountId }, + }); +} + +export function getCaptureGaps(accountId: number): Promise { + return request("/capture/gaps", { + query: { account_id: accountId }, + }); +} + +export function getOnline(peerIds: number[]): Promise { + return request("/presence/online", { + account: true, + query: { peer_ids: peerIds.join(",") }, + }); +} diff --git a/frontend/src/lib/api/notes.ts b/frontend/src/lib/api/notes.ts new file mode 100644 index 0000000..1ad27c6 --- /dev/null +++ b/frontend/src/lib/api/notes.ts @@ -0,0 +1,31 @@ +import { request } from "#lib/api/client.js"; +import type { Annotation } from "#lib/api/types.js"; + +export interface Note extends Annotation { + chat_has_avatar: boolean; + chat_title: string | null; + message_date: string | null; + message_deleted: boolean; + message_has_media: boolean; + message_text: string | null; +} + +export interface NoteTag { + count: number; + tag: string; +} + +export function listNotes( + tag: string | null, + offset: number, + limit: number +): Promise { + return request("/annotations/notes", { + account: true, + query: { limit, offset, tag }, + }); +} + +export function listNoteTags(): Promise { + return request("/annotations/tags", { account: true }); +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 5939f72..05a89b0 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -45,6 +45,8 @@ export interface Chat { is_contact: boolean; kind: ChatKind; last_date: string | null; + last_deleted: boolean; + last_message_id: number | null; last_sender_id: number | null; last_text: string | null; message_count: number; @@ -196,6 +198,7 @@ export interface MessageView { entities: EntityView[]; forward: ForwardView | null; has_media: boolean; + has_note: boolean; inline_buttons: InlineButton[][]; is_animated_emoji: boolean; is_self_destruct: boolean; diff --git a/frontend/src/lib/components/Hotkeys.svelte b/frontend/src/lib/components/Hotkeys.svelte new file mode 100644 index 0000000..04299dc --- /dev/null +++ b/frontend/src/lib/components/Hotkeys.svelte @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/lib/components/alerts/AlertsPanel.svelte b/frontend/src/lib/components/alerts/AlertsPanel.svelte index c743c10..89a4f84 100644 --- a/frontend/src/lib/components/alerts/AlertsPanel.svelte +++ b/frontend/src/lib/components/alerts/AlertsPanel.svelte @@ -22,6 +22,7 @@ EVENT_ICONS, } from "#lib/format/watches.js"; import { t } from "#lib/i18n/locale.svelte.js"; + import { replaceParam } from "#lib/params.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js"; @@ -48,15 +49,7 @@ ); 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 }); - } + replaceParam(ALERTS_PARAM, value); } function setFilter(next: Filter) { @@ -135,10 +128,10 @@ async function openAlert(alert: AlertItem) { seen(alert); - await goto(alert.path); if (isMobile()) { - ui.closePanel(); + ui.resetPanel(); } + await goto(alert.path); } async function seeAll() { diff --git a/frontend/src/lib/format/capture.ts b/frontend/src/lib/format/capture.ts new file mode 100644 index 0000000..bdb17f7 --- /dev/null +++ b/frontend/src/lib/format/capture.ts @@ -0,0 +1,14 @@ +import type { CaptureStatus } from "#lib/api/capture.js"; +import { formatSince } from "#lib/format/datetime.js"; +import { t } from "#lib/i18n/locale.svelte.js"; + +export function captureLine(status: CaptureStatus, bot: boolean): string { + if (status.live && status.since) { + return t("capture.live", { when: formatSince(status.since) }); + } + if (status.last_seen) { + const when = formatSince(status.last_seen); + return bot ? t("capture.botQuiet", { when }) : t("capture.quiet", { when }); + } + return bot ? t("capture.botNever") : t("capture.never"); +} diff --git a/frontend/src/lib/format/datetime.ts b/frontend/src/lib/format/datetime.ts index 8dc2353..03707e3 100644 --- a/frontend/src/lib/format/datetime.ts +++ b/frontend/src/lib/format/datetime.ts @@ -3,6 +3,15 @@ import { locale } from "#lib/i18n/locale.svelte.js"; const OPTIONS = { date: { day: "numeric", month: "short", year: "numeric" }, day: { day: "numeric", month: "short" }, + exact: { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "long", + second: "2-digit", + weekday: "long", + year: "numeric", + }, full: { day: "numeric", hour: "2-digit", @@ -12,6 +21,14 @@ const OPTIONS = { }, longDay: { day: "numeric", month: "long", weekday: "long" }, offset: { timeZoneName: "shortOffset" }, + stamp: { day: "numeric", hour: "2-digit", minute: "2-digit", month: "short" }, + stampYear: { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short", + year: "numeric", + }, time: { hour: "2-digit", minute: "2-digit" }, } satisfies Record; @@ -47,6 +64,37 @@ export function formatFull(iso: string): string { return format("full", new Date(iso)); } +export function formatExact(iso: string): string { + return format("exact", new Date(iso)); +} + +function stampKind(date: Date): "stamp" | "stampYear" { + return date.getFullYear() === new Date().getFullYear() + ? "stamp" + : "stampYear"; +} + +export function formatStamp(iso: string): string { + const date = new Date(iso); + return format(stampKind(date), date); +} + +export function formatSince(iso: string): string { + const date = new Date(iso); + if (date.toDateString() === new Date().toDateString()) { + return format("time", date); + } + return format(stampKind(date), date); +} + +export function formatSpanRange(startIso: string, endIso: string): string { + const start = new Date(startIso); + const end = new Date(endIso); + const kind = + start.toDateString() === end.toDateString() ? "time" : stampKind(start); + return formatter(kind).formatRange(start, end); +} + export function formatListDate(iso: string | null): string { if (!iso) { return ""; diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index ff1c666..43044df 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -1,6 +1,8 @@ import { account } from "#lib/i18n/messages/account.js"; import { alerts } from "#lib/i18n/messages/alerts.js"; import { bots } from "#lib/i18n/messages/bots.js"; +import { capture } from "#lib/i18n/messages/capture.js"; +import { chat } from "#lib/i18n/messages/chat.js"; import { common } from "#lib/i18n/messages/common.js"; import { feed } from "#lib/i18n/messages/feed.js"; import { jobs } from "#lib/i18n/messages/jobs.js"; @@ -9,6 +11,7 @@ import { media } from "#lib/i18n/messages/media.js"; import { menu } from "#lib/i18n/messages/menu.js"; import { notes } from "#lib/i18n/messages/notes.js"; import { policy } from "#lib/i18n/messages/policy.js"; +import { panels } from "#lib/i18n/messages/panels.js"; import { profile } from "#lib/i18n/messages/profile.js"; import { read } from "#lib/i18n/messages/read.js"; import { search } from "#lib/i18n/messages/search.js"; @@ -25,6 +28,8 @@ export const messages = { ...account, ...alerts, ...bots, + ...capture, + ...chat, ...common, ...feed, ...jobs, @@ -33,6 +38,7 @@ export const messages = { ...menu, ...notes, ...policy, + ...panels, ...profile, ...read, ...search, diff --git a/frontend/src/lib/i18n/messages/capture.ts b/frontend/src/lib/i18n/messages/capture.ts new file mode 100644 index 0000000..dc181bd --- /dev/null +++ b/frontend/src/lib/i18n/messages/capture.ts @@ -0,0 +1,24 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const capture = { + "capture.botNever": { + en: "The bot has not connected yet", + ru: "Бот ещё не выходил на связь", + }, + "capture.botQuiet": { + en: "The bot is silent since {when}", + ru: "Бот молчит с {when}", + }, + "capture.live": { + en: "Capturing since {when}", + ru: "Захват идёт с {when}", + }, + "capture.never": { + en: "The userbot has not connected yet", + ru: "Юзербот ещё не выходил на связь", + }, + "capture.quiet": { + en: "The userbot is silent since {when}", + ru: "Юзербот молчит с {when}", + }, +} satisfies Messages; diff --git a/frontend/src/lib/i18n/messages/chat.ts b/frontend/src/lib/i18n/messages/chat.ts new file mode 100644 index 0000000..fca505f --- /dev/null +++ b/frontend/src/lib/i18n/messages/chat.ts @@ -0,0 +1,116 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const chat = { + "chat.analytics": { en: "Analytics", ru: "Аналитика" }, + "chat.back": { en: "Back to chats", ru: "Назад к чатам" }, + "chat.backfill": { + en: "Fetch new messages", + ru: "Догрузить новые сообщения", + }, + "chat.backfillDone": { + en: "New messages are on the way", + ru: "Новые сообщения уже догружаются", + }, + "chat.backfillFailed": { + en: "Could not start fetching", + ru: "Не удалось запустить догрузку", + }, + "chat.calendar": { en: "Jump to date", ru: "Перейти к дате" }, + "chat.calendar.count": { + en: { one: "{count} message", other: "{count} messages" }, + ru: { + few: "{count} сообщения", + many: "{count} сообщений", + one: "{count} сообщение", + other: "{count} сообщения", + }, + }, + "chat.calendar.nextMonth": { en: "Next month", ru: "Следующий месяц" }, + "chat.calendar.nextYear": { en: "Next year", ru: "Следующий год" }, + "chat.calendar.prevMonth": { en: "Previous month", ru: "Предыдущий месяц" }, + "chat.calendar.prevYear": { en: "Previous year", ru: "Предыдущий год" }, + "chat.channel": { en: "channel", ru: "канал" }, + "chat.deletedAccount": { en: "Deleted account", ru: "Удалённый аккаунт" }, + "chat.deletedAt": { en: "deleted {when}", ru: "удалено {when}" }, + "chat.deletedPreview": { en: "Deleted", ru: "Удалено" }, + "chat.edited": { en: "edited", ru: "изменено" }, + "chat.editedAt": { en: "Edited {when}", ru: "Изменено {when}" }, + "chat.gap": { en: "archive was off {range}", ru: "архив не писал {range}" }, + "chat.group": { en: "group", ru: "группа" }, + "chat.latest": { en: "To the latest messages", ru: "К последним сообщениям" }, + "chat.links": { + en: { one: "{count} link", other: "{count} links" }, + ru: { + few: "{count} ссылки", + many: "{count} ссылок", + one: "{count} ссылка", + other: "{count} ссылки", + }, + }, + "chat.loadFailed": { + en: "Could not load messages", + ru: "Не удалось загрузить сообщения", + }, + "chat.media": { en: "Media", ru: "Медиа" }, + "chat.media.download": { en: "Download {kind}", ru: "Скачать: {kind}" }, + "chat.media.downloadFailed": { + en: "Could not download the file", + ru: "Не удалось скачать файл", + }, + "chat.media.file": { en: "File", ru: "Файл" }, + "chat.media.fullscreen": { + en: "Open full screen", + ru: "Открыть на весь экран", + }, + "chat.media.photo": { en: "Photo", ru: "Фото" }, + "chat.media.queue": { en: "Download to archive", ru: "Скачать в архив" }, + "chat.media.queued": { en: "Queued", ru: "В очереди" }, + "chat.media.queueFailed": { + en: "Could not queue the download", + ru: "Не удалось поставить в очередь", + }, + "chat.media.save": { en: "Download file", ru: "Скачать файл" }, + "chat.media.selfDestruct": { + en: "Self-destructing media", + ru: "Самоуничтожающееся медиа", + }, + "chat.media.share": { en: "Share by link", ru: "Доступ по ссылке" }, + "chat.media.tapToDownload": { + en: "Tap to download", + ru: "Нажмите, чтобы скачать", + }, + "chat.media.versions": { en: "Media versions", ru: "Версии медиа" }, + "chat.media.video": { en: "Video", ru: "Видео" }, + "chat.menu.author": { en: "Author's profile", ru: "Профиль автора" }, + "chat.menu.callbacks": { en: "Callback data", ru: "Callback-данные" }, + "chat.menu.copied": { en: "Copied", ru: "Скопировано" }, + "chat.menu.copyId": { en: "Copy ID", ru: "Копировать ID" }, + "chat.menu.copyLink": { en: "Copy link", ru: "Копировать ссылку" }, + "chat.menu.copyText": { en: "Copy text", ru: "Копировать текст" }, + "chat.menu.linkCopied": { en: "Link copied", ru: "Ссылка скопирована" }, + "chat.menu.links": { en: "Links", ru: "Ссылки" }, + "chat.menu.notes": { en: "Notes", ru: "Заметки" }, + "chat.menu.open": { en: "Open", ru: "Открыть" }, + "chat.menu.policy": { en: "Capture policy", ru: "Политика захвата" }, + "chat.menu.profile": { en: "Profile", ru: "Профиль" }, + "chat.menu.reactions": { en: "Reactions", ru: "Реакции" }, + "chat.menu.reply": { en: "Go to reply", ru: "Перейти к ответу" }, + "chat.menu.search": { en: "Search in chat", ru: "Поиск в чате" }, + "chat.menu.stories": { en: "Stories", ru: "Сторис" }, + "chat.menu.versions": { en: "Versions", ru: "Версии" }, + "chat.messages": { + en: { one: "{count} message", other: "{count} messages" }, + ru: { + few: "{count} сообщения", + many: "{count} сообщений", + one: "{count} сообщение", + other: "{count} сообщения", + }, + }, + "chat.noText": { en: "No text", ru: "Без текста" }, + "chat.note": { en: "Note", ru: "Заметка" }, + "chat.openProfile": { en: "Open profile", ru: "Открыть профиль" }, + "chat.title": { en: "Chat {id}", ru: "Чат {id}" }, + "chat.you": { en: "You", ru: "Вы" }, + "chat.youPrefix": { en: "You: ", ru: "Вы: " }, +} satisfies Messages; diff --git a/frontend/src/lib/i18n/messages/notes.ts b/frontend/src/lib/i18n/messages/notes.ts index 93eba68..ce928e2 100644 --- a/frontend/src/lib/i18n/messages/notes.ts +++ b/frontend/src/lib/i18n/messages/notes.ts @@ -2,10 +2,33 @@ import type { Messages } from "#lib/i18n/types.js"; export const notes = { "notes.add": { en: "Add note", ru: "Добавить заметку" }, + "notes.all": { en: "All", ru: "Все" }, "notes.edit": { en: "Edit", ru: "Изменить" }, - "notes.placeholder": { - en: "Note on the message…", - ru: "Заметка к сообщению…", + "notes.empty": { + en: "Right-click a message and pick Notes to write one", + ru: "Нажмите на сообщение правой кнопкой и выберите «Заметки»", }, + "notes.emptyTag": { + en: "No notes with #{tag}", + ru: "Нет заметок с #{tag}", + }, + "notes.emptyTitle": { en: "No notes yet", ru: "Заметок пока нет" }, + "notes.failed": { + en: "Could not load notes", + ru: "Не удалось загрузить заметки", + }, + "notes.mediaMessage": { en: "Media", ru: "Медиа" }, + "notes.messageGone": { + en: "Message is not in the archive", + ru: "Сообщения нет в архиве", + }, + "notes.more": { en: "Show more", ru: "Показать ещё" }, + "notes.noMessage": { + en: "No message selected", + ru: "Сообщение не выбрано", + }, + "notes.placeholder": { en: "Note to the message", ru: "Заметка к сообщению" }, + "notes.retry": { en: "Retry", ru: "Повторить" }, + "notes.tag": { en: "Notes with #{tag}", ru: "Заметки с #{tag}" }, "notes.title": { en: "Notes", ru: "Заметки" }, } satisfies Messages; diff --git a/frontend/src/lib/i18n/messages/panels.ts b/frontend/src/lib/i18n/messages/panels.ts new file mode 100644 index 0000000..237352e --- /dev/null +++ b/frontend/src/lib/i18n/messages/panels.ts @@ -0,0 +1,14 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const panels = { + "panels.callbacks": { en: "Callback data", ru: "Callback-данные" }, + "panels.links": { en: "Links", ru: "Ссылки" }, + "panels.presence": { en: "Analytics", ru: "Аналитика" }, + "panels.profile": { en: "Profile", ru: "Профиль" }, + "panels.reactions": { en: "Reactions", ru: "Реакции" }, + "panels.search": { en: "Search", ru: "Поиск" }, + "panels.storageChat": { en: "Chat files", ru: "Файлы чата" }, + "panels.stories": { en: "Stories", ru: "Сторис" }, + "panels.storiesAll": { en: "All stories", ru: "Все сторис" }, + "panels.trash": { en: "Trash", ru: "Корзина" }, +} satisfies Messages; diff --git a/frontend/src/lib/params.ts b/frontend/src/lib/params.ts new file mode 100644 index 0000000..213d1c4 --- /dev/null +++ b/frontend/src/lib/params.ts @@ -0,0 +1,17 @@ +import { goto } from "$app/navigation"; +import { page } from "$app/state"; + +export async function replaceParam( + key: string, + value: string | null +): Promise { + const url = new URL(page.url.href); + if (value === null) { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + if (url.href !== page.url.href) { + await goto(url, { replace: true, reset: false, state: { ...page.state } }); + } +} diff --git a/frontend/src/lib/stores/capture.svelte.ts b/frontend/src/lib/stores/capture.svelte.ts new file mode 100644 index 0000000..dd47ad3 --- /dev/null +++ b/frontend/src/lib/stores/capture.svelte.ts @@ -0,0 +1,59 @@ +import { + type CaptureGap, + type CaptureStatus, + getCaptureGaps, + getCaptureStatus, +} from "#lib/api/capture.js"; + +const POLL_INTERVAL = 60_000; + +function createCapture() { + let account: number | null = null; + let status = $state(null); + let gaps = $state.raw([]); + let timer: ReturnType | undefined; + + async function refresh() { + const current = account; + if (current === null) { + return; + } + const next = await getCaptureStatus(current).catch(() => null); + if (next === null || current !== account) { + return; + } + const restarted = next.since !== status?.since; + status = next; + if (restarted) { + const found = await getCaptureGaps(current).catch(() => null); + if (found !== null && current === account) { + gaps = found; + } + } + } + + return { + get gaps(): CaptureGap[] { + return gaps; + }, + get status(): CaptureStatus | null { + return status; + }, + track(accountId: number | null) { + if (accountId === account) { + return; + } + account = accountId; + status = null; + gaps = []; + clearInterval(timer); + if (accountId === null) { + return; + } + refresh(); + timer = setInterval(refresh, POLL_INTERVAL); + }, + }; +} + +export const capture = createCapture(); diff --git a/frontend/src/lib/stores/notes.svelte.ts b/frontend/src/lib/stores/notes.svelte.ts new file mode 100644 index 0000000..5ef9550 --- /dev/null +++ b/frontend/src/lib/stores/notes.svelte.ts @@ -0,0 +1,59 @@ +import { SvelteMap } from "svelte/reactivity"; +import type { MessageView } from "#lib/api/types.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; +import { ui } from "#lib/stores/ui.svelte.js"; + +export const NOTES_PARAM = "notes"; + +const TAG = /(^|[^\p{L}\p{N}_])#([\p{L}\p{N}_]+)/gu; + +export type NotePart = { label: string; tag: string } | { text: string }; + +export function noteParts(text: string): NotePart[] { + const parts: NotePart[] = []; + let last = 0; + for (const match of text.matchAll(TAG)) { + const start = match.index + match[1].length; + if (start > last) { + parts.push({ text: text.slice(last, start) }); + } + parts.push({ label: `#${match[2]}`, tag: match[2].toLowerCase() }); + last = start + match[2].length + 1; + } + if (last < text.length) { + parts.push({ text: text.slice(last) }); + } + return parts; +} + +function createNotes() { + let tag = $state(null); + const marks = new SvelteMap(); + + function key(chatId: number, messageId: number): string { + return `${accounts.selectedId}:${chatId}:${messageId}`; + } + + return { + has(message: MessageView): boolean { + return ( + marks.get(key(message.chat_id, message.message_id)) ?? message.has_note + ); + }, + mark(chatId: number, messageId: number, noted: boolean) { + marks.set(key(chatId, messageId), noted); + }, + openTag(next: string | null) { + tag = next; + ui.openPanel("notes"); + }, + get tag(): string | null { + return tag; + }, + set tag(next: string | null) { + tag = next; + }, + }; +} + +export const notes = createNotes(); diff --git a/frontend/src/lib/stores/online.svelte.ts b/frontend/src/lib/stores/online.svelte.ts new file mode 100644 index 0000000..9c8d690 --- /dev/null +++ b/frontend/src/lib/stores/online.svelte.ts @@ -0,0 +1,94 @@ +import { SvelteMap } from "svelte/reactivity"; +import { getOnline } from "#lib/api/capture.js"; +import type { PresenceSample } from "#lib/api/types.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; +import { events } from "#lib/stores/events.svelte.js"; + +const CLOCK_INTERVAL = 30_000; +const BATCH = 200; + +function createOnline() { + const until = new SvelteMap(); + const asked = new Set(); + let pending: number[] = []; + let account: number | null = null; + let now = $state(Date.now()); + let ticking = false; + + function apply(peerId: number, sample: PresenceSample | null) { + const ends = sample?.next_offline_date; + if (sample?.status === "online" && ends) { + until.set(peerId, new Date(ends).getTime()); + now = Date.now(); + } else { + until.delete(peerId); + } + } + + async function ask(ids: number[]) { + const current = account; + for (let start = 0; start < ids.length; start += BATCH) { + // biome-ignore lint/performance/noAwaitInLoops: batches go one after another + const samples = await getOnline(ids.slice(start, start + BATCH)).catch( + () => [] + ); + if (current !== account) { + return; + } + for (const sample of samples) { + apply(sample.peer_id, sample); + } + } + } + + function flush() { + const ids = pending; + pending = []; + ask(ids); + } + + function syncAccount() { + if (accounts.selectedId === account) { + return; + } + account = accounts.selectedId; + until.clear(); + asked.clear(); + pending = []; + } + + events.subscribe((event) => { + if (event.type === "presence") { + apply(event.peer_id, event.sample); + } + }); + events.onReconnect(() => { + until.clear(); + ask([...asked]); + }); + + return { + isOnline(peerId: number): boolean { + return (until.get(peerId) ?? 0) > now; + }, + watch(peerId: number) { + syncAccount(); + if (account === null || asked.has(peerId)) { + return; + } + asked.add(peerId); + if (pending.length === 0) { + queueMicrotask(flush); + } + pending.push(peerId); + if (!ticking) { + ticking = true; + setInterval(() => { + now = Date.now(); + }, CLOCK_INTERVAL); + } + }, + }; +} + +export const online = createOnline(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index 21d8cf4..05c4183 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -1,13 +1,16 @@ +import { pushState, replaceState } from "$app/navigation"; +import { page } from "$app/state"; + export type RightPanel = | "changes" | "profile" | "search" - | "versions" | "reactions" | "readers" | "callbacks" | "links" | "annotations" + | "notes" | "jobs" | "presence" | "stories" @@ -24,32 +27,82 @@ export type RightPanel = export type LeftView = "main" | "settings"; +export interface PanelState { + chatId: number | null; + messageId: number | null; + name: RightPanel; + peerId: number | null; +} + interface JumpTarget { chatId: number; messageId: number; } -interface PersonTarget { - chatId: number; - peerId: number; +function currentChat(): number | null { + return page.params.chatId ? Number(page.params.chatId) : null; +} + +function samePanel(a: PanelState | null, b: PanelState | null): boolean { + return ( + a?.name === b?.name && + a?.messageId === b?.messageId && + a?.peerId === b?.peerId && + a?.chatId === b?.chatId + ); } function createUi() { - let rightPanel = $state(null); - let panelMessageId = $state(null); + let panel = $state.raw(null); let leftColumnOpen = $state(true); let leftView = $state("main"); let jumpTarget = $state(null); - let person = $state(null); + let writing: Promise = Promise.resolve(); + + function queue(step: () => Promise | undefined) { + writing = writing.then(step).catch(() => undefined); + } + + function record(stay = false) { + queue(() => { + const next = panel; + if (next === null || samePanel(page.state.panel ?? null, next)) { + return; + } + const fresh = page.state.panel === undefined && !stay; + return (fresh ? pushState : replaceState)("", { + ...page.state, + panel: next, + }); + }); + } + + function markInApp() { + queue(() => { + if (page.state.inApp) { + return; + } + return replaceState("", { ...page.state, inApp: true }); + }); + } + + function show(next: PanelState) { + panel = next; + record(); + } return { clearJump() { jumpTarget = null; }, closePanel() { - rightPanel = null; - panelMessageId = null; - person = null; + if (panel === null) { + return; + } + panel = null; + if (page.state.panel !== undefined) { + history.back(); + } }, closeSettings() { leftView = "main"; @@ -63,43 +116,60 @@ function createUi() { get leftView() { return leftView; }, - openMessagePanel(panel: RightPanel, messageId: number) { - rightPanel = panel; - panelMessageId = messageId; + markInApp, + openMessagePanel(name: RightPanel, messageId: number) { + show({ chatId: currentChat(), messageId, name, peerId: null }); }, - openPanel(panel: RightPanel) { - rightPanel = panel; - panelMessageId = null; - person = null; + openPanel(name: RightPanel) { + show({ chatId: null, messageId: null, name, peerId: null }); }, openPerson(chatId: number, peerId: number) { - rightPanel = "profile"; - panelMessageId = null; - person = { chatId, peerId }; + show({ chatId, messageId: null, name: "profile", peerId }); }, openSettings() { leftView = "settings"; }, + get panel() { + return panel; + }, get panelMessageId() { - return panelMessageId; + return panel?.messageId ?? null; }, personIn(chatId: number | null): number | null { - return person !== null && person.chatId === chatId ? person.peerId : null; + return panel !== null && panel.peerId !== null && panel.chatId === chatId + ? panel.peerId + : null; }, + record, requestJump(chatId: number, messageId: number) { jumpTarget = { chatId, messageId }; }, + resetPanel() { + panel = null; + }, + restore(next: PanelState | null) { + if (!samePanel(panel, next)) { + panel = next; + } + }, get rightPanel() { - return rightPanel; + return panel?.name ?? null; }, setChat(chatId: number | null) { - if (person !== null && person.chatId !== chatId) { - person = null; + if (panel === null || panel.chatId === null || panel.chatId === chatId) { + return; } + panel = + panel.messageId === null + ? { ...panel, chatId: null, peerId: null } + : null; }, setLeftColumn(open: boolean) { leftColumnOpen = open; }, + settled(): Promise { + return writing; + }, toggleLeftColumn() { leftColumnOpen = !leftColumnOpen; }, diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte index 1136082..c205058 100644 --- a/frontend/src/routes/app/+layout.svelte +++ b/frontend/src/routes/app/+layout.svelte @@ -5,6 +5,7 @@ import ChatList from "#lib/components/ChatList.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte"; import FeedView from "#lib/components/feed/FeedView.svelte"; + import Hotkeys from "#lib/components/Hotkeys.svelte"; import MainMenu from "#lib/components/MainMenu.svelte"; import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte"; import RightColumn from "#lib/components/RightColumn.svelte"; @@ -18,12 +19,15 @@ import { accounts } from "#lib/stores/accounts.svelte.js"; import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js"; + import { capture } from "#lib/stores/capture.svelte.js"; import { events } from "#lib/stores/events.svelte.js"; import { feedFilterOf } from "#lib/stores/feed.svelte.js"; + import { NOTES_PARAM, notes } from "#lib/stores/notes.svelte.js"; import { search } from "#lib/stores/search.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js"; - import { ui } from "#lib/stores/ui.svelte.js"; + import { type PanelState, ui } from "#lib/stores/ui.svelte.js"; + import { afterNavigate, beforeNavigate } from "$app/navigation"; import { page } from "$app/state"; let { children } = $props(); @@ -42,6 +46,56 @@ events.open(auth.token === null ? null : accounts.selectedId); }); + $effect(() => { + capture.track(auth.token === null ? null : accounts.selectedId); + }); + + let carrying = false; + let seenPanel: PanelState | null = null; + + $effect(() => { + const next = page.state.panel ?? null; + untrack(() => { + const changed = JSON.stringify(next) !== JSON.stringify(seenPanel); + seenPanel = next; + if (changed && !carrying) { + ui.restore(next); + } + }); + }); + + beforeNavigate((navigation) => { + if (navigation.shallow || navigation.type === "popstate") { + return; + } + carrying = ui.rightPanel !== null; + navigation.complete.catch(() => { + carrying = false; + }); + }); + + afterNavigate((navigation) => { + if ( + navigation.shallow || + navigation.type === "popstate" || + navigation.type === "enter" + ) { + return; + } + ui.setChat(page.params.chatId ? Number(page.params.chatId) : null); + const from = navigation.from?.url?.pathname; + const moved = Boolean(from) && from !== navigation.to?.url?.pathname; + if (moved) { + ui.markInApp(); + } + if (carrying) { + ui.record(!moved); + } + ui.settled().then(() => { + carrying = false; + }); + }); + $effect(() => { const chatId = page.params.chatId ? Number(page.params.chatId) : null; untrack(() => { @@ -59,14 +113,17 @@ } shownAccount = id; untrack(() => { - ui.closePanel(); + ui.resetPanel(); search.reset(); }); }); onMount(() => { + const tag = page.url.searchParams.get(NOTES_PARAM); if (page.url.searchParams.has(ALERTS_PARAM)) { ui.openPanel("alerts"); + } else if (tag !== null) { + notes.openTag(tag || null); } }); @@ -136,6 +193,8 @@ {/if} + +