feat: web UI chat render, panels, presence + analytics

This commit is contained in:
hh
2026-05-31 19:41:37 +02:00
parent 75425d1bee
commit ed469ba8dd
83 changed files with 6034 additions and 136 deletions
+204 -47
View File
@@ -2,17 +2,21 @@
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 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 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 { 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;
@@ -26,6 +30,7 @@
const SCROLL_THRESHOLD = 160;
const STICK_OFFSET = 9;
const IDLE_DELAY = 1500;
const JUMP_MAX_PAGES = 12;
let messages = $state<MessageView[]>([]);
let loading = $state(true);
@@ -96,6 +101,11 @@
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);
@@ -217,6 +227,139 @@
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;
}
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) {
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) {
let guard = 0;
while (
!messages.some((m) => m.message_id === messageId) &&
hasMore &&
(messages.length === 0 || messages[0].message_id > messageId) &&
guard < JUMP_MAX_PAGES
) {
await loadOlder();
guard++;
}
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(() => {
const deps = {
account: accounts.selectedId,
@@ -229,52 +372,59 @@
});
</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>
<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}
<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}
<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}
{#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}
</div>
{/if}
</div>
</div>
<MediaViewer
@@ -290,9 +440,16 @@
/>
<style lang="scss">
.message-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.message-list {
overflow-y: auto;
height: 100%;
flex: 1;
min-height: 0;
}
.messages-container {