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

634 lines
16 KiB
Svelte

<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;
const MAX_MESSAGES = 360;
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 += 1) {
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({
dayKey: day,
daySeparator: samePrevDay ? null : formatDay(message.date),
firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay,
message,
own: ownId !== null && message.sender_id === ownId,
});
}
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);
}
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
if (message.reply?.sender_id != null) {
ids.add(message.reply.sender_id);
}
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
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, {
include_deleted: true,
limit: PAGE,
offset: 0,
});
messages = [...page].reverse();
hasMore = page.length > 0;
ensurePeers(messages);
await tick();
scrollToBottom();
updateStuck();
} catch {
toasts.error("Failed to load messages");
} finally {
loading = false;
}
}
function trimTail() {
if (messages.length <= MAX_MESSAGES) {
return;
}
messages = messages.slice(0, MAX_MESSAGES);
hasNewer = true;
}
async function trimHead() {
if (messages.length <= MAX_MESSAGES || container === null) {
return;
}
const el = container;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
messages = messages.slice(messages.length - MAX_MESSAGES);
hasMore = true;
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
}
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, {
before_id: messages[0].message_id,
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));
hasMore = fresh.length > 0;
if (fresh.length > 0) {
suppressAppear = true;
messages = [...[...fresh].reverse(), ...messages];
ensurePeers(fresh);
await tick();
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
trimTail();
}
} finally {
loadingOlder = false;
suppressAppear = false;
}
}
async function loadNewer() {
if (loadingNewer || !hasNewer || messages.length === 0) {
return;
}
loadingNewer = true;
try {
const page = await listMessages(chatId, {
after_id: messages.at(-1)?.message_id,
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));
hasNewer = fresh.length > 0;
if (fresh.length > 0) {
suppressAppear = true;
messages = [...messages, ...fresh];
ensurePeers(fresh);
await tick();
await trimHead();
}
} finally {
loadingNewer = false;
suppressAppear = false;
}
}
async function loadAround(messageId: number) {
suppressAppear = true;
try {
const [older, newer] = await Promise.all([
listMessages(chatId, {
before_id: messageId + 1,
include_deleted: true,
limit: PAGE,
}),
listMessages(chatId, {
after_id: messageId,
include_deleted: true,
limit: PAGE,
}),
]);
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]);
await trimHead();
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, {
include_deleted: true,
limit: PAGE,
offset: 0,
});
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);
await trimHead();
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
class="message-list custom-scroll"
onscroll={onScroll}
bind:this={container}
>
{#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"
data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay}
>
<span>{row.daySeparator}</span>
</div>
{/if}
{#if row.message.service}
<ActionMessage message={row.message} onjump={jumpToMessage} />
{:else}
<MessageBubble
animate={!suppressAppear}
firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id}
{isGroupChat}
lastInGroup={row.lastInGroup}
message={row.message}
onjump={jumpToMessage}
onmedia={(index) => openMedia(row.message, index)}
onversions={() => openVersions(row.message.message_id)}
own={row.own}
/>
{/if}
{/each}
{#if loadingNewer}
<div class="loading-older"><Spinner /></div>
{/if}
</div>
{/if}
</div>
{#if hasNewer}
<button
aria-label="К последним сообщениям"
class="to-latest"
onclick={goToLatest}
type="button"
>
<Icon name="arrow-down" />
</button>
{/if}
</div>
<MediaViewer
{chatId}
items={mediaItems}
bind:index={mediaIndex}
bind:open={mediaOpen}
/>
<MessageVersions
{chatId}
messageId={versionsMessageId}
bind:open={versionsOpen}
/>
<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>