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:
hh committed 2026-10-19 05:25:54 +00:00
1 parent 7f7fc1296e
commit 848d2aca1a
19 files changed
+813 -46

No files matched your search

+32
View File
@@ -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(",") },
});
}
+31
View File
@@ -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 });
}
+3
View File
@@ -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;
+104
View File
@@ -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() {
+14
View File
@@ -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");
}
+48
View File
@@ -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 "";
+6
View File
@@ -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,
+24
View File
@@ -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;
+116
View File
@@ -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;
+26 -3
View File
@@ -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;
+14
View File
@@ -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;
+17
View File
@@ -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 } });
}
}
+59
View File
@@ -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();
+59
View File
@@ -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();
+94
View File
@@ -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();
+95 -25
View File
@@ -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;
},