feat: 1-to-1 message render + web data-lake backend
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { listMessages } from "$lib/api/endpoints";
|
||||
import { type ViewerItem, viewerItemsFrom } from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import MediaViewer from "$lib/components/MediaViewer.svelte";
|
||||
import MessageBubble from "$lib/components/MessageBubble.svelte";
|
||||
import MessageVersions from "$lib/components/MessageVersions.svelte";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatDay } from "$lib/format/datetime";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.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 hasMore = $state(true);
|
||||
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 (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;
|
||||
try {
|
||||
const page = await listMessages(chatId, {
|
||||
limit: PAGE,
|
||||
offset: 0,
|
||||
include_deleted: true,
|
||||
});
|
||||
messages = [...page].reverse();
|
||||
hasMore = page.length === PAGE;
|
||||
ensurePeers(messages);
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
updateStuck();
|
||||
} catch {
|
||||
toasts.error("Failed to load messages");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loadingOlder || !hasMore || container === null) {
|
||||
return;
|
||||
}
|
||||
loadingOlder = true;
|
||||
suppressAppear = true;
|
||||
const el = container;
|
||||
const prevHeight = el.scrollHeight;
|
||||
const prevTop = el.scrollTop;
|
||||
try {
|
||||
const page = await listMessages(chatId, {
|
||||
limit: PAGE,
|
||||
offset: messages.length,
|
||||
include_deleted: true,
|
||||
});
|
||||
if (page.length > 0) {
|
||||
messages = [...[...page].reverse(), ...messages];
|
||||
ensurePeers(page);
|
||||
}
|
||||
hasMore = page.length === PAGE;
|
||||
await tick();
|
||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
suppressAppear = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
scrolling = true;
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
idleTimer = setTimeout(() => {
|
||||
scrolling = false;
|
||||
}, IDLE_DELAY);
|
||||
updateStuck();
|
||||
if (container && container.scrollTop < SCROLL_THRESHOLD) {
|
||||
loadOlder();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const deps = {
|
||||
account: accounts.selectedId,
|
||||
revision: chats.revision,
|
||||
};
|
||||
if (deps.account === null) {
|
||||
return;
|
||||
}
|
||||
loadInitial();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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}
|
||||
<EmptyState
|
||||
title="No messages"
|
||||
description="This chat has no archived messages"
|
||||
/>
|
||||
{: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}
|
||||
<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)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<MediaViewer
|
||||
bind:open={mediaOpen}
|
||||
bind:index={mediaIndex}
|
||||
{chatId}
|
||||
items={mediaItems}
|
||||
/>
|
||||
<MessageVersions
|
||||
bind:open={versionsOpen}
|
||||
{chatId}
|
||||
messageId={versionsMessageId}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user