diff --git a/frontend/src/lib/components/MessageList.svelte b/frontend/src/lib/components/MessageList.svelte index 72c0608..dec099f 100644 --- a/frontend/src/lib/components/MessageList.svelte +++ b/frontend/src/lib/components/MessageList.svelte @@ -12,6 +12,7 @@ import Icon from "#lib/components/ui/Icon.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte"; import { formatDay } from "#lib/format/datetime.js"; + import { t } from "#lib/i18n/locale.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; import { events } from "#lib/stores/events.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js"; @@ -117,16 +118,78 @@ peers.ensure(ids); } + function memberIds(message: MessageView): number[] { + return [message.message_id, ...message.media.map((ref) => ref.message_id)]; + } + + function holds(message: MessageView, messageId: number): boolean { + return memberIds(message).includes(messageId); + } + + function sameAlbum(a: MessageView, b: MessageView): boolean { + return ( + a.media_group_id !== null && + a.media_group_id === b.media_group_id && + a.chat_id === b.chat_id + ); + } + + function albumPrimary(base: MessageView, incoming: MessageView) { + if ( + base.message_id === incoming.message_id || + (incoming.text && !base.text) + ) { + return incoming; + } + if (base.text) { + return base; + } + return incoming.message_id < base.message_id ? incoming : base; + } + + function joinAlbum(base: MessageView, incoming: MessageView): MessageView { + const media = new Map(base.media.map((ref) => [ref.message_id, ref])); + for (const ref of incoming.media) { + media.set(ref.message_id, ref); + } + return { + ...albumPrimary(base, incoming), + media: [...media.values()].sort((a, b) => a.message_id - b.message_id), + }; + } + + function mergeAlbums(list: MessageView[]): MessageView[] { + const out: MessageView[] = []; + for (const message of list) { + const prev = out.at(-1); + if (prev && sameAlbum(prev, message)) { + out[out.length - 1] = joinAlbum(prev, message); + } else { + out.push(message); + } + } + return out; + } + + function knownIds(): Set { + return new Set(messages.flatMap(memberIds)); + } + + function ownerOf(messageId: number): number | null { + return messages.find((m) => holds(m, messageId))?.message_id ?? null; + } + function jumpToMessage(messageId: number) { + const owner = ownerOf(messageId) ?? messageId; const target = container?.querySelector( - `[data-message-id="${messageId}"]` + `[data-message-id="${owner}"]` ); if (!target) { - toasts.error("Message not loaded"); + toasts.error(t("thread.notFound")); return; } target.scrollIntoView({ behavior: "smooth", block: "center" }); - highlightId = messageId; + highlightId = owner; if (highlightTimer) { clearTimeout(highlightTimer); } @@ -160,20 +223,28 @@ loading = true; hasMore = true; hasNewer = false; + const pending = ui.jumpTarget?.chatId === chatId ? ui.jumpTarget : null; try { + if (pending) { + ui.clearJump(); + await loadAround(pending.messageId); + await tick(); + jumpToMessage(pending.messageId); + return; + } const page = await listMessages(chatId, { include_deleted: true, limit: PAGE, offset: 0, }); - messages = [...page].reverse(); + messages = mergeAlbums([...page].reverse()); hasMore = page.length > 0; ensurePeers(messages); await tick(); scrollToBottom(); updateStuck(); } catch { - toasts.error("Failed to load messages"); + toasts.error(t("thread.loadFailed")); } finally { loading = false; } @@ -215,16 +286,18 @@ const prevTop = el.scrollTop; try { const page = await listMessages(chatId, { - before_id: messages[0].message_id, + before_id: Math.min(...memberIds(messages[0])), include_deleted: true, limit: PAGE, }); - const known = new Set(messages.map((m) => m.message_id)); - const fresh = page.filter((m) => !known.has(m.message_id)); + const known = knownIds(); + const fresh = page.filter((m) => + memberIds(m).some((id) => !known.has(id)) + ); hasMore = fresh.length > 0; if (fresh.length > 0) { suppressAppear = true; - messages = [...[...fresh].reverse(), ...messages]; + messages = mergeAlbums([...[...fresh].reverse(), ...messages]); ensurePeers(fresh); await tick(); el.scrollTop = prevTop + (el.scrollHeight - prevHeight); @@ -242,17 +315,20 @@ } loadingNewer = true; try { + const last = messages.at(-1); const page = await listMessages(chatId, { - after_id: messages.at(-1)?.message_id, + after_id: last ? Math.max(...memberIds(last)) : undefined, include_deleted: true, limit: PAGE, }); - const known = new Set(messages.map((m) => m.message_id)); - const fresh = page.filter((m) => !known.has(m.message_id)); + const known = knownIds(); + const fresh = page.filter((m) => + memberIds(m).some((id) => !known.has(id)) + ); hasNewer = fresh.length > 0; if (fresh.length > 0) { suppressAppear = true; - messages = [...messages, ...fresh]; + messages = mergeAlbums([...messages, ...fresh]); ensurePeers(fresh); await tick(); await trimHead(); @@ -278,7 +354,7 @@ limit: PAGE, }), ]); - messages = [...[...older].reverse(), ...newer]; + messages = mergeAlbums([...[...older].reverse(), ...newer]); hasMore = older.length > 0; hasNewer = newer.length > 0; ensurePeers(messages); @@ -331,13 +407,20 @@ } function replaceMessage(message: MessageView) { - messages = messages.map((m) => - m.message_id === message.message_id ? message : m - ); + messages = messages.map((m) => { + if (sameAlbum(m, message)) { + return joinAlbum(m, message); + } + return m.message_id === message.message_id ? message : m; + }); } async function appendMessage(message: MessageView) { - if (messages.some((m) => m.message_id === message.message_id)) { + const last = messages.at(-1); + if ( + messages.some((m) => holds(m, message.message_id)) || + (last && sameAlbum(last, message)) + ) { replaceMessage(message); return; } @@ -357,7 +440,7 @@ function applyDelete(ids: number[]) { const now = new Date().toISOString(); messages = messages.map((m) => - ids.includes(m.message_id) && m.deleted_at === null + m.deleted_at === null && ids.some((id) => holds(m, id)) ? { ...m, deleted_at: now } : m ); @@ -403,20 +486,24 @@ }); const fresh = [...page].reverse(); const known = new Map(messages.map((m) => [m.message_id, m])); - const maxId = messages.reduce((acc, m) => Math.max(acc, m.message_id), 0); + const maxId = Math.max(0, ...messages.flatMap(memberIds)); const stick = isNearBottom(); const appended: MessageView[] = []; for (const message of fresh) { - if (known.has(message.message_id)) { - known.set(message.message_id, message); + const current = known.get(message.message_id); + if (current) { + known.set( + message.message_id, + sameAlbum(current, message) ? joinAlbum(current, message) : message + ); } else if (message.message_id > maxId) { appended.push(message); } } - messages = [ + messages = mergeAlbums([ ...messages.map((m) => known.get(m.message_id) ?? m), ...appended, - ]; + ]); ensurePeers(fresh); await trimHead(); if (appended.length > 0 && stick) { @@ -425,12 +512,16 @@ } } - async function jumpToTarget(messageId: number) { - if (!messages.some((m) => m.message_id === messageId)) { - await loadAround(messageId); + async function goTo(messageId: number) { + try { + if (ownerOf(messageId) === null) { + await loadAround(messageId); + } + await tick(); + jumpToMessage(messageId); + } catch { + toasts.error(t("thread.loadFailed")); } - await tick(); - jumpToMessage(messageId); } $effect(() => { @@ -439,7 +530,7 @@ return; } ui.clearJump(); - jumpToTarget(target.messageId); + goTo(target.messageId); }); $effect(() => { @@ -460,7 +551,7 @@
- +
{/if} {#if row.message.service} - + {:else} openMedia(row.message, index)} onversions={() => openVersions(row.message.message_id)} own={row.own} @@ -514,7 +605,7 @@
{#if hasNewer}