From bb38e23f2983cb205f022bc1014df9412f48c740 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 02:38:29 +0000 Subject: [PATCH] feat(frontend): my message menu shows when it was read or listened, group readers open in a right panel --- frontend/src/lib/api/endpoints.ts | 20 +++ frontend/src/lib/api/types.ts | 36 +++++ .../src/lib/components/MessageBubble.svelte | 4 + .../src/lib/components/MessageList.svelte | 6 +- .../src/lib/components/MessageMedia.svelte | 4 + .../src/lib/components/MessageMeta.svelte | 13 +- .../src/lib/components/RightColumn.svelte | 9 +- .../components/read/MessageReadItem.svelte | 104 +++++++++++++++ .../lib/components/read/ReadersPanel.svelte | 124 ++++++++++++++++++ frontend/src/lib/format/read.ts | 104 +++++++++++++++ frontend/src/lib/i18n/messages.ts | 2 + frontend/src/lib/i18n/messages/read.ts | 49 +++++++ frontend/src/lib/stores/reads.svelte.ts | 77 +++++++++++ frontend/src/lib/stores/ui.svelte.ts | 1 + 14 files changed, 543 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/components/read/MessageReadItem.svelte create mode 100644 frontend/src/lib/components/read/ReadersPanel.svelte create mode 100644 frontend/src/lib/format/read.ts create mode 100644 frontend/src/lib/i18n/messages/read.ts create mode 100644 frontend/src/lib/stores/reads.svelte.ts diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index a754581..ab933f9 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -31,6 +31,7 @@ import type { PresenceHourly, PresenceSample, ReactionView, + ReadInfo, ResponseStats, ScopeTarget, SearchHit, @@ -304,6 +305,25 @@ export function getChatLinks( }); } +export function getReadInfo( + chatId: number, + messageId: number +): Promise { + return request(`/messages/${chatId}/${messageId}/read`, { + account: true, + }); +} + +export function refreshReadInfo( + chatId: number, + messageId: number +): Promise<{ job_id: number | null }> { + return request<{ job_id: number | null }>( + `/messages/${chatId}/${messageId}/read/refresh`, + { body: { account_id: accounts.selectedId }, method: "POST" } + ); +} + export function getMessageReactions( chatId: number, messageId: number diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 5048b53..85948a3 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -204,6 +204,7 @@ export interface MessageView { quote: string | null; reactions: ReactionCount[]; read: boolean; + read_at: string | null; reply: ReplyView | null; sender_id: number | null; service: ServiceView | null; @@ -369,6 +370,40 @@ export interface PeerView { username: string | null; } +export interface ReaderView { + peer: PeerView | null; + peer_id: number; + read_at: string; +} + +export type ReadOutcome = + | "ok" + | "not_read_yet" + | "user_privacy" + | "your_privacy" + | "chat_too_big" + | "too_old" + | "unavailable" + | "flood" + | "error"; + +export interface ReadInfo { + can_fetch: boolean; + chat_id: number; + chat_kind: "dm" | "group" | "channel"; + exact_at: string | null; + fetched_at: string | null; + listened_at: string | null; + message_id: number; + observed_at: string | null; + outcome: ReadOutcome | null; + own: boolean; + read: boolean; + readers: ReaderView[]; + unread: boolean; + voice: boolean; +} + export interface PeerHistoryView { first_name: string | null; is_deleted_account: boolean; @@ -518,6 +553,7 @@ export interface LivePresenceEvent { export interface LiveReceiptEvent { chat_id: number; + read_at: string; read_up_to: number; type: "receipt"; } diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte index aa77c8d..fcbed63 100644 --- a/frontend/src/lib/components/MessageBubble.svelte +++ b/frontend/src/lib/components/MessageBubble.svelte @@ -11,6 +11,7 @@ import Poll from "#lib/components/Poll.svelte"; import Reactions from "#lib/components/Reactions.svelte"; import ReplyHeader from "#lib/components/ReplyHeader.svelte"; + import MessageReadItem from "#lib/components/read/MessageReadItem.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; @@ -247,6 +248,9 @@ {/snippet} {#snippet menu()} + {#if own} + + {/if} Заметки diff --git a/frontend/src/lib/components/MessageList.svelte b/frontend/src/lib/components/MessageList.svelte index a36c11d..72c0608 100644 --- a/frontend/src/lib/components/MessageList.svelte +++ b/frontend/src/lib/components/MessageList.svelte @@ -363,13 +363,13 @@ ); } - function applyReceipt(readUpTo: number) { + function applyReceipt(readUpTo: number, readAt: string) { if (ownId === null) { return; } messages = messages.map((m) => m.sender_id === ownId && m.message_id <= readUpTo && !m.read - ? { ...m, read: true } + ? { ...m, read: true, read_at: readAt } : m ); } @@ -388,7 +388,7 @@ ) { applyDelete(event.message_ids); } else if (event.type === "receipt" && event.chat_id === chatId) { - applyReceipt(event.read_up_to); + applyReceipt(event.read_up_to, event.read_at); } } diff --git a/frontend/src/lib/components/MessageMedia.svelte b/frontend/src/lib/components/MessageMedia.svelte index f5a16d7..d18adaf 100644 --- a/frontend/src/lib/components/MessageMedia.svelte +++ b/frontend/src/lib/components/MessageMedia.svelte @@ -14,6 +14,7 @@ import TgsSticker from "#lib/components/media/TgsSticker.svelte"; import VideoNote from "#lib/components/media/VideoNote.svelte"; import VoiceMessage from "#lib/components/media/VoiceMessage.svelte"; + import MessageReadItem from "#lib/components/read/MessageReadItem.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import Icon from "#lib/components/ui/Icon.svelte"; @@ -222,6 +223,9 @@ {/snippet} {#snippet menu()} + {#if own} + + {/if} Открыть на весь экран diff --git a/frontend/src/lib/components/MessageMeta.svelte b/frontend/src/lib/components/MessageMeta.svelte index 2e9e839..4815cc4 100644 --- a/frontend/src/lib/components/MessageMeta.svelte +++ b/frontend/src/lib/components/MessageMeta.svelte @@ -2,6 +2,7 @@ import type { MessageView } from "#lib/api/types.js"; import Icon from "#lib/components/ui/Icon.svelte"; import { formatTime } from "#lib/format/datetime.js"; + import { readTitle } from "#lib/format/read.js"; interface Props { message: MessageView; @@ -25,11 +26,9 @@ {/if} {formatTime(message.date)} {#if own} - + + + {/if} @@ -52,6 +51,10 @@ white-space: nowrap; } + .ticks { + display: inline-flex; + } + .edited { cursor: pointer; padding: 0; diff --git a/frontend/src/lib/components/RightColumn.svelte b/frontend/src/lib/components/RightColumn.svelte index 32aa54c..cb6b1d1 100644 --- a/frontend/src/lib/components/RightColumn.svelte +++ b/frontend/src/lib/components/RightColumn.svelte @@ -8,6 +8,7 @@ import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte"; import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte"; import ProfilePanel from "#lib/components/profile/ProfilePanel.svelte"; + import ReadersPanel from "#lib/components/read/ReadersPanel.svelte"; import ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte"; import SharesPanel from "#lib/components/shares/SharesPanel.svelte"; import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte"; @@ -18,13 +19,14 @@ import Button from "#lib/components/ui/Button.svelte"; import Icon from "#lib/components/ui/Icon.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; + 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 { policyUi } from "#lib/stores/policy.svelte.js"; import { storageUi } from "#lib/stores/storage.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; - const titles: Record = { + const titles: Record = $derived({ alerts: "Алерты", annotations: "Заметки", bots: "Боты", @@ -35,6 +37,7 @@ presence: "Аналитика", profile: "Профиль", reactions: "Реакции", + readers: t("read.readers.title"), search: "Поиск", shares: "Файлы по ссылке", storage: "Хранилище", @@ -44,7 +47,7 @@ "stories-all": "Все сторис", versions: "Версии", watches: "Отслеживания", - }; + }); const parents: Partial> = { "storage-chat": "storage", @@ -104,6 +107,8 @@ {:else if ui.rightPanel === "reactions"} + {:else if ui.rightPanel === "readers"} + {:else if ui.rightPanel === "callbacks"} {:else if ui.rightPanel === "links"} diff --git a/frontend/src/lib/components/read/MessageReadItem.svelte b/frontend/src/lib/components/read/MessageReadItem.svelte new file mode 100644 index 0000000..047c78b --- /dev/null +++ b/frontend/src/lib/components/read/MessageReadItem.svelte @@ -0,0 +1,104 @@ + + +{#if shown?.kind === "readers"} + + + {t("read.readers", { count: shown.readers.length })} + {#if fetching} + + {/if} + + {#each shown.readers.slice(0, AVATARS) as reader (reader.peer_id)} + + {/each} + + + +{:else if shown} +
+ + {shown.text} + {#if fetching} + + {/if} +
+ +{/if} + + diff --git a/frontend/src/lib/components/read/ReadersPanel.svelte b/frontend/src/lib/components/read/ReadersPanel.svelte new file mode 100644 index 0000000..a0902ac --- /dev/null +++ b/frontend/src/lib/components/read/ReadersPanel.svelte @@ -0,0 +1,124 @@ + + +{#if entry === undefined} +
+{:else if readers.length === 0} + +{:else} +
    + {#each readers as reader (reader.peer_id)} + {@const name = peerName(reader.peer) || String(reader.peer_id)} +
  • + +
  • + {/each} +
+{/if} + + diff --git a/frontend/src/lib/format/read.ts b/frontend/src/lib/format/read.ts new file mode 100644 index 0000000..6a7ad0e --- /dev/null +++ b/frontend/src/lib/format/read.ts @@ -0,0 +1,104 @@ +import type { + MessageView, + ReaderView, + ReadInfo, + ReadOutcome, +} from "#lib/api/types.js"; +import { formatTime } from "#lib/format/datetime.js"; +import { locale, t } from "#lib/i18n/locale.svelte.js"; +import type { MessageKey } from "#lib/i18n/messages.js"; + +export type ReadLine = + | { kind: "readers"; readers: ReaderView[] } + | { icon: string; kind: "text"; muted: boolean; text: string }; + +const REASONS: Partial> = { + chat_too_big: "read.reason.chat_too_big", + error: "read.reason.error", + flood: "read.reason.flood", + too_old: "read.reason.too_old", + unavailable: "read.reason.unavailable", + user_privacy: "read.reason.user_privacy", + your_privacy: "read.reason.your_privacy", +}; + +const dayFormatters = new Map(); + +function dayLabel(date: Date, withYear: boolean): string { + const formatKey = `${locale.tag}:${withYear}`; + let formatter = dayFormatters.get(formatKey); + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale.tag, { + day: "numeric", + month: "short", + year: withYear ? "numeric" : undefined, + }); + dayFormatters.set(formatKey, formatter); + } + const parts = formatter.formatToParts(date); + const pick = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value.replace(".", ""); + return [pick("day"), pick("month"), pick("year")].filter(Boolean).join(" "); +} + +export function readWhen(iso: string): string { + const date = new Date(iso); + const now = new Date(); + const time = formatTime(iso); + if (date.toDateString() === now.toDateString()) { + return t("read.when.today", { time }); + } + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return t("read.when.yesterday", { time }); + } + const withYear = date.getFullYear() !== now.getFullYear(); + return t("read.when.day", { day: dayLabel(date, withYear), time }); +} + +function line(text: string, icon = "message-read", muted = false): ReadLine { + return { icon, kind: "text", muted, text }; +} + +export function readTitle(message: MessageView): string | undefined { + if (message.read_at) { + return t("read.at", { when: readWhen(message.read_at) }); + } + return message.read ? t("read.done") : undefined; +} + +export function readLine( + message: MessageView, + info: ReadInfo | null, + fetching: boolean +): ReadLine | null { + if (info === null) { + const title = readTitle(message); + return title ? line(title) : null; + } + if (info.chat_kind === "group" && info.readers.length > 0) { + return { kind: "readers", readers: info.readers }; + } + if (info.voice && info.listened_at) { + return line( + t("read.listened", { when: readWhen(info.listened_at) }), + "play-story" + ); + } + const at = info.exact_at ?? info.observed_at; + if (at) { + return line(t("read.at", { when: readWhen(at) })); + } + if (info.read) { + return line(t("read.done")); + } + if (info.unread) { + return line(t("read.unread"), "check"); + } + const reason = info.outcome ? REASONS[info.outcome] : undefined; + if (reason) { + return line(t(reason), "eye-crossed-outline", true); + } + return fetching ? line(t("read.pending"), "message-read", true) : null; +} diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index c6f5c95..a4b5b99 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -1,12 +1,14 @@ import { account } from "#lib/i18n/messages/account.js"; import { common } from "#lib/i18n/messages/common.js"; import { menu } from "#lib/i18n/messages/menu.js"; +import { read } from "#lib/i18n/messages/read.js"; import { settings } from "#lib/i18n/messages/settings.js"; export const messages = { ...account, ...common, ...menu, + ...read, ...settings, }; diff --git a/frontend/src/lib/i18n/messages/read.ts b/frontend/src/lib/i18n/messages/read.ts new file mode 100644 index 0000000..c832c52 --- /dev/null +++ b/frontend/src/lib/i18n/messages/read.ts @@ -0,0 +1,49 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const read = { + "read.at": { en: "Read {when}", ru: "Прочитано {when}" }, + "read.done": { en: "Read", ru: "Прочитано" }, + "read.listened": { en: "Listened {when}", ru: "Прослушано {when}" }, + "read.pending": { en: "Read time", ru: "Время прочтения" }, + "read.readers": { en: "Read by {count}", ru: "Прочитали: {count}" }, + "read.readers.empty": { + en: "Nobody has read it yet", + ru: "Пока никто не прочитал", + }, + "read.readers.title": { en: "Read by", ru: "Прочитали" }, + "read.reason.chat_too_big": { + en: "The group has over 100 members", + ru: "В группе больше 100 участников", + }, + "read.reason.error": { + en: "Telegram did not answer", + ru: "Telegram не ответил", + }, + "read.reason.flood": { + en: "Telegram asked to wait", + ru: "Telegram попросил подождать", + }, + "read.reason.too_old": { + en: "Telegram no longer keeps the time", + ru: "Telegram уже не хранит время", + }, + "read.reason.unavailable": { + en: "Telegram cannot find the chat", + ru: "Telegram не находит чат", + }, + "read.reason.user_privacy": { + en: "They hide their read time", + ru: "Собеседник скрывает время прочтения", + }, + "read.reason.your_privacy": { + en: "You hide your read time", + ru: "Вы скрываете своё время прочтения", + }, + "read.unread": { en: "Not read yet", ru: "Не прочитано" }, + "read.when.day": { en: "{day} at {time}", ru: "{day} в {time}" }, + "read.when.today": { en: "today at {time}", ru: "сегодня в {time}" }, + "read.when.yesterday": { + en: "yesterday at {time}", + ru: "вчера в {time}", + }, +} satisfies Messages; diff --git a/frontend/src/lib/stores/reads.svelte.ts b/frontend/src/lib/stores/reads.svelte.ts new file mode 100644 index 0000000..f6c1116 --- /dev/null +++ b/frontend/src/lib/stores/reads.svelte.ts @@ -0,0 +1,77 @@ +import { getJob, getReadInfo, refreshReadInfo } from "#lib/api/endpoints.js"; +import type { ReadInfo } from "#lib/api/types.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; + +const POLL_INTERVAL = 1000; +const POLL_MAX = 20; + +export interface ReadEntry { + fetching: boolean; + info: ReadInfo; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function waitForJob(jobId: number) { + for (let attempt = 0; attempt < POLL_MAX; attempt += 1) { + // biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose + await delay(POLL_INTERVAL); + const job = await getJob(jobId); + if (job.status !== "pending" && job.status !== "running") { + return; + } + } +} + +function createReads() { + let entries = $state>({}); + const inflight = new Set(); + + function key(chatId: number, messageId: number): string { + return `${accounts.selectedId}:${chatId}:${messageId}`; + } + + async function load(entryKey: string, chatId: number, messageId: number) { + const info = await getReadInfo(chatId, messageId); + entries[entryKey] = { fetching: info.can_fetch, info }; + if (!info.can_fetch) { + return; + } + try { + const { job_id } = await refreshReadInfo(chatId, messageId); + if (job_id !== null) { + await waitForJob(job_id); + } + entries[entryKey] = { + fetching: false, + info: await getReadInfo(chatId, messageId), + }; + } finally { + entries[entryKey].fetching = false; + } + } + + return { + get(chatId: number, messageId: number): ReadEntry | undefined { + return entries[key(chatId, messageId)]; + }, + open(chatId: number, messageId: number) { + const entryKey = key(chatId, messageId); + if (accounts.selectedId === null || inflight.has(entryKey)) { + return; + } + inflight.add(entryKey); + load(entryKey, chatId, messageId) + .catch(() => undefined) + .finally(() => { + inflight.delete(entryKey); + }); + }, + }; +} + +export const reads = createReads(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index a8f5b0a..608906c 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -3,6 +3,7 @@ export type RightPanel = | "search" | "versions" | "reactions" + | "readers" | "callbacks" | "links" | "annotations"