Files
beavergram/frontend/src/lib/components/MessageList.svelte
T

606 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { tick, untrack } from "svelte";
import { listMessages } from "$lib/api/endpoints";
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
import type { LiveEvent, MessageView } from "$lib/api/types";
import ActionMessage from "$lib/components/ActionMessage.svelte";
import MediaViewer from "$lib/components/MediaViewer.svelte";
import MessageBubble from "$lib/components/MessageBubble.svelte";
import MessageVersions from "$lib/components/MessageVersions.svelte";
import PinnedBar from "$lib/components/PinnedBar.svelte";
import TrackChat from "$lib/components/TrackChat.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatDay } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const skeletonBubbles = [42, 66, 28, 54, 38, 72, 46];
const PAGE = 60;
const SCROLL_THRESHOLD = 160;
const STICK_OFFSET = 9;
const IDLE_DELAY = 1500;
let messages = $state<MessageView[]>([]);
let loading = $state(true);
let loadingOlder = $state(false);
let loadingNewer = $state(false);
let hasMore = $state(true);
let hasNewer = $state(false);
let container = $state<HTMLDivElement | null>(null);
let suppressAppear = $state(false);
let scrolling = $state(false);
let stuckDay = $state<string | null>(null);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false);
let mediaItems = $state<ViewerItem[]>([]);
let mediaIndex = $state(0);
let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const isGroupChat = $derived(chatId < 0);
interface Row {
dayKey: string;
daySeparator: string | null;
firstInGroup: boolean;
lastInGroup: boolean;
message: MessageView;
own: boolean;
}
function dayKey(iso: string): string {
return new Date(iso).toDateString();
}
const rows = $derived.by<Row[]>(() => {
const out: Row[] = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const prev = messages[i - 1];
const next = messages[i + 1];
const day = dayKey(message.date);
const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({
message,
dayKey: day,
own: ownId !== null && message.sender_id === ownId,
firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay,
daySeparator: samePrevDay ? null : formatDay(message.date),
});
}
return out;
});
function ensurePeers(items: MessageView[]) {
const ids = new Set<number>();
for (const message of items) {
if (message.sender_id !== null) {
ids.add(message.sender_id);
}
if (message.reply?.sender_id != null) {
ids.add(message.reply.sender_id);
}
if (message.forward?.from_id != null) {
ids.add(message.forward.from_id);
}
if (message.service?.member_ids) {
for (const id of message.service.member_ids) {
ids.add(id);
}
}
}
if (ownId !== null) {
ids.add(ownId);
}
peers.ensure(ids);
}
function jumpToMessage(messageId: number) {
const target = container?.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`
);
if (!target) {
toasts.error("Message not loaded");
return;
}
target.scrollIntoView({ behavior: "smooth", block: "center" });
highlightId = messageId;
if (highlightTimer) {
clearTimeout(highlightTimer);
}
highlightTimer = setTimeout(() => {
highlightId = null;
}, 1600);
}
function scrollToBottom() {
if (container) {
container.scrollTop = container.scrollHeight;
}
}
function updateStuck() {
if (!container) {
return;
}
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null;
for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) {
day = sep.dataset.day ?? null;
}
}
stuckDay = day;
}
async function loadInitial() {
loading = true;
hasMore = true;
hasNewer = false;
try {
const page = await listMessages(chatId, {
limit: PAGE,
offset: 0,
include_deleted: true,
});
messages = [...page].reverse();
hasMore = page.length > 0;
ensurePeers(messages);
await tick();
scrollToBottom();
updateStuck();
} catch {
toasts.error("Failed to load messages");
} finally {
loading = false;
}
}
async function loadOlder() {
if (
loadingOlder ||
!hasMore ||
container === null ||
messages.length === 0
) {
return;
}
loadingOlder = true;
const el = container;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
try {
const page = await listMessages(chatId, {
limit: PAGE,
before_id: messages[0].message_id,
include_deleted: true,
});
const known = new Set(messages.map((m) => m.message_id));
const fresh = page.filter((m) => !known.has(m.message_id));
hasMore = fresh.length > 0;
if (fresh.length > 0) {
suppressAppear = true;
messages = [...[...fresh].reverse(), ...messages];
ensurePeers(fresh);
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
}
} finally {
loadingOlder = false;
suppressAppear = false;
}
}
async function loadNewer() {
if (loadingNewer || !hasNewer || messages.length === 0) {
return;
}
loadingNewer = true;
try {
const page = await listMessages(chatId, {
limit: PAGE,
after_id: messages.at(-1)?.message_id,
include_deleted: true,
});
const known = new Set(messages.map((m) => m.message_id));
const fresh = page.filter((m) => !known.has(m.message_id));
hasNewer = fresh.length > 0;
if (fresh.length > 0) {
suppressAppear = true;
messages = [...messages, ...fresh];
ensurePeers(fresh);
await tick();
}
} finally {
loadingNewer = false;
suppressAppear = false;
}
}
async function loadAround(messageId: number) {
suppressAppear = true;
try {
const [older, newer] = await Promise.all([
listMessages(chatId, {
limit: PAGE,
before_id: messageId + 1,
include_deleted: true,
}),
listMessages(chatId, {
limit: PAGE,
after_id: messageId,
include_deleted: true,
}),
]);
messages = [...[...older].reverse(), ...newer];
hasMore = older.length > 0;
hasNewer = newer.length > 0;
ensurePeers(messages);
await tick();
} finally {
suppressAppear = false;
}
}
async function goToLatest() {
await loadInitial();
}
function onScroll() {
scrolling = true;
if (idleTimer) {
clearTimeout(idleTimer);
}
idleTimer = setTimeout(() => {
scrolling = false;
}, IDLE_DELAY);
updateStuck();
if (container && container.scrollTop < SCROLL_THRESHOLD) {
loadOlder();
}
if (hasNewer && isNearBottom()) {
loadNewer();
}
}
function openMedia(message: MessageView, index: number) {
mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index;
mediaOpen = true;
}
function openVersions(messageId: number) {
versionsMessageId = messageId;
versionsOpen = true;
}
function isNearBottom(): boolean {
if (!container) {
return true;
}
return (
container.scrollHeight - container.scrollTop - container.clientHeight <
SCROLL_THRESHOLD
);
}
function replaceMessage(message: MessageView) {
messages = messages.map((m) =>
m.message_id === message.message_id ? message : m
);
}
async function appendMessage(message: MessageView) {
if (messages.some((m) => m.message_id === message.message_id)) {
replaceMessage(message);
return;
}
if (hasNewer) {
return;
}
const stick = isNearBottom();
messages = [...messages, message];
ensurePeers([message]);
if (stick) {
await tick();
scrollToBottom();
}
}
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: now }
: m
);
}
function applyReceipt(readUpTo: number) {
if (ownId === null) {
return;
}
messages = messages.map((m) =>
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
? { ...m, read: true }
: m
);
}
function applyLiveEvent(event: LiveEvent) {
if (event.type === "message" && event.message.chat_id === chatId) {
appendMessage(event.message);
} else if (
(event.type === "edit" || event.type === "reaction") &&
event.message.chat_id === chatId
) {
replaceMessage(event.message);
} else if (
event.type === "delete" &&
(event.chat_id === null || event.chat_id === chatId)
) {
applyDelete(event.message_ids);
} else if (event.type === "receipt" && event.chat_id === chatId) {
applyReceipt(event.read_up_to);
}
}
async function resyncTail() {
if (messages.length === 0 || hasNewer) {
return;
}
const page = await listMessages(chatId, {
limit: PAGE,
offset: 0,
include_deleted: true,
});
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 stick = isNearBottom();
const appended: MessageView[] = [];
for (const message of fresh) {
if (known.has(message.message_id)) {
known.set(message.message_id, message);
} else if (message.message_id > maxId) {
appended.push(message);
}
}
messages = [
...messages.map((m) => known.get(m.message_id) ?? m),
...appended,
];
ensurePeers(fresh);
if (appended.length > 0 && stick) {
await tick();
scrollToBottom();
}
}
async function jumpToTarget(messageId: number) {
if (!messages.some((m) => m.message_id === messageId)) {
await loadAround(messageId);
}
await tick();
jumpToMessage(messageId);
}
$effect(() => {
const target = ui.jumpTarget;
if (target === null || target.chatId !== chatId || loading) {
return;
}
ui.clearJump();
jumpToTarget(target.messageId);
});
$effect(() => {
const unsub = events.subscribe(applyLiveEvent);
const unsubReconnect = events.onReconnect(resyncTail);
return () => {
unsub();
unsubReconnect();
};
});
$effect(() => {
if (accounts.selectedId === null) {
return;
}
untrack(() => loadInitial());
});
</script>
<div class="message-pane">
<PinnedBar {chatId} onjump={jumpToMessage} />
<div
bind:this={container}
class="message-list custom-scroll"
onscroll={onScroll}
>
{#if loading && messages.length === 0}
<div class="messages-container">
{#each skeletonBubbles as width, index (index)}
<div class="bubble-skeleton skeleton" style:width="{width}%"></div>
{/each}
</div>
{:else if rows.length === 0}
<TrackChat {chatId} />
{:else}
<div class="messages-container">
{#if loadingOlder}
<div class="loading-older"><Spinner /></div>
{/if}
{#each rows as row (row.message.message_id)}
{#if row.daySeparator}
<div
class="day-separator"
class:idle={!scrolling && row.dayKey === stuckDay}
data-day={row.dayKey}
>
<span>{row.daySeparator}</span>
</div>
{/if}
{#if row.message.service}
<ActionMessage message={row.message} onjump={jumpToMessage} />
{:else}
<MessageBubble
message={row.message}
own={row.own}
{isGroupChat}
firstInGroup={row.firstInGroup}
lastInGroup={row.lastInGroup}
highlighted={highlightId === row.message.message_id}
animate={!suppressAppear}
onjump={jumpToMessage}
onmedia={(index) => openMedia(row.message, index)}
onversions={() => openVersions(row.message.message_id)}
/>
{/if}
{/each}
{#if loadingNewer}
<div class="loading-older"><Spinner /></div>
{/if}
</div>
{/if}
</div>
{#if hasNewer}
<button
type="button"
class="to-latest"
onclick={goToLatest}
aria-label="К последним сообщениям"
>
<Icon name="arrow-down" />
</button>
{/if}
</div>
<MediaViewer
bind:open={mediaOpen}
bind:index={mediaIndex}
{chatId}
items={mediaItems}
/>
<MessageVersions
bind:open={versionsOpen}
{chatId}
messageId={versionsMessageId}
/>
<style lang="scss">
.message-pane {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.to-latest {
position: absolute;
right: 1.25rem;
bottom: 1.25rem;
z-index: var(--z-sticky-date);
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-background);
box-shadow: 0 2px 8px rgb(0 0 0 / 16%);
&:hover {
color: var(--color-text);
}
}
.message-list {
overflow-y: auto;
flex: 1;
min-height: 0;
}
.messages-container {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
padding: 1rem max(1rem, calc((100% - var(--messages-container-width)) / 2));
}
.loading-older {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.bubble-skeleton {
align-self: flex-start;
height: 2.25rem;
margin-bottom: 0.5rem;
border-radius: var(--border-radius-messages);
&:last-child {
margin-bottom: 0;
}
}
.day-separator {
pointer-events: none;
position: sticky;
top: 0.5625rem;
z-index: var(--z-sticky-date);
display: flex;
justify-content: center;
margin: 0.75rem 0;
span {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
background-color: var(--color-default-shadow);
backdrop-filter: blur(8px);
transition: opacity 0.3s ease;
}
&.idle span {
opacity: 0;
}
}
</style>