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
|
import type { PanelState } from "#lib/stores/ui.svelte.js";
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
interface PageState {
|
||||||
|
inApp?: boolean;
|
||||||
|
panel?: PanelState;
|
||||||
|
}
|
||||||
// interface Platform {}
|
// 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;
|
is_contact: boolean;
|
||||||
kind: ChatKind;
|
kind: ChatKind;
|
||||||
last_date: string | null;
|
last_date: string | null;
|
||||||
|
last_deleted: boolean;
|
||||||
|
last_message_id: number | null;
|
||||||
last_sender_id: number | null;
|
last_sender_id: number | null;
|
||||||
last_text: string | null;
|
last_text: string | null;
|
||||||
message_count: number;
|
message_count: number;
|
||||||
@@ -196,6 +198,7 @@ export interface MessageView {
|
|||||||
entities: EntityView[];
|
entities: EntityView[];
|
||||||
forward: ForwardView | null;
|
forward: ForwardView | null;
|
||||||
has_media: boolean;
|
has_media: boolean;
|
||||||
|
has_note: boolean;
|
||||||
inline_buttons: InlineButton[][];
|
inline_buttons: InlineButton[][];
|
||||||
is_animated_emoji: boolean;
|
is_animated_emoji: boolean;
|
||||||
is_self_destruct: 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,
|
EVENT_ICONS,
|
||||||
} from "#lib/format/watches.js";
|
} from "#lib/format/watches.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { replaceParam } from "#lib/params.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -48,15 +49,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function writeParam(value: Filter | null) {
|
function writeParam(value: Filter | null) {
|
||||||
const url = new URL(page.url.href);
|
replaceParam(ALERTS_PARAM, value);
|
||||||
if (value === null) {
|
|
||||||
url.searchParams.delete(ALERTS_PARAM);
|
|
||||||
} else {
|
|
||||||
url.searchParams.set(ALERTS_PARAM, value);
|
|
||||||
}
|
|
||||||
if (url.href !== page.url.href) {
|
|
||||||
goto(url, { replace: true, reset: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(next: Filter) {
|
function setFilter(next: Filter) {
|
||||||
@@ -135,10 +128,10 @@
|
|||||||
|
|
||||||
async function openAlert(alert: AlertItem) {
|
async function openAlert(alert: AlertItem) {
|
||||||
seen(alert);
|
seen(alert);
|
||||||
await goto(alert.path);
|
|
||||||
if (isMobile()) {
|
if (isMobile()) {
|
||||||
ui.closePanel();
|
ui.resetPanel();
|
||||||
}
|
}
|
||||||
|
await goto(alert.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seeAll() {
|
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 = {
|
const OPTIONS = {
|
||||||
date: { day: "numeric", month: "short", year: "numeric" },
|
date: { day: "numeric", month: "short", year: "numeric" },
|
||||||
day: { day: "numeric", month: "short" },
|
day: { day: "numeric", month: "short" },
|
||||||
|
exact: {
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
second: "2-digit",
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
},
|
||||||
full: {
|
full: {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -12,6 +21,14 @@ const OPTIONS = {
|
|||||||
},
|
},
|
||||||
longDay: { day: "numeric", month: "long", weekday: "long" },
|
longDay: { day: "numeric", month: "long", weekday: "long" },
|
||||||
offset: { timeZoneName: "shortOffset" },
|
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" },
|
time: { hour: "2-digit", minute: "2-digit" },
|
||||||
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
||||||
|
|
||||||
@@ -47,6 +64,37 @@ export function formatFull(iso: string): string {
|
|||||||
return format("full", new Date(iso));
|
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 {
|
export function formatListDate(iso: string | null): string {
|
||||||
if (!iso) {
|
if (!iso) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { account } from "#lib/i18n/messages/account.js";
|
import { account } from "#lib/i18n/messages/account.js";
|
||||||
import { alerts } from "#lib/i18n/messages/alerts.js";
|
import { alerts } from "#lib/i18n/messages/alerts.js";
|
||||||
import { bots } from "#lib/i18n/messages/bots.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 { common } from "#lib/i18n/messages/common.js";
|
||||||
import { feed } from "#lib/i18n/messages/feed.js";
|
import { feed } from "#lib/i18n/messages/feed.js";
|
||||||
import { jobs } from "#lib/i18n/messages/jobs.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 { menu } from "#lib/i18n/messages/menu.js";
|
||||||
import { notes } from "#lib/i18n/messages/notes.js";
|
import { notes } from "#lib/i18n/messages/notes.js";
|
||||||
import { policy } from "#lib/i18n/messages/policy.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 { profile } from "#lib/i18n/messages/profile.js";
|
||||||
import { read } from "#lib/i18n/messages/read.js";
|
import { read } from "#lib/i18n/messages/read.js";
|
||||||
import { search } from "#lib/i18n/messages/search.js";
|
import { search } from "#lib/i18n/messages/search.js";
|
||||||
@@ -25,6 +28,8 @@ export const messages = {
|
|||||||
...account,
|
...account,
|
||||||
...alerts,
|
...alerts,
|
||||||
...bots,
|
...bots,
|
||||||
|
...capture,
|
||||||
|
...chat,
|
||||||
...common,
|
...common,
|
||||||
...feed,
|
...feed,
|
||||||
...jobs,
|
...jobs,
|
||||||
@@ -33,6 +38,7 @@ export const messages = {
|
|||||||
...menu,
|
...menu,
|
||||||
...notes,
|
...notes,
|
||||||
...policy,
|
...policy,
|
||||||
|
...panels,
|
||||||
...profile,
|
...profile,
|
||||||
...read,
|
...read,
|
||||||
...search,
|
...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 = {
|
export const notes = {
|
||||||
"notes.add": { en: "Add note", ru: "Добавить заметку" },
|
"notes.add": { en: "Add note", ru: "Добавить заметку" },
|
||||||
|
"notes.all": { en: "All", ru: "Все" },
|
||||||
"notes.edit": { en: "Edit", ru: "Изменить" },
|
"notes.edit": { en: "Edit", ru: "Изменить" },
|
||||||
"notes.placeholder": {
|
"notes.empty": {
|
||||||
en: "Note on the message…",
|
en: "Right-click a message and pick Notes to write one",
|
||||||
ru: "Заметка к сообщению…",
|
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: "Заметки" },
|
"notes.title": { en: "Notes", ru: "Заметки" },
|
||||||
} satisfies Messages;
|
} 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 =
|
export type RightPanel =
|
||||||
| "changes"
|
| "changes"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "search"
|
| "search"
|
||||||
| "versions"
|
|
||||||
| "reactions"
|
| "reactions"
|
||||||
| "readers"
|
| "readers"
|
||||||
| "callbacks"
|
| "callbacks"
|
||||||
| "links"
|
| "links"
|
||||||
| "annotations"
|
| "annotations"
|
||||||
|
| "notes"
|
||||||
| "jobs"
|
| "jobs"
|
||||||
| "presence"
|
| "presence"
|
||||||
| "stories"
|
| "stories"
|
||||||
@@ -24,32 +27,82 @@ export type RightPanel =
|
|||||||
|
|
||||||
export type LeftView = "main" | "settings";
|
export type LeftView = "main" | "settings";
|
||||||
|
|
||||||
|
export interface PanelState {
|
||||||
|
chatId: number | null;
|
||||||
|
messageId: number | null;
|
||||||
|
name: RightPanel;
|
||||||
|
peerId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface JumpTarget {
|
interface JumpTarget {
|
||||||
chatId: number;
|
chatId: number;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersonTarget {
|
function currentChat(): number | null {
|
||||||
chatId: number;
|
return page.params.chatId ? Number(page.params.chatId) : null;
|
||||||
peerId: number;
|
}
|
||||||
|
|
||||||
|
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() {
|
function createUi() {
|
||||||
let rightPanel = $state<RightPanel | null>(null);
|
let panel = $state.raw<PanelState | null>(null);
|
||||||
let panelMessageId = $state<number | null>(null);
|
|
||||||
let leftColumnOpen = $state(true);
|
let leftColumnOpen = $state(true);
|
||||||
let leftView = $state<LeftView>("main");
|
let leftView = $state<LeftView>("main");
|
||||||
let jumpTarget = $state<JumpTarget | null>(null);
|
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 {
|
return {
|
||||||
clearJump() {
|
clearJump() {
|
||||||
jumpTarget = null;
|
jumpTarget = null;
|
||||||
},
|
},
|
||||||
closePanel() {
|
closePanel() {
|
||||||
rightPanel = null;
|
if (panel === null) {
|
||||||
panelMessageId = null;
|
return;
|
||||||
person = null;
|
}
|
||||||
|
panel = null;
|
||||||
|
if (page.state.panel !== undefined) {
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
closeSettings() {
|
closeSettings() {
|
||||||
leftView = "main";
|
leftView = "main";
|
||||||
@@ -63,43 +116,60 @@ function createUi() {
|
|||||||
get leftView() {
|
get leftView() {
|
||||||
return leftView;
|
return leftView;
|
||||||
},
|
},
|
||||||
openMessagePanel(panel: RightPanel, messageId: number) {
|
markInApp,
|
||||||
rightPanel = panel;
|
openMessagePanel(name: RightPanel, messageId: number) {
|
||||||
panelMessageId = messageId;
|
show({ chatId: currentChat(), messageId, name, peerId: null });
|
||||||
},
|
},
|
||||||
openPanel(panel: RightPanel) {
|
openPanel(name: RightPanel) {
|
||||||
rightPanel = panel;
|
show({ chatId: null, messageId: null, name, peerId: null });
|
||||||
panelMessageId = null;
|
|
||||||
person = null;
|
|
||||||
},
|
},
|
||||||
openPerson(chatId: number, peerId: number) {
|
openPerson(chatId: number, peerId: number) {
|
||||||
rightPanel = "profile";
|
show({ chatId, messageId: null, name: "profile", peerId });
|
||||||
panelMessageId = null;
|
|
||||||
person = { chatId, peerId };
|
|
||||||
},
|
},
|
||||||
openSettings() {
|
openSettings() {
|
||||||
leftView = "settings";
|
leftView = "settings";
|
||||||
},
|
},
|
||||||
|
get panel() {
|
||||||
|
return panel;
|
||||||
|
},
|
||||||
get panelMessageId() {
|
get panelMessageId() {
|
||||||
return panelMessageId;
|
return panel?.messageId ?? null;
|
||||||
},
|
},
|
||||||
personIn(chatId: number | null): number | 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) {
|
requestJump(chatId: number, messageId: number) {
|
||||||
jumpTarget = { chatId, messageId };
|
jumpTarget = { chatId, messageId };
|
||||||
},
|
},
|
||||||
|
resetPanel() {
|
||||||
|
panel = null;
|
||||||
|
},
|
||||||
|
restore(next: PanelState | null) {
|
||||||
|
if (!samePanel(panel, next)) {
|
||||||
|
panel = next;
|
||||||
|
}
|
||||||
|
},
|
||||||
get rightPanel() {
|
get rightPanel() {
|
||||||
return rightPanel;
|
return panel?.name ?? null;
|
||||||
},
|
},
|
||||||
setChat(chatId: number | null) {
|
setChat(chatId: number | null) {
|
||||||
if (person !== null && person.chatId !== chatId) {
|
if (panel === null || panel.chatId === null || panel.chatId === chatId) {
|
||||||
person = null;
|
return;
|
||||||
}
|
}
|
||||||
|
panel =
|
||||||
|
panel.messageId === null
|
||||||
|
? { ...panel, chatId: null, peerId: null }
|
||||||
|
: null;
|
||||||
},
|
},
|
||||||
setLeftColumn(open: boolean) {
|
setLeftColumn(open: boolean) {
|
||||||
leftColumnOpen = open;
|
leftColumnOpen = open;
|
||||||
},
|
},
|
||||||
|
settled(): Promise<unknown> {
|
||||||
|
return writing;
|
||||||
|
},
|
||||||
toggleLeftColumn() {
|
toggleLeftColumn() {
|
||||||
leftColumnOpen = !leftColumnOpen;
|
leftColumnOpen = !leftColumnOpen;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import ChatList from "#lib/components/ChatList.svelte";
|
import ChatList from "#lib/components/ChatList.svelte";
|
||||||
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
||||||
import FeedView from "#lib/components/feed/FeedView.svelte";
|
import FeedView from "#lib/components/feed/FeedView.svelte";
|
||||||
|
import Hotkeys from "#lib/components/Hotkeys.svelte";
|
||||||
import MainMenu from "#lib/components/MainMenu.svelte";
|
import MainMenu from "#lib/components/MainMenu.svelte";
|
||||||
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
||||||
import RightColumn from "#lib/components/RightColumn.svelte";
|
import RightColumn from "#lib/components/RightColumn.svelte";
|
||||||
@@ -18,12 +19,15 @@
|
|||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
|
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
|
||||||
import { auth } from "#lib/stores/auth.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 { events } from "#lib/stores/events.svelte.js";
|
||||||
import { feedFilterOf } from "#lib/stores/feed.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 { search } from "#lib/stores/search.svelte.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.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";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@@ -42,6 +46,56 @@
|
|||||||
events.open(auth.token === null ? null : accounts.selectedId);
|
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(() => {
|
$effect(() => {
|
||||||
const chatId = page.params.chatId ? Number(page.params.chatId) : null;
|
const chatId = page.params.chatId ? Number(page.params.chatId) : null;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
@@ -59,14 +113,17 @@
|
|||||||
}
|
}
|
||||||
shownAccount = id;
|
shownAccount = id;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
ui.closePanel();
|
ui.resetPanel();
|
||||||
search.reset();
|
search.reset();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const tag = page.url.searchParams.get(NOTES_PARAM);
|
||||||
if (page.url.searchParams.has(ALERTS_PARAM)) {
|
if (page.url.searchParams.has(ALERTS_PARAM)) {
|
||||||
ui.openPanel("alerts");
|
ui.openPanel("alerts");
|
||||||
|
} else if (tag !== null) {
|
||||||
|
notes.openTag(tag || null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -136,6 +193,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Hotkeys />
|
||||||
|
|
||||||
<PolicyDialog />
|
<PolicyDialog />
|
||||||
|
|
||||||
<ShareDialog
|
<ShareDialog
|
||||||
|
|||||||
Reference in new issue
Block a user