diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 12ce3e1..9a1d9ee 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -9,6 +9,11 @@ import type { ChatLinkView, DayCount, DiscoverItem, + FeedChatKind, + FeedKind, + FeedMessagesPage, + FeedPage, + FeedUnseen, Folder, JobStatus, JobView, @@ -274,6 +279,39 @@ export function listDeleted( }); } +export interface FeedQuery { + chat_id?: number; + chat_kind?: FeedChatKind; + cursor?: string; + kind?: FeedKind; + limit?: number; +} + +export function listFeed(query: FeedQuery = {}): Promise { + return request("/feed", { account: true, query: { ...query } }); +} + +export function getFeedUnseen(): Promise { + return request("/feed/unseen", { account: true }); +} + +export function markFeedSeen(at?: string): Promise { + return request("/feed/seen", { + body: { account_id: accounts.selectedId, at }, + method: "POST", + }); +} + +export function listFeedMessages( + itemId: number, + query: { cursor?: string; limit?: number } = {} +): Promise { + return request(`/feed/${itemId}/messages`, { + account: true, + query: { ...query }, + }); +} + export function getPeer(peerId: number): Promise { return request(`/peers/${peerId}`, { account: true }); } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 69aa3e1..16af511 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -222,6 +222,52 @@ export interface MessageVersion { text: string | null; } +export type FeedKind = "all" | "deleted" | "edited"; +export type FeedChatKind = "dm" | "group" | "channel"; + +export interface FeedChat { + chat_id: number; + has_avatar: boolean; + kind: FeedChatKind; + title: string | null; +} + +export interface FeedItem { + after: string | null; + at: string; + before: string | null; + chat: FeedChat; + edit_date: string | null; + has_before: boolean; + id: number; + kind: Exclude; + message: MessageView | null; + message_count: number; + message_id: number; + outgoing: boolean; + sender_id: number | null; + sender_name: string | null; + since: string; +} + +export interface FeedPage { + items: FeedItem[]; + next_cursor: string | null; +} + +export interface FeedUnseen { + count: number; + deleted: number; + edited: number; + latest: FeedItem | null; + seen_at: string | null; +} + +export interface FeedMessagesPage { + messages: MessageView[]; + next_cursor: string | null; +} + export interface MediaVersion { file_size: number | null; id: number; diff --git a/frontend/src/lib/components/ChatHeader.svelte b/frontend/src/lib/components/ChatHeader.svelte index 512345e..98b0665 100644 --- a/frontend/src/lib/components/ChatHeader.svelte +++ b/frontend/src/lib/components/ChatHeader.svelte @@ -8,14 +8,17 @@ import Icon from "#lib/components/ui/Icon.svelte"; import { peerName } from "#lib/format/peer.js"; import { formatPresence, isOnline } from "#lib/format/presence.js"; + import { t } from "#lib/i18n/locale.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js"; + import { withoutMessage } from "#lib/stores/feed.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js"; import { goto } from "$app/navigation"; + import { page } from "$app/state"; interface Props { chatId: number; @@ -36,14 +39,20 @@ backfilling = true; try { await enqueueBackfill(chatId, true); - toasts.success("Догружаем новые сообщения"); + toasts.success(t("chat.backfill.started")); } catch { - toasts.error("Не удалось запустить бэкфилл"); + toasts.error(t("chat.backfill.failed")); } finally { backfilling = false; } } + function back() { + const target = new URL(page.url.href); + target.pathname = "/app"; + goto(withoutMessage(target)); + } + $effect(() => { if (accounts.selectedId === null) { return; @@ -82,7 +91,9 @@ const fallbackTitle = $derived( chat?.title ?? discovered?.title ?? - (isDm ? "Удалённый аккаунт" : `Chat ${chatId}`) + (isDm + ? t("chat.deletedAccount") + : t("chat.fallbackTitle", { id: chatId })) ); const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle); const subtitle = $derived.by(() => { @@ -97,9 +108,11 @@ } const count = chat?.message_count ?? discovered?.message_count ?? 0; if (count > 0) { - return `${count} messages`; + return t("chat.messages", { count }); } - return discovered?.kind === "channel" ? "channel" : "group"; + return discovered?.kind === "channel" + ? t("chat.kind.channel") + : t("chat.kind.group"); }); const avatarKind = $derived(isDm ? "peer" : "chat"); const hasAvatar = $derived( @@ -114,8 +127,8 @@
+ {:else} + {/each} + {#if loading} +
+ {:else if failed} + + {:else if cursor !== null} + + {/if} +
+ + diff --git a/frontend/src/lib/components/feed/FeedEntry.svelte b/frontend/src/lib/components/feed/FeedEntry.svelte new file mode 100644 index 0000000..f6ad4e9 --- /dev/null +++ b/frontend/src/lib/components/feed/FeedEntry.svelte @@ -0,0 +1,363 @@ + + +
+ + {#if bulk && expanded} + {#key `${item.id}:${item.message_count}`} + + {/key} + {/if} +
+ + diff --git a/frontend/src/lib/components/feed/FeedFilters.svelte b/frontend/src/lib/components/feed/FeedFilters.svelte new file mode 100644 index 0000000..e257d2a --- /dev/null +++ b/frontend/src/lib/components/feed/FeedFilters.svelte @@ -0,0 +1,124 @@ + + +
+
+ {#each FEED_KINDS as option (option)} + + {/each} +
+ {#if onchats} +
+ {#each FEED_CHAT_KINDS as option (option)} + + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/feed/FeedList.svelte b/frontend/src/lib/components/feed/FeedList.svelte new file mode 100644 index 0000000..ed0f50e --- /dev/null +++ b/frontend/src/lib/components/feed/FeedList.svelte @@ -0,0 +1,191 @@ + + +
+ {#if items.length === 0 && loading} +
+ {:else if items.length === 0 && failed} +
+

{t("feed.error")}

+ +
+ {:else if items.length === 0} + + {:else} + {#each items as item (item.id)} + onopen(item, messageId)} + /> + {/each} + {#if cursor !== null} + {#if failed} +
+ +
+ {:else} + {#key cursor} +
+ {#if loading} + + {/if} +
+ {/key} + {/if} + {/if} + {/if} +
+ + diff --git a/frontend/src/lib/components/feed/FeedRow.svelte b/frontend/src/lib/components/feed/FeedRow.svelte new file mode 100644 index 0000000..acc8d2e --- /dev/null +++ b/frontend/src/lib/components/feed/FeedRow.svelte @@ -0,0 +1,216 @@ + + + + + diff --git a/frontend/src/lib/components/feed/FeedText.svelte b/frontend/src/lib/components/feed/FeedText.svelte new file mode 100644 index 0000000..d21dba4 --- /dev/null +++ b/frontend/src/lib/components/feed/FeedText.svelte @@ -0,0 +1,128 @@ + + + + {#if mark} + {mark} + {/if} + {#if prefix} + {prefix}: + {/if} + {#if thumb} + + {/if} + {#if lead} + {lead} + {/if} + {#if text} + {text} + {:else if !lead} + {t("feed.noText")} + {/if} + + + diff --git a/frontend/src/lib/components/feed/FeedView.svelte b/frontend/src/lib/components/feed/FeedView.svelte new file mode 100644 index 0000000..2e05590 --- /dev/null +++ b/frontend/src/lib/components/feed/FeedView.svelte @@ -0,0 +1,93 @@ + + +
+
+ +

{t("feed.title")}

+
+ feed.filter(page.url, { ...filter, chats })} + onkind={(kind) => feed.filter(page.url, { ...filter, kind })} + /> +
+ +
+
+ + diff --git a/frontend/src/lib/format/datetime.ts b/frontend/src/lib/format/datetime.ts index f3dabde..8dc2353 100644 --- a/frontend/src/lib/format/datetime.ts +++ b/frontend/src/lib/format/datetime.ts @@ -79,3 +79,8 @@ export function timeZoneLabel(): string { .find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone() ); } + +export function formatDayTime(iso: string): string { + const date = new Date(iso); + return `${format("day", date)}, ${format("time", date)}`; +} diff --git a/frontend/src/lib/format/diff.ts b/frontend/src/lib/format/diff.ts new file mode 100644 index 0000000..01321ee --- /dev/null +++ b/frontend/src/lib/format/diff.ts @@ -0,0 +1,74 @@ +export interface DiffPart { + changed: boolean; + text: string; +} + +export interface WordDiff { + after: DiffPart[]; + before: DiffPart[]; +} + +const TOKEN = /(\s+)/; +const MAX_TOKENS = 400; + +function tokens(text: string): string[] { + return text.split(TOKEN).filter(Boolean); +} + +function push(parts: DiffPart[], text: string, changed: boolean) { + const last = parts.at(-1); + if (last && last.changed === changed) { + last.text += text; + } else { + parts.push({ changed, text }); + } +} + +function lcsTable(a: string[], b: string[]): Uint16Array { + const width = b.length + 1; + const table = new Uint16Array((a.length + 1) * width); + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + table[i * width + j] = + a[i] === b[j] + ? table[(i + 1) * width + j + 1] + 1 + : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]); + } + } + return table; +} + +export function wordDiff(before: string, after: string): WordDiff | null { + const a = tokens(before); + const b = tokens(after); + if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) { + return null; + } + const table = lcsTable(a, b); + const width = b.length + 1; + const left: DiffPart[] = []; + const right: DiffPart[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + push(left, a[i], false); + push(right, b[j], false); + i += 1; + j += 1; + } else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) { + push(left, a[i], true); + i += 1; + } else { + push(right, b[j], true); + j += 1; + } + } + for (const token of a.slice(i)) { + push(left, token, true); + } + for (const token of b.slice(j)) { + push(right, token, true); + } + return { after: right, before: left }; +} diff --git a/frontend/src/lib/format/media.ts b/frontend/src/lib/format/media.ts index 94e14af..236cdcb 100644 --- a/frontend/src/lib/format/media.ts +++ b/frontend/src/lib/format/media.ts @@ -1,27 +1,30 @@ -const MEDIA_KIND_LABELS: Record = { - animation: "GIF", - audio: "Аудио", - contact: "Контакт", - dice: "Кубик", - document: "Файл", - game: "Игра", - gif: "GIF", - location: "Геопозиция", - photo: "Фото", - poll: "Опрос", - sticker: "Стикер", - story: "Сторис", - venue: "Геопозиция", - video: "Видео", - video_note: "Кружок", - voice: "Голосовое", +import { t } from "#lib/i18n/locale.svelte.js"; +import type { MessageKey } from "#lib/i18n/messages.js"; + +const MEDIA_KIND_KEYS: Record = { + animation: "media.kind.gif", + audio: "media.kind.audio", + contact: "media.kind.contact", + dice: "media.kind.dice", + document: "media.kind.document", + game: "media.kind.game", + gif: "media.kind.gif", + location: "media.kind.location", + photo: "media.kind.photo", + poll: "media.kind.poll", + sticker: "media.kind.sticker", + story: "media.kind.story", + venue: "media.kind.location", + video: "media.kind.video", + video_note: "media.kind.videoNote", + voice: "media.kind.voice", }; export function mediaKindLabel(kind: string | null): string | null { if (!kind) { return null; } - return MEDIA_KIND_LABELS[kind] ?? "Медиа"; + return t(MEDIA_KIND_KEYS[kind] ?? "media.kind.other"); } const BYTE_UNITS = ["B", "KB", "MB", "GB"]; diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index 4c66e91..8aa15bf 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -1,6 +1,7 @@ import { account } from "#lib/i18n/messages/account.js"; import { alerts } from "#lib/i18n/messages/alerts.js"; import { common } from "#lib/i18n/messages/common.js"; +import { feed } from "#lib/i18n/messages/feed.js"; import { menu } from "#lib/i18n/messages/menu.js"; import { profile } from "#lib/i18n/messages/profile.js"; import { read } from "#lib/i18n/messages/read.js"; @@ -11,6 +12,7 @@ export const messages = { ...account, ...alerts, ...common, + ...feed, ...menu, ...profile, ...read, diff --git a/frontend/src/lib/i18n/messages/feed.ts b/frontend/src/lib/i18n/messages/feed.ts new file mode 100644 index 0000000..d8ba9b1 --- /dev/null +++ b/frontend/src/lib/i18n/messages/feed.ts @@ -0,0 +1,139 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const feed = { + "chat.analytics": { en: "Analytics", ru: "Аналитика" }, + "chat.backfill": { + en: "Fetch new messages", + ru: "Догрузить новые сообщения", + }, + "chat.backfill.failed": { + en: "Couldn't start fetching new messages", + ru: "Не удалось начать догрузку", + }, + "chat.backfill.started": { + en: "Fetching new messages", + ru: "Догружаем новые сообщения", + }, + "chat.deletedAccount": { en: "Deleted account", ru: "Удалённый аккаунт" }, + "chat.fallbackTitle": { en: "Chat {id}", ru: "Чат {id}" }, + "chat.kind.channel": { en: "channel", ru: "канал" }, + "chat.kind.group": { en: "group", ru: "группа" }, + "chat.media": { en: "Media", ru: "Медиа" }, + "chat.messages": { + en: { one: "{count} message", other: "{count} messages" }, + ru: { + few: "{count} сообщения", + many: "{count} сообщений", + one: "{count} сообщение", + other: "{count} сообщения", + }, + }, + "chat.open": { en: "Open", ru: "Открыть" }, + "chat.openProfile": { en: "Open profile", ru: "Открыть профиль" }, + "chat.profile": { en: "Profile", ru: "Профиль" }, + "chat.search": { en: "Search in chat", ru: "Поиск в чате" }, + "chat.you": { en: "You", ru: "Вы" }, + "chats.empty": { en: "No chats yet", ru: "Чатов пока нет" }, + "chats.emptyFolder": { en: "Empty folder", ru: "Папка пуста" }, + "chats.emptyFolderHint": { + en: "No chats match this folder yet", + ru: "В этой папке пока нет чатов", + }, + "chats.foldersFailed": { + en: "Couldn't load folders", + ru: "Не удалось загрузить папки", + }, + "chats.loadFailed": { + en: "Couldn't load chats", + ru: "Не удалось загрузить чаты", + }, + "feed.after": { en: "Now", ru: "Стало" }, + "feed.before": { en: "Was", ru: "Было" }, + "feed.bulk": { + en: { one: "{count} message deleted", other: "{count} messages deleted" }, + ru: { + few: "Удалено {count} сообщения", + many: "Удалено {count} сообщений", + one: "Удалено {count} сообщение", + other: "Удалено {count} сообщения", + }, + }, + "feed.changes": { en: "Changes", ru: "Изменения" }, + "feed.chats.channel": { en: "Channels", ru: "Каналы" }, + "feed.chats.dm": { en: "Private", ru: "Личка" }, + "feed.chats.group": { en: "Groups", ru: "Группы" }, + "feed.deleted": { en: "deleted", ru: "удалено" }, + "feed.edited": { en: "edited", ru: "изменено" }, + "feed.empty.all": { + en: "No deleted or edited messages yet", + ru: "Удалённых и изменённых пока нет", + }, + "feed.empty.deleted": { + en: "No deleted messages yet", + ru: "Удалённых пока нет", + }, + "feed.empty.edited": { + en: "No edited messages yet", + ru: "Изменённых пока нет", + }, + "feed.error": { + en: "Couldn't load the feed", + ru: "Не удалось загрузить ленту", + }, + "feed.filter.all": { en: "All", ru: "Все" }, + "feed.filter.deleted": { en: "Deleted", ru: "Удалённые" }, + "feed.filter.edited": { en: "Edited", ru: "Изменённые" }, + "feed.lostText": { + en: "The earlier text wasn't saved", + ru: "Прежний текст не сохранился", + }, + "feed.markSeen": { en: "Mark as seen", ru: "Отметить просмотренным" }, + "feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" }, + "feed.more": { en: "Show more", ru: "Показать ещё" }, + "feed.noText": { en: "No text", ru: "Без текста" }, + "feed.retry": { en: "Retry", ru: "Повторить" }, + "feed.title": { en: "Feed", ru: "Лента" }, + "feed.unseen": { + en: { one: "{count} new", other: "{count} new" }, + ru: { + few: "{count} новых", + many: "{count} новых", + one: "{count} новое", + other: "{count} новых", + }, + }, + "feed.versions": { en: "All versions", ru: "Все версии" }, + "media.kind.audio": { en: "Audio", ru: "Аудио" }, + "media.kind.contact": { en: "Contact", ru: "Контакт" }, + "media.kind.dice": { en: "Dice", ru: "Кубик" }, + "media.kind.document": { en: "File", ru: "Файл" }, + "media.kind.game": { en: "Game", ru: "Игра" }, + "media.kind.gif": { en: "GIF", ru: "GIF" }, + "media.kind.location": { en: "Location", ru: "Геопозиция" }, + "media.kind.other": { en: "Media", ru: "Медиа" }, + "media.kind.photo": { en: "Photo", ru: "Фото" }, + "media.kind.poll": { en: "Poll", ru: "Опрос" }, + "media.kind.sticker": { en: "Sticker", ru: "Стикер" }, + "media.kind.story": { en: "Story", ru: "Сторис" }, + "media.kind.video": { en: "Video", ru: "Видео" }, + "media.kind.videoNote": { en: "Video message", ru: "Кружок" }, + "media.kind.voice": { en: "Voice message", ru: "Голосовое" }, + "panel.allStories": { en: "All stories", ru: "Все сторис" }, + "panel.annotations": { en: "Notes", ru: "Заметки" }, + "panel.callbacks": { en: "Callback data", ru: "Callback-данные" }, + "panel.chatFiles": { en: "Chat files", ru: "Файлы чата" }, + "panel.links": { en: "Links", ru: "Ссылки" }, + "panel.reactions": { en: "Reactions", ru: "Реакции" }, + "panel.search": { en: "Search", ru: "Поиск" }, + "panel.trash": { en: "Trash", ru: "Корзина" }, + "panel.versions": { en: "Versions", ru: "Версии" }, + "thread.loadFailed": { + en: "Couldn't load messages", + ru: "Не удалось загрузить сообщения", + }, + "thread.notFound": { + en: "This message isn't in the archive", + ru: "Этого сообщения нет в архиве", + }, + "thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" }, +} satisfies Messages; diff --git a/frontend/src/lib/stores/feed.svelte.ts b/frontend/src/lib/stores/feed.svelte.ts new file mode 100644 index 0000000..3dfd2fb --- /dev/null +++ b/frontend/src/lib/stores/feed.svelte.ts @@ -0,0 +1,162 @@ +import { getFeedUnseen, markFeedSeen } from "#lib/api/endpoints.js"; +import type { FeedChatKind, FeedKind, FeedUnseen } from "#lib/api/types.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; +import { events } from "#lib/stores/events.svelte.js"; +import { browser } from "$app/env"; +import { goto } from "$app/navigation"; + +const LIVE_DELAY = 600; +const KIND_PARAM = "feed"; +const CHATS_PARAM = "chats"; +const MESSAGE_PARAM = "message"; + +export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"]; +export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; + +export interface FeedFilter { + chats: FeedChatKind | null; + kind: FeedKind; +} + +interface UrlLike { + readonly href: string; +} + +export function feedFilterOf(url: UrlLike): FeedFilter | null { + const { searchParams } = new URL(url.href); + const kind = searchParams.get(KIND_PARAM); + if (!FEED_KINDS.includes(kind as FeedKind)) { + return null; + } + const chats = searchParams.get(CHATS_PARAM); + return { + chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind) + ? (chats as FeedChatKind) + : null, + kind: kind as FeedKind, + }; +} + +function withFilter(url: UrlLike, filter: FeedFilter | null): URL { + const next = new URL(url.href); + next.searchParams.delete(KIND_PARAM); + next.searchParams.delete(CHATS_PARAM); + next.searchParams.delete(MESSAGE_PARAM); + if (filter) { + next.searchParams.set(KIND_PARAM, filter.kind); + if (filter.chats) { + next.searchParams.set(CHATS_PARAM, filter.chats); + } + } + return next; +} + +function href(url: UrlLike): string { + const { pathname, search } = new URL(url.href); + return `${pathname}${search}`; +} + +export function messageHref( + url: UrlLike, + chatId: number, + messageId: number +): string { + const next = withFilter(url, feedFilterOf(url)); + next.pathname = `/app/${chatId}`; + next.searchParams.set(MESSAGE_PARAM, String(messageId)); + return href(next); +} + +export function withoutMessage(url: UrlLike): string { + const next = new URL(url.href); + next.searchParams.delete(MESSAGE_PARAM); + return href(next); +} + +function createFeed() { + let unseen = $state(null); + let revision = $state(0); + let account: number | null = null; + let openedHref: string | null = null; + let timer: ReturnType | null = null; + + async function refresh() { + const id = accounts.selectedId; + if (id === null) { + unseen = null; + return; + } + const next = await getFeedUnseen(); + if (accounts.selectedId === id) { + unseen = next; + } + } + + function schedule() { + if (timer !== null) { + return; + } + timer = setTimeout(() => { + timer = null; + revision += 1; + refresh().catch(() => undefined); + }, LIVE_DELAY); + } + + if (browser) { + events.subscribe((event) => { + if (event.type === "delete" || event.type === "edit") { + schedule(); + } + }); + events.onReconnect(schedule); + } + + return { + async close(url: UrlLike) { + if (openedHref === href(url)) { + openedHref = null; + history.back(); + return; + } + await goto(href(withFilter(url, null)), { replace: true, reset: false }); + }, + async filter(url: UrlLike, filter: FeedFilter) { + const wasOpened = openedHref === href(url); + const target = href(withFilter(url, filter)); + if (wasOpened) { + openedHref = target; + } + await goto(target, { replace: true, reset: false }); + }, + async markSeen(at?: string) { + const id = accounts.selectedId; + const next = await markFeedSeen(at); + if (accounts.selectedId === id) { + unseen = next; + } + }, + async open(url: UrlLike) { + const target = href(withFilter(url, { chats: null, kind: "all" })); + openedHref = target; + await goto(target, { reset: false }); + }, + refresh, + get revision() { + return revision; + }, + sync() { + if (accounts.selectedId === account) { + return; + } + account = accounts.selectedId; + unseen = null; + refresh().catch(() => undefined); + }, + get unseen() { + return unseen; + }, + }; +} + +export const feed = createFeed(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index 99c7074..21d8cf4 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -1,4 +1,5 @@ export type RightPanel = + | "changes" | "profile" | "search" | "versions" diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte index 16e709b..c4016bd 100644 --- a/frontend/src/routes/app/+layout.svelte +++ b/frontend/src/routes/app/+layout.svelte @@ -4,6 +4,7 @@ import { fly } from "svelte/transition"; import ChatList from "#lib/components/ChatList.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte"; + import FeedView from "#lib/components/feed/FeedView.svelte"; import MainMenu from "#lib/components/MainMenu.svelte"; import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte"; import RightColumn from "#lib/components/RightColumn.svelte"; @@ -18,6 +19,7 @@ import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js"; import { events } from "#lib/stores/events.svelte.js"; + import { feedFilterOf } from "#lib/stores/feed.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"; @@ -28,6 +30,7 @@ // biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent const chatOpen = $derived(page.params.chatId != null); + const feedFilter = $derived(feedFilterOf(page.url)); $effect(() => { if (!accounts.loaded) { @@ -81,6 +84,13 @@ > + {:else if feedFilter} +
+ +
{:else}
+ import { untrack } from "svelte"; import ChatHeader from "#lib/components/ChatHeader.svelte"; import MessageList from "#lib/components/MessageList.svelte"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js"; + import { withoutMessage } from "#lib/stores/feed.svelte.js"; + import { ui } from "#lib/stores/ui.svelte.js"; + import { goto } from "$app/navigation"; import { page } from "$app/state"; const chatId = $derived(Number(page.params.chatId)); + const linkedMessage = $derived(page.url.searchParams.get("message")); $effect(() => { if (accounts.selectedId === null) { @@ -14,6 +19,19 @@ chats.ensure(chatId); chats.enrich(chatId); }); + + $effect(() => { + if (linkedMessage === null) { + return; + } + const messageId = Number(linkedMessage); + untrack(() => { + if (Number.isSafeInteger(messageId)) { + ui.requestJump(chatId, messageId); + } + goto(withoutMessage(page.url), { replace: true, reset: false }); + }); + });