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
+112 -4
View File
@@ -1,10 +1,20 @@
<script lang="ts">
import { getPeer } from "$lib/api/endpoints";
import type { PeerView } from "$lib/api/types";
import {
enqueueBackfill,
getCurrentPresence,
getPeer,
} from "$lib/api/endpoints";
import type { PeerView, PresenceSample } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { peerName } from "$lib/format/peer";
import { formatPresence } from "$lib/format/presence";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { events } from "$lib/stores/events.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
chatId: number;
@@ -15,6 +25,23 @@
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
let peer = $state<PeerView | null>(null);
let presence = $state<PresenceSample | null>(null);
let backfilling = $state(false);
async function backfill() {
if (backfilling) {
return;
}
backfilling = true;
try {
await enqueueBackfill(chatId, true);
toasts.success("Бэкфилл запущен");
} catch {
toasts.error("Не удалось запустить бэкфилл");
} finally {
backfilling = false;
}
}
$effect(() => {
if (accounts.selectedId === null || !isDm) {
@@ -39,10 +66,48 @@
};
});
const fallbackTitle = $derived(chat?.title ?? `Chat ${chatId}`);
$effect(() => {
if (accounts.selectedId === null || !isDm) {
presence = null;
return;
}
let active = true;
presence = null;
getCurrentPresence(chatId)
.then((result) => {
if (active) {
presence = result;
}
})
.catch(() => {
if (active) {
presence = null;
}
});
const unsub = events.subscribe((event) => {
if (
event.type === "presence" &&
event.peer_id === chatId &&
event.sample
) {
presence = event.sample;
}
});
return () => {
active = false;
unsub();
};
});
const fallbackTitle = $derived(
chat?.title ?? (isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
);
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
if (isDm) {
if (presence) {
return formatPresence(presence);
}
if (peer?.username) {
return `@${peer.username}`;
}
@@ -66,7 +131,39 @@
/>
<div class="info">
<h2 class="title">{title}</h2>
<span class="subtitle">{subtitle}</span>
<span class="subtitle" class:online={isDm && presence?.status === "online"}>
{subtitle}
</span>
</div>
<div class="actions">
<Button
variant="translucent"
round
smaller
onclick={() => ui.openPanel("search")}
aria-label="Поиск в чате"
>
<Icon name="search" />
</Button>
<Button
variant="translucent"
round
smaller
onclick={() => ui.openPanel("presence")}
aria-label="Аналитика"
>
<Icon name="stats" />
</Button>
<Button
variant="translucent"
round
smaller
loading={backfilling}
onclick={backfill}
aria-label="Скачать историю"
>
<Icon name="cloud-download" />
</Button>
</div>
</header>
@@ -88,6 +185,13 @@
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.title {
overflow: hidden;
margin: 0;
@@ -100,5 +204,9 @@
.subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
</style>