feat: add annotations, user profiles, watchers, stories, search and more

This commit is contained in:
hh
2026-06-01 17:15:09 +02:00
parent ed469ba8dd
commit 2465bcd184
47 changed files with 5009 additions and 242 deletions
+124 -21
View File
@@ -9,6 +9,7 @@
import MessageVersions from "$lib/components/MessageVersions.svelte";
import PinnedBar from "$lib/components/PinnedBar.svelte";
import EmptyState from "$lib/components/ui/EmptyState.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";
@@ -30,12 +31,13 @@
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);
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);
@@ -155,6 +157,7 @@
async function loadInitial() {
loading = true;
hasMore = true;
hasNewer = false;
try {
const page = await listMessages(chatId, {
limit: PAGE,
@@ -162,7 +165,7 @@
include_deleted: true,
});
messages = [...page].reverse();
hasMore = page.length === PAGE;
hasMore = page.length > 0;
ensurePeers(messages);
await tick();
scrollToBottom();
@@ -175,33 +178,95 @@
}
async function loadOlder() {
if (loadingOlder || !hasMore || container === null) {
if (
loadingOlder ||
!hasMore ||
container === null ||
messages.length === 0
) {
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,
before_id: messages[0].message_id,
include_deleted: true,
});
if (page.length > 0) {
messages = [...[...page].reverse(), ...messages];
ensurePeers(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);
}
hasMore = page.length === PAGE;
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) {
@@ -214,6 +279,9 @@
if (container && container.scrollTop < SCROLL_THRESHOLD) {
loadOlder();
}
if (hasNewer && isNearBottom()) {
loadNewer();
}
}
function openMedia(message: MessageView, index: number) {
@@ -248,6 +316,9 @@
replaceMessage(message);
return;
}
if (hasNewer) {
return;
}
const stick = isNearBottom();
messages = [...messages, message];
ensurePeers([message]);
@@ -296,7 +367,7 @@
}
async function resyncTail() {
if (messages.length === 0) {
if (messages.length === 0 || hasNewer) {
return;
}
const page = await listMessages(chatId, {
@@ -328,15 +399,8 @@
}
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++;
if (!messages.some((m) => m.message_id === messageId)) {
await loadAround(messageId);
}
await tick();
jumpToMessage(messageId);
@@ -422,9 +486,22 @@
/>
{/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
@@ -441,11 +518,37 @@
<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;