feat: web UI chat render, panels, presence + analytics
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user