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

This commit is contained in:
h
2026-05-31 19:41:01 +02:00
parent 75425d1bee
commit ed469ba8dd
83 changed files with 6034 additions and 136 deletions
+70
View File
@@ -0,0 +1,70 @@
import { accounts } from "$lib/stores/accounts.svelte";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const RETRY_DELAY = 2500;
export interface CustomEmojiAsset {
mime: string;
url: string;
}
const ready = new Map<string, CustomEmojiAsset>();
const missing = new Set<string>();
const inflight = new Map<string, Promise<CustomEmojiAsset | null>>();
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function fetchEmoji(
account: number,
id: string,
key: string,
retry: boolean
): Promise<CustomEmojiAsset | null> {
const url = `${BASE}/custom-emoji/${id}?account_id=${account}`;
const response = await fetch(url, { headers: authHeaders() });
if (response.ok) {
const blob = await response.blob();
const asset = { url: URL.createObjectURL(blob), mime: blob.type };
ready.set(key, asset);
return asset;
}
if (response.status === 409 && retry) {
await delay(RETRY_DELAY);
return fetchEmoji(account, id, key, false);
}
missing.add(key);
return null;
}
export function loadCustomEmoji(id: string): Promise<CustomEmojiAsset | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
const key = `${account}:${id}`;
const cached = ready.get(key);
if (cached) {
return Promise.resolve(cached);
}
if (missing.has(key)) {
return Promise.resolve(null);
}
const existing = inflight.get(key);
if (existing) {
return existing;
}
const promise = fetchEmoji(account, id, key, true).finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
+131
View File
@@ -1,14 +1,25 @@
import { request } from "$lib/api/client";
import type {
Account,
CaptureToggles,
Chat,
Folder,
JobStatus,
JobView,
MediaVersion,
MediaView,
MessageVersion,
MessageView,
PeerView,
PinnedView,
PolicyChatKind,
PolicyCreate,
PolicyRecord,
PresenceHourly,
PresenceSample,
ResponseStats,
SearchHit,
VolumeBucket,
} from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
@@ -29,6 +40,43 @@ export function listFolders(): Promise<Folder[]> {
return request<Folder[]>("/folders", { account: true });
}
export function listPolicies(): Promise<PolicyRecord[]> {
return request<PolicyRecord[]>("/policy", { account: true });
}
export function createPolicy(body: PolicyCreate): Promise<PolicyRecord> {
return request<PolicyRecord>("/policy", {
method: "POST",
body: { ...body, account_id: accounts.selectedId },
});
}
export function updatePolicy(
id: number,
toggles: CaptureToggles
): Promise<PolicyRecord> {
return request<PolicyRecord>(`/policy/${id}`, {
method: "PUT",
body: toggles,
});
}
export function deletePolicy(id: number): Promise<void> {
return request<void>(`/policy/${id}`, { method: "DELETE" });
}
export function effectivePolicy(query: {
chat_id: number;
is_bot?: boolean;
is_contact?: boolean | null;
kind: PolicyChatKind;
}): Promise<CaptureToggles> {
return request<CaptureToggles>("/policy/effective", {
account: true,
query,
});
}
export function listMessages(
chatId: number,
options: Page & { include_deleted?: boolean } = {}
@@ -39,6 +87,55 @@ export function listMessages(
});
}
export function getCurrentPresence(
peerId: number
): Promise<PresenceSample | null> {
return request<PresenceSample | null>("/presence/current", {
account: true,
query: { peer_id: peerId },
});
}
export function getPresenceHistory(
peerId: number,
page: Page = {}
): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence", {
account: true,
query: { peer_id: peerId, ...page },
});
}
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
return request<PresenceHourly[]>("/presence/hourly", {
account: true,
query: { peer_id: peerId },
});
}
export function getMessageVolume(
chatId: number,
days = 90
): Promise<VolumeBucket[]> {
return request<VolumeBucket[]>("/analytics/volume", {
account: true,
query: { chat_id: chatId, days },
});
}
export function getResponseStats(chatId: number): Promise<ResponseStats> {
return request<ResponseStats>("/analytics/response-time", {
account: true,
query: { chat_id: chatId },
});
}
export function getPinned(chatId: number): Promise<PinnedView | null> {
return request<PinnedView | null>(`/chats/${chatId}/pinned`, {
account: true,
});
}
export function listMessageVersions(
chatId: number,
messageId: number
@@ -83,6 +180,30 @@ export function getJob(jobId: number): Promise<JobView> {
return request<JobView>(`/jobs/${jobId}`, { account: true });
}
export function listJobs(status?: JobStatus): Promise<JobView[]> {
return request<JobView[]>("/jobs", {
account: true,
query: status ? { status } : {},
});
}
export function enqueueBackfill(
chatId: number,
media: boolean
): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/backfill", {
method: "POST",
body: { account_id: accounts.selectedId, chat_id: chatId, media },
});
}
export function syncDialogs(): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/dialogs/sync", {
method: "POST",
body: { account_id: accounts.selectedId },
});
}
export function getMediaVersions(
chatId: number,
messageId: number
@@ -105,6 +226,16 @@ export function getMessageMedia(
});
}
export function searchMessages(
query: string,
options: Page & { chat_id?: number } = {}
): Promise<SearchHit[]> {
return request<SearchHit[]>("/search", {
account: true,
query: { query, ...options },
});
}
export function fetchMedia(
chatId: number,
messageId: number
+58
View File
@@ -139,6 +139,13 @@ export interface ServiceView {
pinned_message_id: number | null;
}
export interface PinnedView {
media_kind: string | null;
message_id: number;
sender_name: string | null;
text: string | null;
}
export interface StickerView {
emoji: string | null;
height: number | null;
@@ -169,6 +176,7 @@ export interface MessageView {
poll: PollView | null;
quote: string | null;
reactions: ReactionCount[];
read: boolean;
reply: ReplyView | null;
sender_id: number | null;
service: ServiceView | null;
@@ -257,6 +265,20 @@ export interface PresenceHourly {
samples: number;
}
export interface VolumeBucket {
bucket: string;
incoming: number;
outgoing: number;
total: number;
}
export interface ResponseStats {
mine_count: number;
mine_median_seconds: number | null;
their_count: number;
their_median_seconds: number | null;
}
export interface PeerView {
first_name: string | null;
has_avatar: boolean;
@@ -323,6 +345,13 @@ export interface PolicyRecord extends CaptureToggles {
scope_type: PolicyScopeType;
}
export type PolicyChatKind = "dm" | "group" | "channel";
export interface PolicyCreate extends CaptureToggles {
scope_id?: number | null;
scope_type: PolicyScopeType;
}
export interface Folder {
bots: boolean;
broadcasts: boolean;
@@ -373,3 +402,32 @@ export interface JobView {
started_at: string | null;
status: JobStatus;
}
export interface LiveMessageEvent {
message: MessageView;
type: "message" | "edit" | "reaction";
}
export interface LiveDeleteEvent {
chat_id: number | null;
message_ids: number[];
type: "delete";
}
export interface LivePresenceEvent {
peer_id: number;
sample: PresenceSample | null;
type: "presence";
}
export interface LiveReceiptEvent {
chat_id: number;
read_up_to: number;
type: "receipt";
}
export type LiveEvent =
| LiveMessageEvent
| LiveDeleteEvent
| LivePresenceEvent
| LiveReceiptEvent;
@@ -0,0 +1,138 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
interface Props {
message: MessageView;
onjump: (messageId: number) => void;
}
let { message, onjump }: Props = $props();
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
function nameOf(id: number | null): string {
if (id === null) {
return "Someone";
}
if (id === ownId) {
return accounts.selected ? accountName(accounts.selected) : "You";
}
const peer = peers.get(id);
return peer ? peerName(peer) : String(id);
}
function durationOf(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
const actor = $derived(nameOf(message.sender_id));
const text = $derived.by(() => {
const service = message.service;
if (!service) {
return "";
}
const members = service.member_ids ?? [];
switch (service.kind) {
case "new_chat_members": {
const selfJoin =
members.length === 1 && members[0] === message.sender_id;
if (selfJoin) {
return `${actor} joined the group`;
}
const names = members.map(nameOf).join(", ");
return names ? `${actor} added ${names}` : `${actor} joined the group`;
}
case "left_chat_members":
return `${actor} left the group`;
case "pinned_message":
return `${actor} pinned a message`;
case "group_chat_created":
return `${actor} created the group`;
case "channel_chat_created":
return "Channel created";
case "phone_call_ended":
return service.duration === null
? "Call ended"
: `Call ended · ${durationOf(service.duration)}`;
case "phone_call_started":
return "Call started";
case "poll_option_added":
return `${actor} added a poll option`;
case "new_chat_title":
return `${actor} changed the group name`;
case "new_chat_photo":
return `${actor} changed the group photo`;
case "delete_chat_photo":
return `${actor} removed the group photo`;
default:
return service.kind.replace(/_/g, " ") || "Service message";
}
});
const pinned = $derived(
message.service?.kind === "pinned_message" &&
message.service.pinned_message_id !== null
);
</script>
<div class="ActionMessage">
{#if pinned}
<button
type="button"
class="pill action"
onclick={() => {
const id = message.service?.pinned_message_id;
if (id != null) {
onjump(id);
}
}}
>
<Icon name="pin" size="0.8125rem" />
<span>{text}</span>
</button>
{:else}
<span class="pill">{text}</span>
{/if}
</div>
<style lang="scss">
.ActionMessage {
display: flex;
justify-content: center;
margin: 0.5rem 0;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
max-width: min(30rem, 85%);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
text-align: center;
color: var(--color-white);
background-color: var(--color-default-shadow);
backdrop-filter: blur(8px);
}
button.pill {
cursor: pointer;
border: 0;
}
button.pill:hover {
background-color: var(--color-default-shadow-hover, var(--color-default-shadow));
}
</style>
+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>
+11 -2
View File
@@ -25,6 +25,8 @@
: chats.list.filter((chat) => folderContains(selectedFolder, chat))
);
const SCROLL_THRESHOLD = 600;
$effect(() => {
if (accounts.selectedId === null) {
return;
@@ -32,9 +34,16 @@
chats.load().catch(() => toasts.error("Failed to load chats"));
folders.load().catch(() => toasts.error("Failed to load folders"));
});
function onScroll(event: Event) {
const el = event.currentTarget as HTMLElement;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
chats.loadMore().catch(() => undefined);
}
}
</script>
<div class="chat-list custom-scroll">
<div class="chat-list custom-scroll" onscroll={onScroll}>
{#if chats.loading && chats.list.length === 0}
{#each skeletonRows as index (index)}
<div class="row-skeleton">
@@ -53,7 +62,7 @@
class="folder-view"
in:fly={{ x: folders.direction * 24, duration: 200, easing: cubicOut }}
>
{#if visibleChats.length === 0}
{#if visibleChats.length === 0 && !chats.hasMore}
<EmptyState
title="Empty folder"
description="No chats match this folder yet"
@@ -14,7 +14,10 @@
let { chat, selected, onclick }: Props = $props();
const title = $derived(chat.title ?? `Chat ${chat.chat_id}`);
const title = $derived(
chat.title ??
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`)
);
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived(
@@ -0,0 +1,45 @@
<script lang="ts">
import type { ContactView } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
interface Props {
contact: ContactView;
}
let { contact }: Props = $props();
const name = $derived(
[contact.first_name, contact.last_name].filter(Boolean).join(" ") ||
"Contact"
);
</script>
<div class="Contact">
<Avatar {name} colorKey={contact.user_id ?? 0} size={2.5} />
<div class="info">
<div class="name">{name}</div>
{#if contact.phone_number}
<div class="phone">{contact.phone_number}</div>
{/if}
</div>
</div>
<style lang="scss">
.Contact {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 12rem;
margin-bottom: 0.25rem;
}
.name {
font-weight: var(--font-weight-medium);
}
.phone {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
import {
type CustomEmojiAsset,
loadCustomEmoji,
} from "$lib/api/custom-emoji";
interface Props {
id: string;
size?: number;
}
let { id, size = 1.25 }: Props = $props();
let asset = $state<CustomEmojiAsset | null>(null);
$effect(() => {
asset = null;
let active = true;
loadCustomEmoji(id).then((resolved) => {
if (active) {
asset = resolved;
}
});
return () => {
active = false;
};
});
const isVideo = $derived(asset?.mime.startsWith("video/") ?? false);
const isImage = $derived(asset?.mime.startsWith("image/") ?? false);
</script>
<span class="CustomEmoji" style="--emoji-size: {size}rem;">
{#if asset && isVideo}
<video src={asset.url} autoplay loop muted playsinline></video>
{:else if asset && isImage}
<img src={asset.url} alt="emoji">
{:else}
<span class="placeholder">🧩</span>
{/if}
</span>
<style lang="scss">
.CustomEmoji {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--emoji-size);
height: var(--emoji-size);
vertical-align: middle;
}
img,
video {
width: 100%;
height: 100%;
object-fit: contain;
}
.placeholder {
font-size: calc(var(--emoji-size) * 0.85);
line-height: 1;
}
</style>
@@ -0,0 +1,111 @@
<script lang="ts">
import type { InlineButton } from "$lib/api/types";
interface Props {
rows: InlineButton[][];
}
let { rows }: Props = $props();
let copied = $state<string | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined;
async function copy(key: string, data: string): Promise<void> {
await navigator.clipboard.writeText(data);
copied = key;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
copied = null;
}, 1500);
}
</script>
<div class="InlineButtons">
{#each rows as row, rowIndex (rowIndex)}
<div class="row">
{#each row as button, colIndex (colIndex)}
{@const key = `${rowIndex}:${colIndex}`}
{#if button.kind === "url" && button.url}
<a class="button" href={button.url} target="_blank" rel="noopener">
<span class="label">{button.text}</span>
<span class="corner"></span>
</a>
{:else if button.kind === "callback" && button.data}
<button
class="button"
type="button"
onclick={() => copy(key, button.data ?? "")}
>
<span class="label"
>{copied === key ? "Скопировано" : button.text}</span
>
</button>
{:else}
<span class="button static">
<span class="label">{button.text}</span>
</span>
{/if}
{/each}
</div>
{/each}
</div>
<style lang="scss">
.InlineButtons {
display: flex;
flex-direction: column;
gap: 0.1875rem;
margin-top: 0.25rem;
}
.row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 0.1875rem;
}
.button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-height: 2rem;
padding: 0.25rem 0.5rem;
border: none;
border-radius: var(--border-radius-messages-small);
font-family: inherit;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
text-align: center;
text-decoration: none;
background-color: var(--color-message-reaction);
cursor: pointer;
transition: background-color 150ms;
&:hover:not(.static) {
background-color: var(--color-message-reaction-hover);
}
&.static {
cursor: default;
}
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.corner {
flex-shrink: 0;
font-size: 0.75rem;
opacity: 0.7;
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { LocationView } from "$lib/api/types";
interface Props {
location: LocationView;
}
let { location }: Props = $props();
const hasCoords = $derived(
location.latitude !== null && location.longitude !== null
);
const mapUrl = $derived(
hasCoords
? `https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`
: null
);
const title = $derived(location.title ?? "Location");
</script>
<svelte:element
this={mapUrl ? "a" : "div"}
class="Location"
href={mapUrl}
target={mapUrl ? "_blank" : undefined}
rel={mapUrl ? "noopener" : undefined}
>
<span class="pin">📍</span>
<div class="info">
<div class="title">{title}</div>
{#if location.address}
<div class="address">{location.address}</div>
{/if}
{#if hasCoords}
<div class="coords">{location.latitude}, {location.longitude}</div>
{/if}
</div>
</svelte:element>
<style lang="scss">
.Location {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 12rem;
margin-bottom: 0.25rem;
color: inherit;
text-decoration: none;
}
.pin {
font-size: 1.5rem;
line-height: 1;
}
.title {
font-weight: var(--font-weight-medium);
}
.address,
.coords {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -1,13 +1,19 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import Contact from "$lib/components/Contact.svelte";
import EntityText from "$lib/components/EntityText.svelte";
import ForwardHeader from "$lib/components/ForwardHeader.svelte";
import InlineButtons from "$lib/components/InlineButtons.svelte";
import Location from "$lib/components/Location.svelte";
import MediaAlbum from "$lib/components/MediaAlbum.svelte";
import MessageMedia from "$lib/components/MessageMedia.svelte";
import MessageMeta from "$lib/components/MessageMeta.svelte";
import Poll from "$lib/components/Poll.svelte";
import Reactions from "$lib/components/Reactions.svelte";
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import WebPage from "$lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
@@ -41,6 +47,11 @@
const deleted = $derived(message.deleted_at !== null);
const hasText = $derived(Boolean(message.text));
const special = $derived(
Boolean(
message.web_page || message.poll || message.contact || message.location
)
);
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const sender = $derived(
@@ -107,14 +118,25 @@
deleted
</span>
{/if}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{#if message.poll}
<Poll poll={message.poll} />
{/if}
{#if message.contact}
<Contact contact={message.contact} />
{/if}
{#if message.location}
<Location location={message.location} />
{/if}
{#if !special}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{/if}
{/if}
{#if hasText}
<div class="text">
@@ -124,10 +146,19 @@
{own}
/>
</div>
{:else if !(message.has_media || deleted)}
{:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div>
{/if}
<MessageMeta {message} {onversions} />
{#if message.web_page}
<WebPage {message} {own} {onmedia} />
{/if}
{#if message.reactions.length}
<Reactions reactions={message.reactions} {own} />
{/if}
<MessageMeta {message} {own} {onversions} />
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
+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 {
@@ -8,6 +8,7 @@
} from "$lib/api/media";
import type { MessageView } from "$lib/api/types";
import AudioFile from "$lib/components/media/AudioFile.svelte";
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
@@ -123,9 +124,7 @@
playsinline
></video>
{:else if ready && isTgsSticker}
<div class="media-sticker tgs">
<span class="tgs-emoji">{message.sticker?.emoji ?? "🎞"}</span>
</div>
<TgsSticker url={ready.url} />
{:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} autoplay loop muted playsinline></video>
@@ -215,23 +214,6 @@
object-fit: contain;
}
.tgs {
display: flex;
align-items: center;
justify-content: center;
width: 8rem;
height: 8rem;
border-radius: var(--border-radius-default-small);
background-color: var(--color-primary-tint);
}
.tgs-emoji {
font-size: 3.5rem;
line-height: 1;
}
.gif-badge {
position: absolute;
top: 0.375rem;
+10 -1
View File
@@ -1,13 +1,15 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import { formatTime } from "$lib/format/datetime";
interface Props {
message: MessageView;
onversions?: () => void;
own?: boolean;
}
let { message, onversions }: Props = $props();
let { message, onversions, own = false }: Props = $props();
</script>
<span class="MessageMeta">
@@ -22,6 +24,13 @@
</button>
{/if}
<span class="time">{formatTime(message.date)}</span>
{#if own}
<Icon
class="ticks"
name={message.read ? "message-read" : "check"}
size="1rem"
/>
{/if}
</span>
<style lang="scss">
@@ -0,0 +1,110 @@
<script lang="ts">
import { getPinned } from "$lib/api/endpoints";
import type { PinnedView } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "$lib/format/media";
import { accounts } from "$lib/stores/accounts.svelte";
interface Props {
chatId: number;
onjump: (messageId: number) => void;
}
let { chatId, onjump }: Props = $props();
let pinned = $state<PinnedView | null>(null);
const preview = $derived(
pinned
? (pinned.text ??
(pinned.media_kind
? mediaKindLabel(pinned.media_kind)
: "Pinned message"))
: ""
);
$effect(() => {
const chat = chatId;
const account = accounts.selectedId;
if (account === null) {
return;
}
pinned = null;
let active = true;
getPinned(chat)
.then((result) => {
if (active) {
pinned = result;
}
})
.catch(() => {
if (active) {
pinned = null;
}
});
return () => {
active = false;
};
});
</script>
{#if pinned}
<button
type="button"
class="PinnedBar"
onclick={() => pinned && onjump(pinned.message_id)}
>
<Icon name="pin" size="1.125rem" />
<span class="body">
<span class="label">Pinned message</span>
<span class="preview">{preview}</span>
</span>
</button>
{/if}
<style lang="scss">
.PinnedBar {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 1rem;
text-align: start;
color: var(--color-text);
background-color: var(--color-background);
border: 0;
border-bottom: 1px solid var(--color-borders);
cursor: pointer;
:global(.icon) {
flex-shrink: 0;
color: var(--color-primary);
}
&:hover {
background-color: var(--color-background-hover, var(--color-background));
}
}
.body {
display: flex;
flex-direction: column;
min-width: 0;
}
.label {
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.preview {
overflow: hidden;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import type { PollView } from "$lib/api/types";
interface Props {
poll: PollView;
}
let { poll }: Props = $props();
const kindLabel = $derived(poll.quiz ? "Quiz" : "Poll");
const visibility = $derived(poll.anonymous ? "Anonymous" : "Public");
</script>
<div class="Poll">
<div class="question">{poll.question}</div>
<div class="meta">
{kindLabel}
· {visibility}{poll.closed ? " · Closed" : ""}
</div>
{#each poll.options as option, index (index)}
<div class="option" class:correct={option.correct}>
<div class="head">
<span class="text">{option.text}</span>
<span class="pct">{option.vote_percentage}%</span>
</div>
<div class="bar">
<div class="fill" style="width: {option.vote_percentage}%;"></div>
</div>
</div>
{/each}
<div class="footer">{poll.total_voter_count} votes</div>
</div>
<style lang="scss">
.Poll {
min-width: 16rem;
margin-bottom: 0.25rem;
}
.question {
font-weight: var(--font-weight-medium);
}
.meta {
margin-bottom: 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.option {
margin-bottom: 0.375rem;
}
.head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.9375rem;
}
.pct {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bar {
overflow: hidden;
height: 0.25rem;
margin-top: 0.1875rem;
border-radius: 0.25rem;
background-color: var(--color-message-reaction);
}
.fill {
height: 100%;
border-radius: 0.25rem;
background-color: var(--color-primary);
}
.correct .fill {
background-color: var(--color-text-green);
}
.footer {
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { ReactionCount } from "$lib/api/types";
import CustomEmoji from "$lib/components/CustomEmoji.svelte";
interface Props {
own: boolean;
reactions: ReactionCount[];
}
let { reactions, own }: Props = $props();
</script>
<div class="Reactions" class:own>
{#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)}
<span class="reaction" class:chosen={reaction.chosen}>
{#if reaction.custom_emoji_id}
<CustomEmoji id={reaction.custom_emoji_id} size={1.25} />
{:else}
<span class="emoji">{reaction.emoji ?? "❓"}</span>
{/if}
<span class="count">{reaction.count}</span>
</span>
{/each}
</div>
<style lang="scss">
.Reactions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}
.reaction {
display: inline-flex;
align-items: center;
gap: 0.1875rem;
height: 1.625rem;
padding: 0 0.4375rem 0 0.375rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
color: var(--color-text);
background-color: var(--color-message-reaction);
.own & {
background-color: var(--color-message-reaction-own);
}
&.chosen {
color: var(--color-white);
background-color: var(--color-primary);
}
}
.emoji {
font-size: 1rem;
line-height: 1;
}
.count {
line-height: 1;
}
</style>
@@ -0,0 +1,71 @@
<script lang="ts">
import JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { type RightPanel, ui } from "$lib/stores/ui.svelte";
const titles: Record<RightPanel, string> = {
profile: "Профиль",
search: "Поиск",
versions: "Версии",
reactions: "Реакции",
links: "Ссылки",
annotations: "Заметки",
jobs: "Данные и хранилище",
presence: "Аналитика",
stories: "Сторис",
policy: "Политика захвата",
};
</script>
<header class="right-header">
<Button
variant="translucent"
round
smaller
onclick={() => ui.closePanel()}
aria-label="Закрыть"
>
<Icon name="close" />
</Button>
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
</header>
<div class="right-body custom-scroll">
{#if ui.rightPanel === "policy"}
<PolicyEditor />
{:else if ui.rightPanel === "jobs"}
<JobsPanel />
{:else if ui.rightPanel === "presence"}
<AnalyticsPanel />
{:else if ui.rightPanel === "search"}
<ChatSearchPanel />
{/if}
</div>
<style lang="scss">
.right-header {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.75rem;
height: var(--header-height);
padding: 0 0.625rem;
border-bottom: 1px solid var(--color-borders);
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
}
}
.right-body {
overflow-y: auto;
flex: 1;
}
</style>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import MessageMedia from "$lib/components/MessageMedia.svelte";
interface Props {
message: MessageView;
onmedia: (index: number) => void;
own: boolean;
}
let { message, own, onmedia }: Props = $props();
const web = $derived(message.web_page);
</script>
{#if web}
<div class="WebPage">
{#if web.has_photo}
<div class="photo">
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
</div>
{/if}
<a class="card" href={web.url} target="_blank" rel="noopener">
{#if web.site_name}
<div class="site">{web.site_name}</div>
{:else if web.display_url}
<div class="site">{web.display_url}</div>
{/if}
{#if web.title}
<div class="title">{web.title}</div>
{/if}
{#if web.description}
<div class="desc">{web.description}</div>
{/if}
</a>
</div>
{/if}
<style lang="scss">
.WebPage {
margin-top: 0.25rem;
padding-left: 0.5rem;
border-left: 3px solid var(--color-primary);
}
.photo {
margin-bottom: 0.25rem;
}
.card {
display: block;
color: inherit;
text-decoration: none;
}
.site {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.title {
font-weight: var(--font-weight-medium);
}
.desc {
display: -webkit-box;
overflow: hidden;
color: var(--color-text);
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
}
</style>
@@ -0,0 +1,205 @@
<script lang="ts">
import { listJobs } from "$lib/api/endpoints";
import type { JobStatus, JobView } from "$lib/api/types";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
interface Props {
version?: number;
}
let { version = 0 }: Props = $props();
const POLL_MS = 2000;
const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл",
fetch_media: "Докачка медиа",
fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи",
enrich_chat: "Обогащение чата",
transcribe: "Расшифровка",
sync_dialogs: "Синхронизация диалогов",
};
const STATUS_LABELS: Record<JobStatus, string> = {
pending: "В очереди",
running: "Выполняется",
done: "Готово",
failed: "Ошибка",
canceled: "Отменено",
paused: "Пауза",
};
let jobs = $state<JobView[]>([]);
let loading = $state(true);
let timer: ReturnType<typeof setTimeout> | null = null;
function isActive(list: JobView[]): boolean {
return list.some((j) => j.status === "pending" || j.status === "running");
}
function schedule() {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (isActive(jobs)) {
timer = setTimeout(() => {
load().catch(() => undefined);
}, POLL_MS);
}
}
async function load() {
jobs = await listJobs();
loading = false;
schedule();
}
function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? kind;
}
function processed(job: JobView): number | null {
const value = job.progress.processed;
return typeof value === "number" ? value : null;
}
function chatId(job: JobView): number | null {
const value = job.params.chat_id;
return typeof value === "number" ? value : null;
}
$effect(() => {
if (version >= 0) {
load().catch(() => {
loading = false;
});
}
return () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
});
</script>
{#if loading}
<div class="state"><Spinner /></div>
{:else if jobs.length === 0}
<div class="state empty">Задач нет</div>
{:else}
<div class="jobs">
{#each jobs as job (job.id)}
<div class="job">
<div class="job-head">
<span class="kind">{kindLabel(job.kind)}</span>
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span>
</div>
<div class="meta">
{#if chatId(job) !== null}
<span>чат {chatId(job)}</span>
{/if}
{#if processed(job) !== null}
<span>обработано {processed(job)}</span>
{/if}
{#if job.flood_waits > 0}
<span>flood-wait {job.flood_waits}</span>
{/if}
<span class="time">{formatFull(job.created_at)}</span>
</div>
{#if job.error}
<div class="error">{job.error}</div>
{/if}
</div>
{/each}
</div>
{/if}
<style lang="scss">
.state {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
&.empty {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
}
.jobs {
display: flex;
flex-direction: column;
}
.job {
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-borders);
}
.job-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind {
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
}
.badge {
flex-shrink: 0;
padding: 0.0625rem 0.5rem;
border-radius: 0.625rem;
font-size: 0.75rem;
color: var(--color-white);
background-color: var(--color-text-secondary);
&.pending {
background-color: var(--color-text-secondary);
}
&.running {
background-color: var(--color-primary);
}
&.done {
background-color: var(--color-green);
}
&.failed {
background-color: var(--color-error);
}
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.time {
margin-left: auto;
}
.error {
overflow: hidden;
margin-top: 0.25rem;
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-error);
}
</style>
@@ -0,0 +1,313 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { enqueueBackfill, syncDialogs } from "$lib/api/endpoints";
import JobList from "$lib/components/jobs/JobList.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
let version = $state(0);
let selected = $state<number | null>(
page.params.chatId ? Number(page.params.chatId) : null
);
let media = $state(true);
let picking = $state(false);
let filter = $state("");
let starting = $state(false);
let syncing = $state(false);
const availableChats = $derived(
chats.list
.filter((c) =>
(c.title ?? "").toLowerCase().includes(filter.trim().toLowerCase())
)
.slice(0, 40)
);
function chatTitle(id: number | null): string {
if (id === null) {
return "Чат не выбран";
}
return chats.byId(id)?.title ?? `Чат ${id}`;
}
function selectChat(id: number) {
selected = id;
picking = false;
filter = "";
}
async function start() {
if (selected === null || starting) {
return;
}
starting = true;
try {
await enqueueBackfill(selected, media);
toasts.success("Бэкфилл запущен");
version += 1;
} catch {
toasts.error("Не удалось запустить бэкфилл");
} finally {
starting = false;
}
}
async function sync() {
if (syncing) {
return;
}
syncing = true;
try {
await syncDialogs();
toasts.success("Синхронизация диалогов запущена");
version += 1;
} catch {
toasts.error("Не удалось синхронизировать диалоги");
} finally {
syncing = false;
}
}
onMount(() => {
chats.load();
});
</script>
<div class="panel">
<section>
<div class="section-title">Диалоги</div>
<p class="hint">
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
и доступны для бэкфилла.
</p>
<div class="action">
<Button variant="secondary" fluid loading={syncing} onclick={sync}>
<Icon name="reload" />
<span>Синхронизировать диалоги</span>
</Button>
</div>
</section>
<section>
<div class="section-title">Бэкфилл</div>
<button
type="button"
class="select"
onclick={() => {
picking = !picking;
}}
>
<Icon name="comments" size="1.25rem" />
<span class="select-label">{chatTitle(selected)}</span>
<Icon name="down" size="1.25rem" />
</button>
{#if picking}
<div class="picker">
<input
class="filter"
type="text"
placeholder="Поиск чата"
bind:value={filter}
>
{#each availableChats as chat (chat.chat_id)}
<button
type="button"
class="pick-row"
onclick={() => selectChat(chat.chat_id)}
>
{chat.title ?? `Чат ${chat.chat_id}`}
</button>
{/each}
</div>
{/if}
<button
type="button"
class="toggle-row"
role="switch"
aria-checked={media}
onclick={() => {
media = !media;
}}
>
<span class="label">Скачивать медиа</span>
<span class="switch" class:on={media}>
<span class="knob"></span>
</span>
</button>
<div class="action">
<Button
variant="primary"
fluid
disabled={selected === null}
loading={starting}
onclick={start}
>
<Icon name="cloud-download" />
<span>Запустить бэкфилл</span>
</Button>
</div>
</section>
<section>
<div class="section-title">Задачи</div>
<JobList {version} />
</section>
</div>
<style lang="scss">
section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-borders);
}
.section-title {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.select {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 1rem;
border: 0;
font-size: 0.9375rem;
color: var(--color-text);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.select-label {
overflow: hidden;
flex: 1;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker {
display: flex;
flex-direction: column;
padding: 0 0.5rem 0.5rem;
}
.filter {
margin: 0.25rem 0.5rem 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default);
font-size: 0.9375rem;
color: var(--color-text);
background-color: var(--color-background);
&:focus {
border-color: var(--color-primary);
outline: none;
}
}
.pick-row {
cursor: pointer;
overflow: hidden;
width: 100%;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 0.5rem;
font-size: 0.9375rem;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.5rem 1rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.label {
flex: 1;
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
.action {
padding: 0.5rem 1rem 0.75rem;
}
.hint {
margin: 0;
padding: 0 1rem 0.25rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,59 @@
<script lang="ts">
interface Props {
size?: number;
url: string;
}
let { url, size = 12 }: Props = $props();
let container = $state<HTMLDivElement | null>(null);
$effect(() => {
const target = container;
if (!target) {
return;
}
let active = true;
let anim: { destroy: () => void } | null = null;
(async () => {
try {
const [lottieModule, pako] = await Promise.all([
import("lottie-web"),
import("pako"),
]);
const response = await fetch(url);
const bytes = new Uint8Array(await response.arrayBuffer());
const json = JSON.parse(pako.inflate(bytes, { to: "string" }));
if (!active) {
return;
}
anim = lottieModule.default.loadAnimation({
container: target,
renderer: "svg",
loop: true,
autoplay: true,
animationData: json,
});
} catch {
// tgs decode/render failed — leave empty
}
})();
return () => {
active = false;
anim?.destroy();
};
});
</script>
<div
class="TgsSticker"
bind:this={container}
style="--tgs-size: {size}rem;"
></div>
<style lang="scss">
.TgsSticker {
width: var(--tgs-size);
height: var(--tgs-size);
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { CaptureToggles } from "$lib/api/types";
interface Props {
disabled?: boolean;
onchange: (key: keyof CaptureToggles, value: boolean) => void;
toggles: CaptureToggles;
}
let { toggles, disabled = false, onchange }: Props = $props();
const rows: { key: keyof CaptureToggles; label: string }[] = [
{ key: "messages", label: "Сообщения" },
{ key: "media", label: "Медиа" },
{ key: "self_destruct_media", label: "Самоуничтожающиеся" },
{ key: "stt", label: "Расшифровка голоса" },
{ key: "reactions", label: "Реакции" },
{ key: "track_edits_deletes", label: "Правки и удаления" },
{ key: "profile_history", label: "История профиля" },
{ key: "stories", label: "Сторис" },
{ key: "presence", label: "Presence" },
{ key: "backfill", label: "Бэкфилл" },
];
</script>
<div class="toggles">
{#each rows as row (row.key)}
<button
type="button"
class="toggle-row"
role="switch"
aria-checked={toggles[row.key]}
{disabled}
onclick={() => onchange(row.key, !toggles[row.key])}
>
<span class="label">{row.label}</span>
<span class="switch" class:on={toggles[row.key]}>
<span class="knob"></span>
</span>
</button>
{/each}
</div>
<style lang="scss">
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.5rem 1rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--color-chat-hover);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
}
.label {
flex: 1;
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
</style>
@@ -0,0 +1,381 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
listFolders,
listPolicies,
updatePolicy,
} from "$lib/api/endpoints";
import type {
CaptureToggles,
Folder,
PolicyRecord,
PolicyScopeType,
} from "$lib/api/types";
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
let policies = $state<PolicyRecord[]>([]);
let folders = $state<Folder[]>([]);
let loading = $state(true);
let pickFolder = $state(false);
let pickChat = $state(false);
let chatFilter = $state("");
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
{ scope: "default_dm", label: "Личные чаты" },
{ scope: "default_group", label: "Группы" },
{ scope: "default_channel", label: "Каналы" },
];
const ALL_FALSE: CaptureToggles = {
messages: false,
media: false,
self_destruct_media: false,
stt: false,
reactions: false,
track_edits_deletes: false,
profile_history: false,
stories: false,
presence: false,
backfill: false,
};
function pickToggles(t: CaptureToggles): CaptureToggles {
return {
messages: t.messages,
media: t.media,
self_destruct_media: t.self_destruct_media,
stt: t.stt,
reactions: t.reactions,
track_edits_deletes: t.track_edits_deletes,
profile_history: t.profile_history,
stories: t.stories,
presence: t.presence,
backfill: t.backfill,
};
}
const defaults = $derived(
DEFAULT_SCOPES.map((d) => ({
label: d.label,
record: policies.find((p) => p.scope_type === d.scope),
})).filter((d): d is { label: string; record: PolicyRecord } =>
Boolean(d.record)
)
);
const folderPolicies = $derived(
policies.filter((p) => p.scope_type === "folder")
);
const chatPolicies = $derived(
policies.filter((p) => p.scope_type === "chat")
);
const availableFolders = $derived(
folders.filter(
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
)
);
const availableChats = $derived(
chats.list
.filter((c) => !chatPolicies.some((p) => p.scope_id === c.chat_id))
.filter((c) =>
(c.title ?? "").toLowerCase().includes(chatFilter.trim().toLowerCase())
)
.slice(0, 40)
);
function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
}
function chatTitle(id: number | null): string {
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
}
async function reload() {
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
}
async function toggle(
record: PolicyRecord,
key: keyof CaptureToggles,
value: boolean
) {
const next = { ...pickToggles(record), [key]: value };
policies = policies.map((p) =>
p.id === record.id ? { ...p, [key]: value } : p
);
try {
await updatePolicy(record.id, next);
} catch {
toasts.error("Не удалось сохранить политику");
await reload();
}
}
async function addOverride(scope: "folder" | "chat", scopeId: number) {
try {
const record = await createPolicy({
...ALL_FALSE,
scope_type: scope,
scope_id: scopeId,
});
policies = [...policies, record];
pickFolder = false;
pickChat = false;
chatFilter = "";
} catch {
toasts.error("Не удалось создать оверрайд");
}
}
async function removeOverride(record: PolicyRecord) {
policies = policies.filter((p) => p.id !== record.id);
try {
await deletePolicy(record.id);
} catch {
toasts.error("Не удалось удалить оверрайд");
await reload();
}
}
onMount(async () => {
chats.load();
try {
await reload();
} catch {
toasts.error("Не удалось загрузить политики");
} finally {
loading = false;
}
});
</script>
{#snippet card(record: PolicyRecord, title: string, onremove?: () => void)}
<div class="card">
<div class="card-head">
<span class="card-title">{title}</span>
{#if onremove}
<button
type="button"
class="remove"
aria-label="Удалить оверрайд"
onclick={onremove}
>
<Icon name="close" size="1.125rem" />
</button>
{/if}
</div>
<CaptureToggleList
toggles={record}
onchange={(key, value) => toggle(record, key, value)}
/>
</div>
{/snippet}
{#if loading}
<div class="state"><Spinner /></div>
{:else}
<div class="editor">
<section>
<div class="section-title">По умолчанию</div>
{#each defaults as item (item.record.id)}
{@render card(item.record, item.label)}
{/each}
</section>
<section>
<div class="section-title">Оверрайды папок</div>
{#each folderPolicies as record (record.id)}
{@render card(record, folderTitle(record.scope_id), () =>
removeOverride(record)
)}
{/each}
{#if availableFolders.length > 0}
<button
type="button"
class="add"
onclick={() => {
pickFolder = !pickFolder;
}}
>
<Icon name="add" size="1.25rem" />
<span>Добавить папку</span>
</button>
{#if pickFolder}
<div class="picker">
{#each availableFolders as folder (folder.folder_id)}
<button
type="button"
class="pick-row"
onclick={() => addOverride("folder", folder.folder_id)}
>
{folder.title}
</button>
{/each}
</div>
{/if}
{/if}
</section>
<section>
<div class="section-title">Оверрайды чатов</div>
{#each chatPolicies as record (record.id)}
{@render card(record, chatTitle(record.scope_id), () =>
removeOverride(record)
)}
{/each}
<button
type="button"
class="add"
onclick={() => {
pickChat = !pickChat;
}}
>
<Icon name="add" size="1.25rem" />
<span>Добавить чат</span>
</button>
{#if pickChat}
<div class="picker">
<input
class="filter"
type="text"
placeholder="Поиск чата"
bind:value={chatFilter}
>
{#each availableChats as chat (chat.chat_id)}
<button
type="button"
class="pick-row"
onclick={() => addOverride("chat", chat.chat_id)}
>
{chat.title ?? `Чат ${chat.chat_id}`}
</button>
{/each}
</div>
{/if}
</section>
</div>
{/if}
<style lang="scss">
.state {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.editor {
padding-bottom: 1rem;
}
section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-borders);
}
.section-title {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.card {
padding: 0.25rem 0 0.5rem;
}
.card-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
}
.card-title {
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
}
.remove {
cursor: pointer;
display: flex;
padding: 0.25rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.add {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.625rem 1rem;
border: 0;
font-size: 0.9375rem;
color: var(--color-primary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.picker {
display: flex;
flex-direction: column;
padding: 0 0.5rem 0.5rem;
}
.filter {
margin: 0.25rem 0.5rem 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default);
font-size: 0.9375rem;
color: var(--color-text);
background-color: var(--color-background);
&:focus {
border-color: var(--color-primary);
outline: none;
}
}
.pick-row {
cursor: pointer;
overflow: hidden;
width: 100%;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: 0.5rem;
font-size: 0.9375rem;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
</style>
@@ -0,0 +1,294 @@
<script lang="ts">
import { page } from "$app/state";
import {
getCurrentPresence,
getMessageVolume,
getPresenceHistory,
getPresenceHourly,
getResponseStats,
} from "$lib/api/endpoints";
import type {
PresenceHourly,
PresenceSample,
ResponseStats,
VolumeBucket,
} from "$lib/api/types";
import PresenceHeatmap from "$lib/components/presence/PresenceHeatmap.svelte";
import VolumeChart from "$lib/components/presence/VolumeChart.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import { formatFull } from "$lib/format/datetime";
import { formatPresence } from "$lib/format/presence";
import { accounts } from "$lib/stores/accounts.svelte";
import { events } from "$lib/stores/events.svelte";
const HISTORY_LIMIT = 200;
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const isDm = $derived(chatId !== null && chatId > 0);
let current = $state<PresenceSample | null>(null);
let hourly = $state<PresenceHourly[]>([]);
let history = $state<PresenceSample[]>([]);
let volume = $state<VolumeBucket[]>([]);
let response = $state<ResponseStats | null>(null);
let loading = $state(false);
const changes = $derived.by(() => {
const result: PresenceSample[] = [];
let last: string | null = null;
for (const sample of history) {
if (sample.status !== last) {
result.push(sample);
last = sample.status;
}
}
return result;
});
function formatDuration(seconds: number | null): string {
if (seconds === null) {
return "—";
}
if (seconds < SECONDS_IN_MINUTE) {
return `${Math.round(seconds)} с`;
}
if (seconds < SECONDS_IN_HOUR) {
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
}
if (seconds < SECONDS_IN_DAY) {
return `${Math.round(seconds / SECONDS_IN_HOUR)} ч`;
}
return `${Math.round(seconds / SECONDS_IN_DAY)} д`;
}
$effect(() => {
if (accounts.selectedId === null || chatId === null) {
return;
}
const id = chatId;
let active = true;
loading = true;
current = null;
hourly = [];
history = [];
volume = [];
response = null;
Promise.all([getMessageVolume(id), getResponseStats(id)])
.then(([vol, resp]) => {
if (active) {
volume = vol;
response = resp;
}
})
.catch(() => undefined)
.finally(() => {
if (active) {
loading = false;
}
});
if (id > 0) {
Promise.all([
getCurrentPresence(id),
getPresenceHourly(id),
getPresenceHistory(id, { limit: HISTORY_LIMIT }),
])
.then(([cur, hrs, hist]) => {
if (active) {
current = cur;
hourly = hrs;
history = hist;
}
})
.catch(() => undefined);
}
const unsub = events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === id && event.sample) {
current = event.sample;
}
});
return () => {
active = false;
unsub();
};
});
</script>
{#if chatId === null}
<EmptyState title="Аналитика" description="Откройте чат" />
{:else}
<div class="analytics">
<section>
<h3>Объём сообщений (90 дней)</h3>
{#if volume.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
{:else}
<VolumeChart {volume} />
{/if}
</section>
<section>
<h3>Время ответа</h3>
{#if response}
<div class="stats">
<div class="stat">
<span class="value"
>{formatDuration(response.mine_median_seconds)}</span
>
<span class="caption">я отвечаю ({response.mine_count})</span>
</div>
<div class="stat">
<span class="value"
>{formatDuration(response.their_median_seconds)}</span
>
<span class="caption">мне отвечают ({response.their_count})</span>
</div>
</div>
{:else}
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
{/if}
</section>
{#if isDm}
<section>
<div class="status" class:online={current?.status === "online"}>
{current ? formatPresence(current) : "нет данных"}
</div>
</section>
<section>
<h3>Когда в сети</h3>
{#if hourly.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
{:else}
<PresenceHeatmap {hourly} />
{/if}
</section>
<section>
<h3>История статусов</h3>
{#if changes.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет записей"}</p>
{:else}
<ul class="changes">
{#each changes as sample (sample.ts)}
<li>
<span
class="dot"
class:online={sample.status === "online"}
></span>
<span class="label">{formatPresence(sample)}</span>
<span class="time">{formatFull(sample.ts)}</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
{/if}
<style lang="scss">
.analytics {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stats {
display: flex;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.value {
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.caption {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.status {
font-size: 0.9375rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
.muted {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.changes {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.changes li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.dot {
width: 0.5rem;
height: 0.5rem;
flex-shrink: 0;
border-radius: 50%;
background-color: var(--color-text-secondary);
&.online {
background-color: var(--color-primary);
}
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex-shrink: 0;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { PresenceHourly } from "$lib/api/types";
interface Props {
hourly: PresenceHourly[];
}
const { hourly }: Props = $props();
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
const HOUR_TICKS = new Set([0, 6, 12, 18]);
interface Cell {
online: number;
total: number;
}
const grid = $derived.by(() => {
const cells: Cell[][] = DAYS.map(() =>
HOURS.map(() => ({ online: 0, total: 0 }))
);
for (const bucket of hourly) {
const date = new Date(bucket.bucket);
const day = (date.getDay() + 6) % 7;
const cell = cells[day]?.[date.getHours()];
if (cell) {
cell.online += bucket.online_samples;
cell.total += bucket.samples;
}
}
return cells;
});
function intensity(cell: Cell): number {
return cell.total > 0 ? cell.online / cell.total : 0;
}
function cellTitle(day: number, hour: number, cell: Cell): string {
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
}
</script>
<div class="heatmap">
{#each grid as row, day (day)}
<div class="heat-row">
<span class="day-label">{DAYS[day]}</span>
<div class="cells">
{#each row as cell, hour (hour)}
<span
class="cell"
class:empty={cell.total === 0}
style:--i={intensity(cell)}
title={cellTitle(day, hour, cell)}
></span>
{/each}
</div>
</div>
{/each}
<div class="heat-row axis">
<span class="day-label"></span>
<div class="cells">
{#each HOURS as hour (hour)}
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
{/each}
</div>
</div>
</div>
<style lang="scss">
.heatmap {
display: flex;
flex-direction: column;
gap: 3px;
}
.heat-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.day-label {
width: 1.5rem;
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--color-text-secondary);
text-align: right;
}
.cells {
display: grid;
flex: 1;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.cell {
aspect-ratio: 1;
border-radius: 2px;
background-color: var(--color-primary);
opacity: calc(0.12 + 0.88 * var(--i));
&.empty {
background-color: var(--color-text-secondary);
opacity: 0.08;
}
}
.tick {
font-size: 0.625rem;
color: var(--color-text-secondary);
text-align: left;
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { VolumeBucket } from "$lib/api/types";
interface Props {
volume: VolumeBucket[];
}
const { volume }: Props = $props();
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
function barTitle(bucket: VolumeBucket): string {
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
}
</script>
<div class="volume">
<div class="bars">
{#each volume as bucket (bucket.bucket)}
<div
class="bar"
style:height={`${(bucket.total / max) * 100}%`}
title={barTitle(bucket)}
>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
</div>
{/each}
</div>
<div class="legend">
<span class="key"><span class="swatch out"></span>исходящие</span>
<span class="key"><span class="swatch in"></span>входящие</span>
</div>
</div>
<style lang="scss">
.bars {
display: flex;
align-items: flex-end;
gap: 1px;
height: 7.5rem;
}
.bar {
display: flex;
flex: 1;
flex-direction: column;
min-height: 2px;
overflow: hidden;
border-radius: 2px 2px 0 0;
}
.seg.out {
background-color: var(--color-primary);
}
.seg.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
}
.legend {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.key {
display: flex;
align-items: center;
gap: 0.375rem;
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.out {
background-color: var(--color-primary);
}
&.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
}
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { searchMessages } from "$lib/api/endpoints";
import type { SearchHit } from "$lib/api/types";
import SearchMessageItem from "$lib/components/search/SearchMessageItem.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 { ui } from "$lib/stores/ui.svelte";
const DEBOUNCE_MS = 250;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
let query = $state("");
let hits = $state<SearchHit[]>([]);
let loading = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
async function run(value: string, id: number) {
const current = ++seq;
loading = true;
try {
const result = await searchMessages(value, { chat_id: id });
if (current === seq) {
hits = result;
}
} catch {
if (current === seq) {
hits = [];
}
} finally {
if (current === seq) {
loading = false;
}
}
}
function onInput(event: Event) {
query = (event.currentTarget as HTMLInputElement).value;
const value = query.trim();
if (timer) {
clearTimeout(timer);
}
if (value.length === 0 || chatId === null) {
seq++;
hits = [];
loading = false;
return;
}
const id = chatId;
loading = true;
timer = setTimeout(() => {
run(value, id).catch(() => undefined);
}, DEBOUNCE_MS);
}
function openHit(messageId: number) {
if (chatId !== null) {
ui.requestJump(chatId, messageId);
goto(`/app/${chatId}`);
}
}
</script>
<div class="chat-search">
<div class="search-input">
<Icon name="search" size="1.25rem" class="chat-search-icon" />
<input
type="text"
placeholder="Поиск в чате"
value={query}
oninput={onInput}
>
</div>
<div class="results custom-scroll">
{#if loading && hits.length === 0}
<div class="loading"><Spinner /></div>
{:else if hits.length > 0}
{#each hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem {hit} onclick={() => openHit(hit.message_id)} />
{/each}
{:else if query.trim()}
<EmptyState title="Ничего не найдено" />
{/if}
</div>
</div>
<style lang="scss">
.chat-search {
display: flex;
flex-direction: column;
height: 100%;
}
.search-input {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
margin: 0.625rem;
padding: 0 0.5rem 0 0.75rem;
border-radius: 1.25rem;
background-color: var(--color-background-secondary);
&:focus-within {
background-color: var(--color-background);
box-shadow: inset 0 0 0 1.5px var(--color-primary);
}
}
:global(.chat-search .chat-search-icon) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
input {
flex: 1;
min-width: 0;
border: 0;
font-size: 0.9375rem;
color: var(--color-text);
background: transparent;
&::placeholder {
color: var(--color-text-secondary);
}
&:focus {
outline: none;
}
}
.results {
overflow-y: auto;
flex: 1;
padding: 0.25rem 0.4375rem;
}
.loading {
display: flex;
justify-content: center;
padding: 2rem 0;
}
</style>
@@ -0,0 +1,95 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { search } from "$lib/stores/search.svelte";
let input = $state<HTMLInputElement | null>(null);
function onInput(event: Event) {
search.setQuery((event.currentTarget as HTMLInputElement).value);
}
function clear() {
search.setQuery("");
input?.focus();
}
</script>
<div class="search-input">
<Icon name="search" size="1.25rem" class="search-icon" />
<input
bind:this={input}
type="text"
placeholder="Поиск"
value={search.query}
oninput={onInput}
onfocus={() => search.open()}
>
{#if search.query}
<button type="button" class="clear" onclick={clear} aria-label="Очистить">
<Icon name="close" size="1.125rem" />
</button>
{/if}
</div>
<style lang="scss">
.search-input {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 0.5rem 0 0.75rem;
border-radius: 1.25rem;
background-color: var(--color-background-secondary);
transition: background-color 0.15s ease;
&:focus-within {
background-color: var(--color-background);
box-shadow: inset 0 0 0 1.5px var(--color-primary);
}
}
:global(.search-input .search-icon) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
input {
flex: 1;
min-width: 0;
border: 0;
font-size: 0.9375rem;
color: var(--color-text);
background: transparent;
&::placeholder {
color: var(--color-text-secondary);
}
&:focus {
outline: none;
}
}
.clear {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: 0;
border-radius: 50%;
color: var(--color-icon-secondary);
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
</style>
@@ -0,0 +1,150 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import type { SearchHit } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import { formatListDate } from "$lib/format/datetime";
import { peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { peers } from "$lib/stores/peers.svelte";
interface Props {
hit: SearchHit;
onclick: () => void;
}
let { hit, onclick }: Props = $props();
const chat = $derived(chats.byId(hit.chat_id));
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
$effect(() => {
const ids: number[] = [];
if (hit.chat_id > 0) {
ids.push(hit.chat_id);
}
if (hit.sender_id !== null) {
ids.push(hit.sender_id);
}
if (ids.length > 0) {
peers.ensure(ids);
}
});
const title = $derived.by(() => {
if (chat?.title) {
return chat.title;
}
if (hit.chat_id > 0) {
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`;
}
return `Chat ${hit.chat_id}`;
});
const sender = $derived.by(() => {
if (hit.sender_id === null) {
return "";
}
if (hit.sender_id === ownId) {
return "Вы";
}
return peerName(peers.get(hit.sender_id) ?? null);
});
const snippet = $derived(hit.text ?? hit.extracted_text ?? "");
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
</script>
<button type="button" class="hit" use:ripple {onclick}>
<Avatar
name={title}
colorKey={hit.chat_id}
avatar={{ kind: avatarKind, id: hit.chat_id }}
hasAvatar={chat?.has_avatar ?? false}
size={3}
/>
<div class="body">
<div class="top">
<span class="title">{title}</span>
<span class="date">{formatListDate(hit.date)}</span>
</div>
<div class="snippet">
{#if sender}
<span class="sender">{sender}: </span>
{/if}
{snippet}
</div>
</div>
</button>
<style lang="scss">
.hit {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
&:hover {
background-color: var(--color-chat-hover);
}
}
.body {
display: flex;
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.1875rem;
}
.top {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.title {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.snippet {
overflow: hidden;
display: -webkit-box;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.sender {
color: var(--color-text);
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import ChatListItem from "$lib/components/ChatListItem.svelte";
import SearchMessageItem from "$lib/components/search/SearchMessageItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { search } from "$lib/stores/search.svelte";
import { ui } from "$lib/stores/ui.svelte";
const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const hasChats = $derived(search.chatHits.length > 0);
const hasMessages = $derived(search.messageHits.length > 0);
const empty = $derived(!(search.loading || hasChats || hasMessages));
function openChat(chatId: number) {
search.close();
goto(`/app/${chatId}`);
}
function openHit(chatId: number, messageId: number) {
ui.requestJump(chatId, messageId);
search.close();
goto(`/app/${chatId}`);
}
</script>
<div class="search-results custom-scroll">
{#if hasChats}
<div class="section-label">Чаты</div>
{#each search.chatHits as chat (chat.chat_id)}
<ChatListItem
{chat}
selected={chat.chat_id === activeChatId}
onclick={() => openChat(chat.chat_id)}
/>
{/each}
{/if}
{#if search.loading && !hasMessages}
<div class="loading"><Spinner /></div>
{:else if hasMessages}
<div class="section-label">Сообщения</div>
{#each search.messageHits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem
{hit}
onclick={() => openHit(hit.chat_id, hit.message_id)}
/>
{/each}
{/if}
{#if empty}
<EmptyState title="Ничего не найдено" />
{/if}
</div>
<style lang="scss">
.search-results {
overflow-y: auto;
flex: 1;
padding: 0.25rem 0.4375rem;
}
.section-label {
padding: 0.625rem 0.75rem 0.375rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.loading {
display: flex;
justify-content: center;
padding: 2rem 0;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
const appVersion = "0.0.1";
</script>
<div class="about">
<div class="title">Beavergram</div>
<div class="line">Версия {appVersion}</div>
<div class="line">Лицензия GPL-3.0</div>
<a
class="line link"
href="https://github.com/Ajaxy/telegram-tt"
target="_blank"
rel="noopener"
>
UI портирован из Telegram A
</a>
</div>
<style lang="scss">
.about {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 1.5rem 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.title {
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.link {
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import { theme } from "$lib/stores/theme.svelte";
const options = [
{ value: "light", label: "Светлая" },
{ value: "dark", label: "Тёмная" },
{ value: "system", label: "Системная" },
] as const;
</script>
<div class="appearance">
{#each options as option (option.value)}
<button
type="button"
class="theme-row"
use:ripple
onclick={() => theme.set(option.value)}
>
<span class="dot" class:on={theme.preference === option.value}></span>
<span class="label">{option.label}</span>
</button>
{/each}
</div>
<style lang="scss">
.theme-row {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 1.5rem;
width: 100%;
min-height: 3.25rem;
padding: 0 1.25rem;
border: 0;
font-size: 1rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-chat-hover);
}
}
.dot {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--color-borders);
border-radius: 50%;
transition: border-color 0.15s ease;
&.on {
border-width: 0.4375rem;
border-color: var(--color-primary);
}
}
.label {
flex: 1;
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
const current = $derived(accounts.selected);
const others = $derived(
accounts.list.filter(
(account) => account.account_id !== accounts.selectedId
)
);
</script>
<div class="my-account">
{#if current}
<div class="profile">
<Avatar
name={accountName(current)}
colorKey={current.account_id}
size={5}
/>
<div class="name">{accountName(current)}</div>
{#if current.phone}
<div class="phone">+{current.phone}</div>
{/if}
</div>
{/if}
{#if others.length > 0}
<div class="switch">
{#each others as account (account.account_id)}
<button
type="button"
class="switch-row"
use:ripple
onclick={() => accounts.select(account.account_id)}
>
<Avatar
name={accountName(account)}
colorKey={account.account_id}
size={2.25}
/>
<span>{accountName(account)}</span>
<Icon name="arrow-right" size="1rem" class="chevron" />
</button>
{/each}
</div>
{/if}
</div>
<style lang="scss">
.profile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
}
.name {
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.phone {
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
.switch-row {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
min-height: 3.5rem;
padding: 0 1.25rem;
border: 0;
font-size: 1rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-chat-hover);
}
span {
flex: 1;
}
}
:global(.switch-row .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
</style>
@@ -0,0 +1,102 @@
<script lang="ts">
import About from "$lib/components/settings/About.svelte";
import Appearance from "$lib/components/settings/Appearance.svelte";
import MyAccount from "$lib/components/settings/MyAccount.svelte";
import SettingsItem from "$lib/components/settings/SettingsItem.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { ui } from "$lib/stores/ui.svelte";
const features = [
{ icon: "search", label: "Поиск" },
{ icon: "folder", label: "Папки" },
{ icon: "stats", label: "Presence и аналитика" },
{ icon: "unmute", label: "Алерты" },
];
</script>
<div class="settings">
<header class="settings-header">
<Button
variant="translucent"
round
smaller
onclick={() => ui.closeSettings()}
aria-label="Назад"
>
<Icon name="arrow-left" />
</Button>
<h2>Настройки</h2>
</header>
<div class="settings-body custom-scroll">
<MyAccount />
<div class="section">
<div class="section-title">Внешний вид</div>
<Appearance />
</div>
<div class="section">
<SettingsItem
icon="settings"
label="Политика захвата"
onclick={() => ui.openPanel("policy")}
/>
<SettingsItem
icon="data"
label="Данные и хранилище"
onclick={() => ui.openPanel("jobs")}
/>
{#each features as feature (feature.label)}
<SettingsItem icon={feature.icon} label={feature.label} soon />
{/each}
</div>
<About />
</div>
</div>
<style lang="scss">
.settings {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.settings-header {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.75rem;
height: var(--header-height);
padding: 0 0.625rem;
border-bottom: 1px solid var(--color-borders);
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
}
}
.settings-body {
overflow-y: auto;
flex: 1;
}
.section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-borders);
}
.section-title {
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import Icon from "$lib/components/ui/Icon.svelte";
interface Props {
icon: string;
label: string;
onclick?: () => void;
soon?: boolean;
value?: string;
}
let { icon, label, value, soon = false, onclick }: Props = $props();
</script>
<button
type="button"
class="settings-item"
class:soon
use:ripple
disabled={soon}
{onclick}
>
<Icon name={icon} size="1.5rem" class="leading" />
<span class="label">{label}</span>
{#if soon}
<span class="soon-tag">скоро</span>
{:else if value}
<span class="value">{value}</span>
{/if}
{#if onclick && !soon}
<Icon name="arrow-right" size="1rem" class="chevron" />
{/if}
</button>
<style lang="scss">
.settings-item {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 1.5rem;
width: 100%;
min-height: 3.5rem;
padding: 0 1.25rem;
border: 0;
font-size: 1rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--color-chat-hover);
}
&:disabled {
cursor: default;
}
}
:global(.settings-item .leading) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
.label {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.value {
color: var(--color-text-secondary);
}
.soon-tag {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
:global(.settings-item .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
</style>
+2 -2
View File
@@ -11,7 +11,7 @@ export function peerName(peer: PeerView | null): string {
return "";
}
if (peer.is_deleted_account) {
return "Deleted Account";
return "Удалённый аккаунт";
}
const parts = [peer.first_name, peer.last_name].filter(Boolean);
if (parts.length > 0) {
@@ -20,7 +20,7 @@ export function peerName(peer: PeerView | null): string {
if (peer.username) {
return `@${peer.username}`;
}
return String(peer.peer_id);
return "Удалённый аккаунт";
}
export function accountName(account: Account): string {
+21
View File
@@ -0,0 +1,21 @@
import type { PresenceSample } from "$lib/api/types";
import { formatListDate } from "$lib/format/datetime";
export function formatPresence(sample: PresenceSample): string {
switch (sample.status) {
case "online":
return "online";
case "recently":
return "last seen recently";
case "last_week":
return "last seen within a week";
case "last_month":
return "last seen within a month";
case "long_time_ago":
return "last seen a long time ago";
default:
return sample.last_online_date
? `last seen ${formatListDate(sample.last_online_date)}`
: "offline";
}
}
+77 -2
View File
@@ -1,17 +1,21 @@
import { enrichChat, getJob, listChats } from "$lib/api/endpoints";
import type { Chat } from "$lib/api/types";
import type { Chat, LiveEvent } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
import { events } from "$lib/stores/events.svelte";
import { peers } from "$lib/stores/peers.svelte";
const POLL_INTERVAL = 1500;
const POLL_MAX = 12;
const PAGE_SIZE = 200;
function createChats() {
let list = $state<Chat[]>([]);
let loaded = $state(false);
let loading = $state(false);
let hasMore = $state(false);
let revision = $state(0);
let account: number | null = null;
let filling = false;
const enriched = new Set<number>();
function syncAccount() {
@@ -30,11 +34,71 @@ function createChats() {
}
loading = true;
try {
list = await listChats({ limit: 200 });
const page = await listChats({ limit: PAGE_SIZE });
list = page;
hasMore = page.length === PAGE_SIZE;
loaded = true;
} finally {
loading = false;
}
loadAll();
}
async function loadAll() {
if (filling) {
return;
}
filling = true;
try {
while (hasMore) {
if (loading) {
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
continue;
}
const before = list.length;
await loadMore();
if (list.length === before) {
break;
}
}
} finally {
filling = false;
}
}
async function loadMore() {
syncAccount();
if (account === null || loading || !loaded || !hasMore) {
return;
}
loading = true;
try {
const page = await listChats({ limit: PAGE_SIZE, offset: list.length });
const seen = new Set(list.map((chat) => chat.chat_id));
list = [...list, ...page.filter((chat) => !seen.has(chat.chat_id))];
hasMore = page.length === PAGE_SIZE;
} finally {
loading = false;
}
}
function applyEvent(event: LiveEvent) {
if (event.type !== "message" || !loaded) {
return;
}
const message = event.message;
const existing = list.find((chat) => chat.chat_id === message.chat_id);
if (!existing) {
load(true);
return;
}
existing.last_date = message.date;
existing.last_sender_id = message.sender_id;
existing.last_text = message.text;
existing.message_count++;
list = [existing, ...list.filter((chat) => chat !== existing)];
}
async function waitForJob(jobId: number) {
@@ -49,6 +113,13 @@ function createChats() {
}
}
events.subscribe(applyEvent);
events.onReconnect(() => {
if (loaded) {
load(true);
}
});
return {
get list(): Chat[] {
return list;
@@ -59,9 +130,13 @@ function createChats() {
get loading(): boolean {
return loading;
},
get hasMore(): boolean {
return hasMore;
},
get revision(): number {
return revision;
},
loadMore,
byId(id: number): Chat | undefined {
return list.find((chat) => chat.chat_id === id);
},
+128
View File
@@ -0,0 +1,128 @@
import type { LiveEvent } from "$lib/api/types";
import { auth } from "$lib/stores/auth.svelte";
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
const RECONNECT_DELAY = 2000;
type Listener = (event: LiveEvent) => void;
function parseFrame(block: string): LiveEvent | null {
for (const line of block.split("\n")) {
if (line.startsWith("data:")) {
try {
return JSON.parse(line.slice(5).trim()) as LiveEvent;
} catch {
return null;
}
}
}
return null;
}
function createEvents() {
const listeners = new Set<Listener>();
const reconnectListeners = new Set<() => void>();
let epoch = $state(0);
let account: number | null = null;
let controller: AbortController | null = null;
function emit(event: LiveEvent) {
for (const listener of listeners) {
listener(event);
}
}
function drain(input: string): string {
let rest = input;
let split = rest.indexOf("\n\n");
while (split !== -1) {
const block = rest.slice(0, split);
if (!block.startsWith(":")) {
const event = parseFrame(block);
if (event) {
emit(event);
}
}
rest = rest.slice(split + 2);
split = rest.indexOf("\n\n");
}
return rest;
}
async function consume(response: Response, signal: AbortSignal) {
if (!response.body) {
return;
}
epoch++;
for (const listener of reconnectListeners) {
listener();
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!signal.aborted) {
const { value, done } = await reader.read();
if (done) {
return;
}
buffer = drain(buffer + decoder.decode(value, { stream: true }));
}
}
async function run(accountId: number, signal: AbortSignal) {
while (!signal.aborted) {
try {
const response = await fetch(`${BASE}/events?account_id=${accountId}`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
signal,
});
if (response.ok) {
await consume(response, signal);
}
} catch {
if (signal.aborted) {
return;
}
}
if (signal.aborted) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, RECONNECT_DELAY);
});
}
}
function close() {
controller?.abort();
controller = null;
}
return {
get epoch(): number {
return epoch;
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
onReconnect(listener: () => void): () => void {
reconnectListeners.add(listener);
return () => reconnectListeners.delete(listener);
},
open(accountId: number | null) {
if (accountId === account) {
return;
}
close();
account = accountId;
if (accountId === null || !auth.token) {
return;
}
controller = new AbortController();
run(accountId, controller.signal);
},
};
}
export const events = createEvents();
+101
View File
@@ -0,0 +1,101 @@
import { searchMessages } from "$lib/api/endpoints";
import type { SearchHit } from "$lib/api/types";
import { chats } from "$lib/stores/chats.svelte";
const DEBOUNCE_MS = 250;
const MIN_LENGTH = 1;
function createSearch() {
let active = $state(false);
let query = $state("");
let messageHits = $state<SearchHit[]>([]);
let loading = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
const trimmed = $derived(query.trim());
const chatHits = $derived.by(() => {
const needle = trimmed.toLowerCase();
if (needle.length < MIN_LENGTH) {
return [];
}
return chats.list.filter((chat) =>
(chat.title ?? "").toLowerCase().includes(needle)
);
});
async function run(value: string) {
const current = ++seq;
try {
const hits = await searchMessages(value);
if (current === seq) {
messageHits = hits;
}
} catch {
if (current === seq) {
messageHits = [];
}
} finally {
if (current === seq) {
loading = false;
}
}
}
function schedule() {
if (timer) {
clearTimeout(timer);
}
const value = trimmed;
if (value.length < MIN_LENGTH) {
seq++;
messageHits = [];
loading = false;
return;
}
loading = true;
timer = setTimeout(() => {
run(value).catch(() => undefined);
}, DEBOUNCE_MS);
}
return {
get active() {
return active;
},
get query() {
return query;
},
get trimmed() {
return trimmed;
},
get loading() {
return loading;
},
get messageHits() {
return messageHits;
},
get chatHits() {
return chatHits;
},
open() {
active = true;
},
setQuery(value: string) {
query = value;
schedule();
},
close() {
active = false;
query = "";
messageHits = [];
loading = false;
seq++;
if (timer) {
clearTimeout(timer);
}
},
};
}
export const search = createSearch();
+27
View File
@@ -10,9 +10,18 @@ export type RightPanel =
| "stories"
| "policy";
export type LeftView = "main" | "settings";
interface JumpTarget {
chatId: number;
messageId: number;
}
function createUi() {
let rightPanel = $state<RightPanel | null>(null);
let leftColumnOpen = $state(true);
let leftView = $state<LeftView>("main");
let jumpTarget = $state<JumpTarget | null>(null);
return {
get rightPanel() {
@@ -21,6 +30,24 @@ function createUi() {
get leftColumnOpen() {
return leftColumnOpen;
},
get leftView() {
return leftView;
},
get jumpTarget() {
return jumpTarget;
},
openSettings() {
leftView = "settings";
},
closeSettings() {
leftView = "main";
},
requestJump(chatId: number, messageId: number) {
jumpTarget = { chatId, messageId };
},
clearJump() {
jumpTarget = null;
},
openPanel(panel: RightPanel) {
rightPanel = panel;
},
+91 -18
View File
@@ -1,12 +1,19 @@
<script lang="ts">
import AccountSwitcher from "$lib/components/AccountSwitcher.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import ChatList from "$lib/components/ChatList.svelte";
import FolderTabs from "$lib/components/FolderTabs.svelte";
import RightColumn from "$lib/components/RightColumn.svelte";
import SearchInput from "$lib/components/search/SearchInput.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte";
import Settings from "$lib/components/settings/Settings.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { theme } from "$lib/stores/theme.svelte";
import { events } from "$lib/stores/events.svelte";
import { search } from "$lib/stores/search.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
let { children } = $props();
@@ -15,30 +22,72 @@
accounts.load().catch(() => toasts.error("Failed to load accounts"));
}
});
$effect(() => {
events.open(accounts.selectedId);
});
</script>
<div id="Main">
<div id="Main" class:with-right={ui.rightPanel !== null}>
<div id="LeftColumn">
<header class="left-header">
<AccountSwitcher />
<Button
variant="translucent"
round
smaller
onclick={() => theme.toggle()}
aria-label="Toggle theme"
{#if ui.leftView === "settings"}
<div
class="left-view"
in:fly={{ x: 64, duration: 200, easing: cubicOut }}
>
<Icon name={theme.resolved === "dark" ? "sun" : "moon"} />
</Button>
</header>
<div class="folder-tabs">
<FolderTabs />
</div>
<ChatList />
<Settings />
</div>
{:else}
<div
class="left-view"
in:fly={{ x: -64, duration: 200, easing: cubicOut }}
>
<header class="left-header">
{#if search.active}
<Button
variant="translucent"
round
smaller
onclick={() => search.close()}
aria-label="Назад"
>
<Icon name="arrow-left" />
</Button>
{:else}
<Button
variant="translucent"
round
smaller
onclick={() => ui.openSettings()}
aria-label="Меню"
>
<Icon name="menu" />
</Button>
{/if}
<SearchInput />
</header>
{#if search.active && search.trimmed}
<SearchResults />
{:else}
<div class="folder-tabs">
<FolderTabs />
</div>
<ChatList />
{/if}
</div>
{/if}
</div>
<div id="MiddleColumn">
{@render children()}
</div>
{#if ui.rightPanel !== null}
<div
id="RightColumn"
transition:fly={{ x: 320, duration: 200, easing: cubicOut }}
>
<RightColumn />
</div>
{/if}
</div>
<style lang="scss">
@@ -49,6 +98,10 @@
overflow: hidden;
height: 100%;
&.with-right {
grid-template-columns: minmax(16rem, 26.5rem) 1fr minmax(20rem, 25rem);
}
}
#LeftColumn {
@@ -62,6 +115,15 @@
background-color: var(--color-background);
}
.left-view {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.left-header {
display: flex;
align-items: center;
@@ -83,4 +145,15 @@
height: 100%;
background-color: var(--color-background-secondary);
}
#RightColumn {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
border-left: 1px solid var(--color-borders);
background-color: var(--color-background);
}
</style>