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
This commit is contained in:
1 parent
7f7fc1296e
commit
848d2aca1a
19 files changed
+813
-46
No files matched your search
Vendored
+6
-5
@@ -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 {};
|
||||
@@ -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<CaptureStatus> {
|
||||
return request<CaptureStatus>("/capture/status", {
|
||||
query: { account_id: accountId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getCaptureGaps(accountId: number): Promise<CaptureGap[]> {
|
||||
return request<CaptureGap[]>("/capture/gaps", {
|
||||
query: { account_id: accountId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getOnline(peerIds: number[]): Promise<PresenceSample[]> {
|
||||
return request<PresenceSample[]>("/presence/online", {
|
||||
account: true,
|
||||
query: { peer_ids: peerIds.join(",") },
|
||||
});
|
||||
}
|
||||
@@ -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<Note[]> {
|
||||
return request<Note[]>("/annotations/notes", {
|
||||
account: true,
|
||||
query: { limit, offset, tag },
|
||||
});
|
||||
}
|
||||
|
||||
export function listNoteTags(): Promise<NoteTag[]> {
|
||||
return request<NoteTag[]>("/annotations/tags", { account: true });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { search } from "#lib/stores/search.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const EDITABLE = "input, textarea, select, [contenteditable='true']";
|
||||
const LAYERS = "[role='dialog'], [role='alertdialog'], [role='menu']";
|
||||
|
||||
const chatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
async function focusIn(selector: string) {
|
||||
await tick();
|
||||
document.querySelector<HTMLInputElement>(selector)?.focus();
|
||||
}
|
||||
|
||||
async function focusSearch() {
|
||||
if (ui.leftView === "settings") {
|
||||
ui.closeSettings();
|
||||
}
|
||||
await focusIn("#LeftColumn .left-header input");
|
||||
}
|
||||
|
||||
function stepChat(step: number) {
|
||||
const { list } = chats;
|
||||
const index = list.findIndex((chat) => chat.chat_id === chatId);
|
||||
if (index === -1 && step < 0) {
|
||||
return;
|
||||
}
|
||||
const target = index === -1 ? list[0] : list[index + step];
|
||||
if (target) {
|
||||
goto(`/app/${target.chat_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTop(editable: HTMLElement | null) {
|
||||
if (editable) {
|
||||
if (editable.closest("#LeftColumn .left-header")) {
|
||||
search.close();
|
||||
}
|
||||
editable.blur();
|
||||
} else if (ui.rightPanel !== null) {
|
||||
ui.closePanel();
|
||||
} else if (ui.leftView === "settings") {
|
||||
ui.closeSettings();
|
||||
} else if (chatId !== null) {
|
||||
goto("/app");
|
||||
}
|
||||
}
|
||||
|
||||
function onCommand(event: KeyboardEvent): boolean {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "k") {
|
||||
focusSearch();
|
||||
return true;
|
||||
}
|
||||
if (key === "f" && chatId !== null) {
|
||||
ui.openPanel("search");
|
||||
focusIn("#RightColumn input");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPlain(event: KeyboardEvent): boolean {
|
||||
if (event.key === "/" && !event.altKey) {
|
||||
focusSearch();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
event.altKey &&
|
||||
(event.key === "ArrowUp" || event.key === "ArrowDown")
|
||||
) {
|
||||
stepChat(event.key === "ArrowUp" ? -1 : 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || document.querySelector(LAYERS)) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
const editable = target?.closest<HTMLElement>(EDITABLE) ?? null;
|
||||
const modified = event.ctrlKey || event.metaKey;
|
||||
if (event.key === "Escape") {
|
||||
closeTop(editable);
|
||||
return;
|
||||
}
|
||||
const handled = modified
|
||||
? !event.altKey && onCommand(event)
|
||||
: editable === null && onPlain(event);
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<string, Intl.DateTimeFormatOptions>;
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
export async function replaceParam(
|
||||
key: string,
|
||||
value: string | null
|
||||
): Promise<void> {
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
@@ -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<CaptureStatus | null>(null);
|
||||
let gaps = $state.raw<CaptureGap[]>([]);
|
||||
let timer: ReturnType<typeof setInterval> | 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();
|
||||
@@ -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<string | null>(null);
|
||||
const marks = new SvelteMap<string, boolean>();
|
||||
|
||||
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();
|
||||
@@ -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<number, number>();
|
||||
const asked = new Set<number>();
|
||||
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();
|
||||
@@ -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<RightPanel | null>(null);
|
||||
let panelMessageId = $state<number | null>(null);
|
||||
let panel = $state.raw<PanelState | null>(null);
|
||||
let leftColumnOpen = $state(true);
|
||||
let leftView = $state<LeftView>("main");
|
||||
let jumpTarget = $state<JumpTarget | null>(null);
|
||||
let person = $state<PersonTarget | null>(null);
|
||||
let writing: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function queue(step: () => Promise<void> | 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<unknown> {
|
||||
return writing;
|
||||
},
|
||||
toggleLeftColumn() {
|
||||
leftColumnOpen = !leftColumnOpen;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -136,6 +193,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Hotkeys />
|
||||
|
||||
<PolicyDialog />
|
||||
|
||||
<ShareDialog
|
||||
|
||||
Reference in new issue
Block a user