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