style(frontend): apply ultracite 7.12 formatting and lint rules

This commit is contained in:
hh committed 2026-09-19 00:21:07 +02:00
1 parent fc4758aa0b
commit c8e5cff871
109 files changed
+844 -816

No files matched your search

+20 -20
View File
@@ -1,4 +1,24 @@
{ {
"name": "frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dev": "vite dev",
"fix": "ultracite fix",
"lint": "ultracite check",
"prepare": "svelte-kit sync || echo ''",
"preview": "vite preview"
},
"dependencies": {
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^3.0.0",
"uqr": "^0.1.3"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.13", "@biomejs/biome": "2.5.13",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
@@ -16,25 +36,5 @@
"typescript": "^7.0.0", "typescript": "^7.0.0",
"ultracite": "7.12.0", "ultracite": "7.12.0",
"vite": "^8.0.7" "vite": "^8.0.7"
},
"name": "frontend",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"lint": "ultracite check",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"fix": "ultracite fix"
},
"type": "module",
"version": "0.0.1",
"private": true,
"dependencies": {
"bits-ui": "^2.18.1",
"lottie-web": "^5.13.0",
"pako": "^3.0.0",
"uqr": "^0.1.3"
} }
} }
+2 -2
View File
@@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta content="width=device-width, initial-scale=1" name="viewport" />
<meta name="text-scale" content="scale" /> <meta content="scale" name="text-scale" />
<script> <script>
(() => { (() => {
const stored = localStorage.getItem("bg.theme"); const stored = localStorage.getItem("bg.theme");
+5 -5
View File
@@ -63,7 +63,7 @@ async function handleError(response: Response): Promise<never> {
try { try {
const data = await response.json(); const data = await response.json();
if (data && typeof data.detail === "string") { if (data && typeof data.detail === "string") {
detail = data.detail; ({ detail } = data);
} }
} catch { } catch {
detail = response.statusText; detail = response.statusText;
@@ -81,9 +81,9 @@ export async function request<T>(
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, { const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
headers,
method,
}); });
if (!response.ok) { if (!response.ok) {
return handleError(response); return handleError(response);
@@ -114,9 +114,9 @@ export async function requestMedia(mediaId: number): Promise<MediaResult> {
} }
const blob = await response.blob(); const blob = await response.blob();
return { return {
mime: response.headers.get("Content-Type"),
state: "ready", state: "ready",
url: URL.createObjectURL(blob), url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
}; };
} }
@@ -134,8 +134,8 @@ export async function requestMediaVersion(
} }
const blob = await response.blob(); const blob = await response.blob();
return { return {
mime: response.headers.get("Content-Type"),
state: "ready", state: "ready",
url: URL.createObjectURL(blob), url: URL.createObjectURL(blob),
mime: response.headers.get("Content-Type"),
}; };
} }
+1 -1
View File
@@ -50,7 +50,7 @@ async function fetchEmoji(
const response = await fetch(url, { headers: authHeaders() }); const response = await fetch(url, { headers: authHeaders() });
if (response.ok) { if (response.ok) {
const blob = await response.blob(); const blob = await response.blob();
const asset = { url: URL.createObjectURL(blob), mime: blob.type }; const asset = { mime: blob.type, url: URL.createObjectURL(blob) };
remember(key, asset); remember(key, asset);
return asset; return asset;
} }
+19 -19
View File
@@ -49,8 +49,8 @@ export function listAccounts(): Promise<Account[]> {
export function startLogin(phone: string): Promise<LoginState> { export function startLogin(phone: string): Promise<LoginState> {
return request<LoginState>("/accounts/login", { return request<LoginState>("/accounts/login", {
method: "POST",
body: { phone }, body: { phone },
method: "POST",
}); });
} }
@@ -67,8 +67,8 @@ export function submitLoginCode(
code: string code: string
): Promise<LoginState> { ): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/code`, { return request<LoginState>(`/accounts/login/${loginId}/code`, {
method: "POST",
body: { code }, body: { code },
method: "POST",
}); });
} }
@@ -77,8 +77,8 @@ export function submitLoginPassword(
password: string password: string
): Promise<LoginState> { ): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/password`, { return request<LoginState>(`/accounts/login/${loginId}/password`, {
method: "POST",
body: { password }, body: { password },
method: "POST",
}); });
} }
@@ -91,8 +91,8 @@ export function renameAccountDevice(
deviceModel: string deviceModel: string
): Promise<Account> { ): Promise<Account> {
return request<Account>(`/accounts/${accountId}/device`, { return request<Account>(`/accounts/${accountId}/device`, {
method: "PATCH",
body: { device_model: deviceModel }, body: { device_model: deviceModel },
method: "PATCH",
}); });
} }
@@ -123,8 +123,8 @@ export function listPolicies(): Promise<PolicyRecord[]> {
export function createPolicy(body: PolicyCreate): Promise<PolicyRecord> { export function createPolicy(body: PolicyCreate): Promise<PolicyRecord> {
return request<PolicyRecord>("/policy", { return request<PolicyRecord>("/policy", {
method: "POST",
body: { ...body, account_id: accounts.selectedId }, body: { ...body, account_id: accounts.selectedId },
method: "POST",
}); });
} }
@@ -133,9 +133,9 @@ export function updatePolicy(
toggles: CaptureToggles toggles: CaptureToggles
): Promise<PolicyRecord> { ): Promise<PolicyRecord> {
return request<PolicyRecord>(`/policy/${id}`, { return request<PolicyRecord>(`/policy/${id}`, {
method: "PUT",
account: true, account: true,
body: toggles, body: toggles,
method: "PUT",
}); });
} }
@@ -353,8 +353,8 @@ export function getChatsBatch(ids: number[]): Promise<Chat[]> {
export function enrichChat(chatId: number): Promise<{ job_id: number }> { export function enrichChat(chatId: number): Promise<{ job_id: number }> {
return request<{ job_id: number }>(`/chats/${chatId}/enrich`, { return request<{ job_id: number }>(`/chats/${chatId}/enrich`, {
method: "POST",
body: { account_id: accounts.selectedId }, body: { account_id: accounts.selectedId },
method: "POST",
}); });
} }
@@ -379,8 +379,8 @@ export function enqueueBackfill(
full = false full = false
): Promise<{ job_id: number }> { ): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/backfill", { return request<{ job_id: number }>("/backfill", {
body: { account_id: accounts.selectedId, chat_id: chatId, full, media },
method: "POST", method: "POST",
body: { account_id: accounts.selectedId, chat_id: chatId, media, full },
}); });
} }
@@ -388,8 +388,8 @@ export function enqueueStoriesBackfill(
peerId: number peerId: number
): Promise<{ job_id: number }> { ): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/stories/backfill", { return request<{ job_id: number }>("/stories/backfill", {
method: "POST",
body: { account_id: accounts.selectedId, peer_id: peerId }, body: { account_id: accounts.selectedId, peer_id: peerId },
method: "POST",
}); });
} }
@@ -412,22 +412,22 @@ export function trackChat(
backfill = true backfill = true
): Promise<DiscoverItem> { ): Promise<DiscoverItem> {
return request<DiscoverItem>(`/chats/${chatId}/track`, { return request<DiscoverItem>(`/chats/${chatId}/track`, {
method: "POST",
body: { account_id: accounts.selectedId, backfill }, body: { account_id: accounts.selectedId, backfill },
method: "POST",
}); });
} }
export function syncContacts(): Promise<{ job_id: number }> { export function syncContacts(): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/contacts/sync", { return request<{ job_id: number }>("/contacts/sync", {
method: "POST",
body: { account_id: accounts.selectedId }, body: { account_id: accounts.selectedId },
method: "POST",
}); });
} }
export function syncDialogs(): Promise<{ job_id: number }> { export function syncDialogs(): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/dialogs/sync", { return request<{ job_id: number }>("/dialogs/sync", {
method: "POST",
body: { account_id: accounts.selectedId }, body: { account_id: accounts.selectedId },
method: "POST",
}); });
} }
@@ -468,12 +468,12 @@ export function fetchMedia(
messageId: number messageId: number
): Promise<{ job_id: number }> { ): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/media/fetch", { return request<{ job_id: number }>("/media/fetch", {
method: "POST",
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
}, },
method: "POST",
}); });
} }
@@ -482,12 +482,12 @@ export function transcribeMedia(
messageId: number messageId: number
): Promise<{ job_id: number }> { ): Promise<{ job_id: number }> {
return request<{ job_id: number }>("/media/transcribe", { return request<{ job_id: number }>("/media/transcribe", {
method: "POST",
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
}, },
method: "POST",
}); });
} }
@@ -501,8 +501,8 @@ export function createWatch(
enabled: boolean enabled: boolean
): Promise<Watch> { ): Promise<Watch> {
return request<Watch>("/watches", { return request<Watch>("/watches", {
body: { account_id: accounts.selectedId, enabled, kind, params },
method: "POST", method: "POST",
body: { account_id: accounts.selectedId, kind, params, enabled },
}); });
} }
@@ -512,8 +512,8 @@ export function updateWatch(
enabled: boolean enabled: boolean
): Promise<Watch> { ): Promise<Watch> {
return request<Watch>(`/watches/${id}`, { return request<Watch>(`/watches/${id}`, {
body: { enabled, params },
method: "PUT", method: "PUT",
body: { params, enabled },
}); });
} }
@@ -546,13 +546,13 @@ export function createAnnotation(
text: string text: string
): Promise<Annotation> { ): Promise<Annotation> {
return request<Annotation>("/annotations", { return request<Annotation>("/annotations", {
method: "POST",
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
text, text,
}, },
method: "POST",
}); });
} }
@@ -561,8 +561,8 @@ export function updateAnnotation(
text: string text: string
): Promise<Annotation> { ): Promise<Annotation> {
return request<Annotation>(`/annotations/${id}`, { return request<Annotation>(`/annotations/${id}`, {
method: "PUT",
body: { text }, body: { text },
method: "PUT",
}); });
} }
@@ -575,7 +575,7 @@ export function listWatchedBots(): Promise<WatchedBot[]> {
} }
export function addWatchedBot(token: string): Promise<WatchedBot> { export function addWatchedBot(token: string): Promise<WatchedBot> {
return request<WatchedBot>("/bots", { method: "POST", body: { token } }); return request<WatchedBot>("/bots", { body: { token }, method: "POST" });
} }
export function removeWatchedBot(botId: number): Promise<void> { export function removeWatchedBot(botId: number): Promise<void> {
+20 -19
View File
@@ -35,23 +35,23 @@ export function viewerItemsFrom(
if (media.length === 0) { if (media.length === 0) {
return [ return [
{ {
messageId,
mediaId: null,
kind: "",
downloaded: false, downloaded: false,
fileName: null, fileName: null,
fileSize: null, fileSize: null,
kind: "",
mediaId: null,
messageId,
mime: null, mime: null,
}, },
]; ];
} }
return media.map((item) => ({ return media.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded, downloaded: item.downloaded,
fileName: item.file_name, fileName: item.file_name,
fileSize: item.file_size, fileSize: item.file_size,
kind: item.kind,
mediaId: item.id,
messageId: item.message_id,
mime: item.mime, mime: item.mime,
})); }));
} }
@@ -141,21 +141,21 @@ async function resolve(
return { state: "missing" }; return { state: "missing" };
} }
if (!meta.downloaded) { if (!meta.downloaded) {
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind }; return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" };
} }
const blob = await requestMedia(meta.id); const blob = await requestMedia(meta.id);
if (blob.state === "ready") { if (blob.state === "ready") {
return { return {
state: "ready",
mediaId: meta.id,
kind: meta.kind, kind: meta.kind,
mediaId: meta.id,
mime: blob.mime, mime: blob.mime,
url: blob.url, state: "ready",
transcript: meta.extracted_text, transcript: meta.extracted_text,
url: blob.url,
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { state: "not-downloaded", mediaId: meta.id, kind: meta.kind }; return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" };
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -168,21 +168,21 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
return { state: "missing" }; return { state: "missing" };
} }
if (!media.downloaded) { if (!media.downloaded) {
return { state: "not-downloaded", mediaId: media.id, kind: media.kind }; return { kind: media.kind, mediaId: media.id, state: "not-downloaded" };
} }
const blob = await requestMedia(media.id); const blob = await requestMedia(media.id);
if (blob.state === "ready") { if (blob.state === "ready") {
return { return {
state: "ready",
mediaId: media.id,
kind: media.kind, kind: media.kind,
mediaId: media.id,
mime: blob.mime, mime: blob.mime,
url: blob.url, state: "ready",
transcript: null, transcript: null,
url: blob.url,
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { state: "not-downloaded", mediaId: media.id, kind: media.kind }; return { kind: media.kind, mediaId: media.id, state: "not-downloaded" };
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -235,9 +235,10 @@ export async function requestTranscription(
messageId: number messageId: number
): Promise<string | null> { ): Promise<string | null> {
await transcribeMedia(chatId, messageId); await transcribeMedia(chatId, messageId);
for (let i = 0; i < TRANSCRIBE_TRIES; i++) { for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) {
await new Promise((resolve) => { // biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
setTimeout(resolve, TRANSCRIBE_DELAY); await new Promise((done) => {
setTimeout(done, TRANSCRIBE_DELAY);
}); });
try { try {
const meta = await getMessageMedia(chatId, messageId); const meta = await getMessageMedia(chatId, messageId);
+5 -5
View File
@@ -44,17 +44,17 @@ export function createShare(
settings: ShareSettings settings: ShareSettings
): Promise<FileShare> { ): Promise<FileShare> {
return request<FileShare>("/shares", { return request<FileShare>("/shares", {
method: "POST",
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
expires_in_seconds: settings.expiresInSeconds,
kind: subject.kind, kind: subject.kind,
max_downloads: settings.maxDownloads,
media_id: subject.mediaId ?? null, media_id: subject.mediaId ?? null,
version_id: subject.versionId ?? null,
peer_id: subject.peerId ?? null, peer_id: subject.peerId ?? null,
story_id: subject.storyId ?? null, story_id: subject.storyId ?? null,
expires_in_seconds: settings.expiresInSeconds, version_id: subject.versionId ?? null,
max_downloads: settings.maxDownloads,
}, },
method: "POST",
}); });
} }
@@ -63,12 +63,12 @@ export function updateShare(
settings: ShareSettings settings: ShareSettings
): Promise<FileShare> { ): Promise<FileShare> {
return request<FileShare>(`/shares/${id}`, { return request<FileShare>(`/shares/${id}`, {
method: "PATCH",
body: { body: {
expires_in_seconds: settings.expiresInSeconds, expires_in_seconds: settings.expiresInSeconds,
keep_expiry: settings.keepExpiry ?? false, keep_expiry: settings.keepExpiry ?? false,
max_downloads: settings.maxDownloads, max_downloads: settings.maxDownloads,
}, },
method: "PATCH",
}); });
} }
+16 -1
View File
@@ -1 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> <svg
height="128"
viewBox="0 0 107 128"
width="107"
xmlns="http://www.w3.org/2000/svg"
>
<title>svelte-logo</title>
<path
d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116"
style="fill:#ff3e00"
/>
<path
d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328"
style="fill:#fff"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -34,7 +34,7 @@
const actor = $derived(nameOf(message.sender_id)); const actor = $derived(nameOf(message.sender_id));
const text = $derived.by(() => { const text = $derived.by(() => {
const service = message.service; const { service } = message;
if (!service) { if (!service) {
return ""; return "";
} }
@@ -85,14 +85,15 @@
<div class="ActionMessage"> <div class="ActionMessage">
{#if pinned} {#if pinned}
<button <button
type="button"
class="pill action" class="pill action"
onclick={() => { onclick={() => {
const id = message.service?.pinned_message_id; const id = message.service?.pinned_message_id;
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
if (id != null) { if (id != null) {
onjump(id); onjump(id);
} }
}} }}
type="button"
> >
<Icon name="pin" size="0.8125rem" /> <Icon name="pin" size="0.8125rem" />
<span>{text}</span> <span>{text}</span>
+19 -19
View File
@@ -141,11 +141,11 @@
<header class="chat-header"> <header class="chat-header">
<div class="back"> <div class="back">
<Button <Button
variant="translucent" aria-label="Назад"
onclick={() => goto("/app")}
round round
smaller smaller
onclick={() => goto("/app")} variant="translucent"
aria-label="Назад"
> >
<Icon name="arrow-left" /> <Icon name="arrow-left" />
</Button> </Button>
@@ -154,18 +154,18 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button" aria-label="Открыть профиль"
class="peek" class="peek"
onclick={() => ui.openPanel("profile")} onclick={() => ui.openPanel("profile")}
aria-label="Открыть профиль" type="button"
> >
<Avatar <Avatar
name={title} avatar={{ id: chatId, kind: avatarKind }}
colorKey={chatId} colorKey={chatId}
size={2.5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false} deleted={peer?.is_deleted_account ?? false}
{hasAvatar}
name={title}
size={2.5}
/> />
<div class="info"> <div class="info">
<h2 class="title">{title}</h2> <h2 class="title">{title}</h2>
@@ -188,30 +188,30 @@
</ContextMenu> </ContextMenu>
<div class="actions"> <div class="actions">
<Button <Button
variant="translucent" aria-label="Поиск в чате"
onclick={() => ui.openPanel("search")}
round round
smaller smaller
onclick={() => ui.openPanel("search")} variant="translucent"
aria-label="Поиск в чате"
> >
<Icon name="search" /> <Icon name="search" />
</Button> </Button>
<Button <Button
variant="translucent" aria-label="Аналитика"
onclick={() => ui.openPanel("presence")}
round round
smaller smaller
onclick={() => ui.openPanel("presence")} variant="translucent"
aria-label="Аналитика"
> >
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
variant="translucent" aria-label="Догрузить новые сообщения"
round
smaller
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
aria-label="Догрузить новые сообщения" round
smaller
variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name="cloud-download" />
</Button> </Button>
+9 -9
View File
@@ -75,7 +75,7 @@
}); });
function measure(el: HTMLElement) { function measure(el: HTMLElement) {
scrollTop = el.scrollTop; ({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
chats.loadMore(folders.selectedId).catch(() => undefined); chats.loadMore(folders.selectedId).catch(() => undefined);
} }
@@ -94,35 +94,35 @@
</script> </script>
<div <div
bind:this={viewport}
bind:clientHeight={viewportHeight}
class="chat-list custom-scroll" class="chat-list custom-scroll"
onscroll={onScroll} onscroll={onScroll}
bind:this={viewport}
bind:clientHeight={viewportHeight}
> >
{#if chats.loading && list.length === 0} {#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)} {#each skeletonRows as index (index)}
<div class="row-skeleton"> <div class="row-skeleton">
<Skeleton width="3rem" height="3rem" circle /> <Skeleton circle height="3rem" width="3rem" />
<div class="row-skeleton-lines"> <div class="row-skeleton-lines">
<Skeleton width="55%" height="0.875rem" /> <Skeleton height="0.875rem" width="55%" />
<Skeleton width="80%" height="0.8125rem" /> <Skeleton height="0.8125rem" width="80%" />
</div> </div>
</div> </div>
{/each} {/each}
{:else if list.length === 0} {:else if list.length === 0}
<EmptyState <EmptyState
title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
description={folders.selectedId === null description={folders.selectedId === null
? undefined ? undefined
: "No chats match this folder yet"} : "No chats match this folder yet"}
title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
/> />
{:else} {:else}
<div style:padding-top="{padTop}px" style:padding-bottom="{padBottom}px"> <div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
{#each visible as chat (chat.chat_id)} {#each visible as chat (chat.chat_id)}
<ChatListItem <ChatListItem
{chat} {chat}
selected={chat.chat_id === activeChatId}
onclick={() => goto(`/app/${chat.chat_id}`)} onclick={() => goto(`/app/${chat.chat_id}`)}
selected={chat.chat_id === activeChatId}
/> />
{/each} {/each}
</div> </div>
@@ -62,17 +62,17 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
class="Chat ListItem-button" class="Chat ListItem-button"
{onclick}
type="button"
class:selected class:selected
use:ripple use:ripple
{onclick}
> >
<Avatar <Avatar
name={title} avatar={{ id: chat.chat_id, kind: avatarKind }}
colorKey={chat.chat_id} colorKey={chat.chat_id}
avatar={{ kind: avatarKind, id: chat.chat_id }}
hasAvatar={chat.has_avatar} hasAvatar={chat.has_avatar}
name={title}
/> />
<div class="info"> <div class="info">
<div class="info-row"> <div class="info-row">
+1 -1
View File
@@ -15,7 +15,7 @@
</script> </script>
<div class="Contact"> <div class="Contact">
<Avatar {name} colorKey={contact.user_id ?? 0} size={2.5} /> <Avatar colorKey={contact.user_id ?? 0} {name} size={2.5} />
<div class="info"> <div class="info">
<div class="name">{name}</div> <div class="name">{name}</div>
{#if contact.phone_number} {#if contact.phone_number}
@@ -32,9 +32,9 @@
<span class="CustomEmoji" style="--emoji-size: {size}rem;"> <span class="CustomEmoji" style="--emoji-size: {size}rem;">
{#if asset && isVideo} {#if asset && isVideo}
<video src={asset.url} autoplay loop muted playsinline></video> <video autoplay loop muted playsinline src={asset.url}></video>
{:else if asset && isImage} {:else if asset && isImage}
<img src={asset.url} alt="emoji"> <img alt="emoji" src={asset.url}>
{:else} {:else}
<span class="placeholder">🧩</span> <span class="placeholder">🧩</span>
{/if} {/if}
@@ -50,10 +50,10 @@
{:else if type === "spoiler"} {:else if type === "spoiler"}
{@const key = spoilerKey(node.entity)} {@const key = spoilerKey(node.entity)}
<button <button
type="button"
class="spoiler" class="spoiler"
class:revealed={revealed.has(key)}
onclick={() => revealed.add(key)} onclick={() => revealed.add(key)}
type="button"
class:revealed={revealed.has(key)}
> >
{@render tree(node.children)} {@render tree(node.children)}
</button> </button>
@@ -69,10 +69,10 @@
{:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"} {:else if type === "url" || type === "text_link" || type === "email" || type === "phone_number"}
<a <a
class="link" class="link"
class:own
href={linkHref(node)} href={linkHref(node)}
target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank"
class:own
>{@render tree(node.children)}</a >{@render tree(node.children)}</a
> >
{:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"} {:else if type === "mention" || type === "text_mention" || type === "hashtag" || type === "cashtag" || type === "bot_command"}
@@ -62,21 +62,21 @@
}); });
</script> </script>
<div bind:this={containerEl} class="container" class:ready={clipPath !== ""}> <div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
<button type="button" class="tab" onclick={() => folders.select(tab.id)}> <button class="tab" onclick={() => folders.select(tab.id)} type="button">
{tab.title} {tab.title}
</button> </button>
{/each} {/each}
<div <div
bind:this={indicatorEl} aria-hidden="true"
class="active-indicator" class="active-indicator"
style={clipPath ? `clip-path: ${clipPath}` : undefined} style={clipPath ? `clip-path: ${clipPath}` : undefined}
aria-hidden="true" bind:this={indicatorEl}
> >
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
<button type="button" class="tab" tabindex="-1">{tab.title}</button> <button class="tab" tabindex="-1" type="button">{tab.title}</button>
{/each} {/each}
</div> </div>
</div> </div>
@@ -30,8 +30,8 @@
<a <a
class="button" class="button"
href={safeHref(button.url)} href={safeHref(button.url)}
target="_blank"
rel="noopener" rel="noopener"
target="_blank"
> >
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
<span class="corner">↗</span> <span class="corner">↗</span>
@@ -39,8 +39,8 @@
{:else if button.kind === "callback" && button.data} {:else if button.kind === "callback" && button.data}
<button <button
class="button" class="button"
type="button"
onclick={() => copy(key, button.data ?? "")} onclick={() => copy(key, button.data ?? "")}
type="button"
> >
<span class="label" <span class="label"
>{copied === key ? "Скопировано" : button.text}</span >{copied === key ? "Скопировано" : button.text}</span
+2 -2
View File
@@ -19,11 +19,11 @@
</script> </script>
<svelte:element <svelte:element
this={mapUrl ? "a" : "div"}
class="Location" class="Location"
href={mapUrl} href={mapUrl}
target={mapUrl ? "_blank" : undefined}
rel={mapUrl ? "noopener" : undefined} rel={mapUrl ? "noopener" : undefined}
target={mapUrl ? "_blank" : undefined}
this={mapUrl ? "a" : "div"}
> >
<span class="pin">📍</span> <span class="pin">📍</span>
<div class="info"> <div class="info">
@@ -35,13 +35,13 @@
<h1>Beavergram</h1> <h1>Beavergram</h1>
<p class="subtitle">Enter your access token to continue.</p> <p class="subtitle">Enter your access token to continue.</p>
<input <input
class="form-control"
type="password"
placeholder="Access token"
autocomplete="current-password" autocomplete="current-password"
class="form-control"
placeholder="Access token"
type="password"
bind:value bind:value
> >
<Button type="submit" loading={busy} disabled={busy || !value.trim()}> <Button disabled={busy || !value.trim()} loading={busy} type="submit">
Log in Log in
</Button> </Button>
</form> </form>
@@ -38,7 +38,7 @@
{:else} {:else}
<div class="MediaAlbum" style:--cols={columns}> <div class="MediaAlbum" style:--cols={columns}>
{#each media as item, index (item.id ?? index)} {#each media as item, index (item.id ?? index)}
<AlbumTile media={item} {chatId} onopen={() => onopen(index)} /> <AlbumTile {chatId} media={item} onopen={() => onopen(index)} />
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -33,16 +33,16 @@
{#if result === null} {#if result === null}
<div class="placeholder"><Spinner /></div> <div class="placeholder"><Spinner /></div>
{:else if result.state === "ready" && vk === "image"} {:else if result.state === "ready" && vk === "image"}
<a href={result.url} target="_blank" rel="noopener"> <a href={result.url} rel="noopener" target="_blank">
<img src={result.url} alt={version.kind}> <img alt={version.kind} src={result.url}>
</a> </a>
{:else if result.state === "ready" && vk === "video"} {:else if result.state === "ready" && vk === "video"}
<a href={result.url} target="_blank" rel="noopener"> <a href={result.url} rel="noopener" target="_blank">
<video <video
src={result.url}
muted muted
playsinline playsinline
preload="metadata" preload="metadata"
src={result.url}
use:poster use:poster
></video> ></video>
<span class="play"><Icon name="large-play" size="1.5rem" /></span> <span class="play"><Icon name="large-play" size="1.5rem" /></span>
@@ -50,10 +50,10 @@
{:else if result.state === "ready"} {:else if result.state === "ready"}
<a <a
class="file" class="file"
href={result.url}
target="_blank"
rel="noopener"
download={isActiveContent(result.mime) ? `media_${version.id}` : undefined} download={isActiveContent(result.mime) ? `media_${version.id}` : undefined}
href={result.url}
rel="noopener"
target="_blank"
> >
<Icon name="document" size="1.125rem" /> <Icon name="document" size="1.125rem" />
<span>{version.kind}</span> <span>{version.kind}</span>
+22 -31
View File
@@ -64,32 +64,23 @@
revoke(); revoke();
loading = true; loading = true;
result = null; result = null;
kind = item.kind;
messageId = item.messageId;
currentMediaId = null; currentMediaId = null;
fileName = item.fileName;
fileSize = item.fileSize;
fileOnly = false; fileOnly = false;
const current = ++token; ({ kind, messageId, fileName, fileSize } = item);
token += 1;
const current = token;
try { try {
let mediaId = item.mediaId; let { mediaId, downloaded, mime: mimeType } = item;
let downloaded = item.downloaded; let { fileName: name, fileSize: size } = item;
let name = item.fileName;
let size = item.fileSize;
let mimeType = item.mime;
if (mediaId === null) { if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId); const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id; ({ id: mediaId, downloaded, kind, mime: mimeType } = meta);
downloaded = meta.downloaded; ({ file_name: name, file_size: size } = meta);
kind = meta.kind;
name = meta.file_name;
size = meta.file_size;
mimeType = meta.mime;
} }
const plainFile = downloaded && !isPreviewable(kind, mimeType); const plainFile = downloaded && !isPreviewable(kind, mimeType);
let next: MediaResult; let next: MediaResult;
if (plainFile) { if (plainFile) {
next = { state: "ready", url: "", mime: mimeType }; next = { mime: mimeType, state: "ready", url: "" };
} else if (downloaded) { } else if (downloaded) {
next = await requestMedia(mediaId); next = await requestMedia(mediaId);
} else { } else {
@@ -196,10 +187,10 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
class="media-action"
aria-label="Скачать файл" aria-label="Скачать файл"
class="media-action"
onclick={save} onclick={save}
type="button"
> >
<Icon name={saving ? "timer" : "download"} size="1.375rem" /> <Icon name={saving ? "timer" : "download"} size="1.375rem" />
</button> </button>
@@ -214,15 +205,15 @@
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<button <button
type="button"
class="media-action"
aria-label="Доступ по ссылке" aria-label="Доступ по ссылке"
class="media-action"
onclick={share} onclick={share}
type="button"
> >
<Icon name="allow-share" size="1.375rem" /> <Icon name="allow-share" size="1.375rem" />
</button> </button>
{/if} {/if}
<Dialog.Close class="media-close" aria-label="Закрыть"> <Dialog.Close aria-label="Закрыть" class="media-close">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -233,21 +224,21 @@
{#if loading} {#if loading}
<Spinner color="white" /> <Spinner color="white" />
{:else if result?.state === "ready" && isImage} {:else if result?.state === "ready" && isImage}
<img class="media-image" src={result.url} alt={kind}> <img alt={kind} class="media-image" src={result.url}>
{:else if result?.state === "ready" && isVideo} {:else if result?.state === "ready" && isVideo}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<video <video
class="media-video" class="media-video"
src={result.url}
controls controls
playsinline playsinline
preload="auto" preload="auto"
src={result.url}
use:poster use:poster
></video> ></video>
{:else if result?.state === "ready" && isAudio} {:else if result?.state === "ready" && isAudio}
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio> <audio controls src={result.url}></audio>
{:else if result?.state === "ready"} {:else if result?.state === "ready"}
<div class="media-file"> <div class="media-file">
<span class="file-glyph"><Icon name="document" size="2rem" /></span> <span class="file-glyph"><Icon name="document" size="2rem" /></span>
@@ -255,7 +246,7 @@
{#if fileSize} {#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p> <p class="file-size">{formatBytes(fileSize)}</p>
{/if} {/if}
<button class="media-download" type="button" onclick={save}> <button class="media-download" onclick={save} type="button">
<Icon name="download" /> <Icon name="download" />
Скачать файл Скачать файл
</button> </button>
@@ -263,7 +254,7 @@
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
<p>Файл ещё не скачан в архив.</p> <p>Файл ещё не скачан в архив.</p>
<Button variant="primary" fluid onclick={queueFetch}> <Button fluid onclick={queueFetch} variant="primary">
Скачать в архив Скачать в архив
</Button> </Button>
</div> </div>
@@ -273,20 +264,20 @@
</div> </div>
{#if hasNav} {#if hasNav}
<button <button
class="media-nav prev"
type="button"
aria-label="Previous" aria-label="Previous"
class="media-nav prev"
disabled={index === 0} disabled={index === 0}
onclick={() => step(-1)} onclick={() => step(-1)}
type="button"
> >
<Icon name="previous" size="1.75rem" /> <Icon name="previous" size="1.75rem" />
</button> </button>
<button <button
class="media-nav next"
type="button"
aria-label="Next" aria-label="Next"
class="media-nav next"
disabled={index === items.length - 1} disabled={index === items.length - 1}
onclick={() => step(1)} onclick={() => step(1)}
type="button"
> >
<Icon name="next" size="1.75rem" /> <Icon name="next" size="1.75rem" />
</button> </button>
@@ -109,6 +109,7 @@
ui.openMessagePanel("annotations", message.message_id); ui.openMessagePanel("annotations", message.message_id);
} }
function jumpToReply() { function jumpToReply() {
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
if (message.reply?.message_id != null) { if (message.reply?.message_id != null) {
onjump(message.reply.message_id); onjump(message.reply.message_id);
} }
@@ -134,24 +135,24 @@
<div <div
{...props} {...props}
class="Message" class="Message"
class:own
class:deleted
class:highlighted
class:first-in-group={firstInGroup}
class:last-in-group={lastInGroup}
class:with-avatar={isGroupChat}
data-message-id={message.message_id} data-message-id={message.message_id}
class:deleted
class:first-in-group={firstInGroup}
class:highlighted
class:last-in-group={lastInGroup}
class:own
class:with-avatar={isGroupChat}
in:appear={{ disabled: !animate }} in:appear={{ disabled: !animate }}
> >
{#if isGroupChat} {#if isGroupChat}
<div class="avatar-slot"> <div class="avatar-slot">
{#if lastInGroup && avatarId !== null} {#if lastInGroup && avatarId !== null}
<Avatar <Avatar
name={senderName} avatar={{ id: avatarId, kind: "peer" }}
colorKey={avatarId} colorKey={avatarId}
size={2.125}
avatar={{ kind: "peer", id: avatarId }}
hasAvatar={avatarHas} hasAvatar={avatarHas}
name={senderName}
size={2.125}
/> />
{/if} {/if}
</div> </div>
@@ -165,7 +166,7 @@
<ForwardHeader forward={message.forward} /> <ForwardHeader forward={message.forward} />
{/if} {/if}
{#if message.reply} {#if message.reply}
<ReplyHeader reply={message.reply} {onjump} /> <ReplyHeader {onjump} reply={message.reply} />
{/if} {/if}
{#if deleted} {#if deleted}
<span class="deleted-tag"> <span class="deleted-tag">
@@ -185,49 +186,49 @@
{#if !special} {#if !special}
{#if message.media.length > 1} {#if message.media.length > 1}
<MediaAlbum <MediaAlbum
media={message.media}
chatId={message.chat_id} chatId={message.chat_id}
media={message.media}
onopen={onmedia} onopen={onmedia}
{own} {own}
/> />
{:else if message.has_media} {:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} /> <MessageMedia {message} onopen={() => onmedia(0)} {own} />
{/if} {/if}
{/if} {/if}
{#if hasText} {#if hasText}
<div class="text"> <div class="text">
<EntityText <EntityText
text={message.text ?? ""}
entities={message.entities} entities={message.entities}
{own} {own}
text={message.text ?? ""}
/> />
</div> </div>
{:else if !(message.has_media || deleted || special)} {:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div> <div class="text empty">(no text)</div>
{/if} {/if}
{#if message.web_page} {#if message.web_page}
<WebPage {message} {own} {onmedia} /> <WebPage {message} {onmedia} {own} />
{/if} {/if}
{#if message.reactions.length} {#if message.reactions.length}
<Reactions <Reactions
reactions={message.reactions}
{own}
onclick={openReactions} onclick={openReactions}
{own}
reactions={message.reactions}
/> />
{/if} {/if}
{#if linkCount > 0} {#if linkCount > 0}
<button type="button" class="detail-chip" onclick={openLinks}> <button class="detail-chip" onclick={openLinks} type="button">
<Icon name="link" size="0.875rem" /> <Icon name="link" size="0.875rem" />
{linkCount} {linkCount}
{linkCount === 1 ? "ссылка" : "ссылки"} {linkCount === 1 ? "ссылка" : "ссылки"}
</button> </button>
{/if} {/if}
<MessageMeta {message} {own} {onversions} /> <MessageMeta {message} {onversions} {own} />
{#if message.inline_buttons.length} {#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} /> <InlineButtons rows={message.inline_buttons} />
{/if} {/if}
{#if hasCallbacks} {#if hasCallbacks}
<button type="button" class="detail-chip" onclick={openCallbacks}> <button class="detail-chip" onclick={openCallbacks} type="button">
<Icon name="bot-command" size="0.875rem" /> <Icon name="bot-command" size="0.875rem" />
Callback-данные Callback-данные
</button> </button>
@@ -265,7 +266,7 @@
>Версии</ContextMenuItem >Версии</ContextMenuItem
> >
{/if} {/if}
{#if message.reply?.message_id != null} {#if (message.reply?.message_id ?? null) !== null}
<ContextMenuItem icon="reply" onselect={jumpToReply} <ContextMenuItem icon="reply" onselect={jumpToReply}
>Перейти к ответу</ContextMenuItem >Перейти к ответу</ContextMenuItem
> >
+25 -23
View File
@@ -70,7 +70,7 @@
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const out: Row[] = []; const out: Row[] = [];
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i += 1) {
const message = messages[i]; const message = messages[i];
const prev = messages[i - 1]; const prev = messages[i - 1];
const next = messages[i + 1]; const next = messages[i + 1];
@@ -78,14 +78,14 @@
const samePrevDay = prev ? dayKey(prev.date) === day : false; const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false; const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({ out.push({
message,
dayKey: day, dayKey: day,
own: ownId !== null && message.sender_id === ownId, daySeparator: samePrevDay ? null : formatDay(message.date),
firstInGroup: firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay, !prev || prev.sender_id !== message.sender_id || !samePrevDay,
lastInGroup: lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay, !next || next.sender_id !== message.sender_id || !sameNextDay,
daySeparator: samePrevDay ? null : formatDay(message.date), message,
own: ownId !== null && message.sender_id === ownId,
}); });
} }
return out; return out;
@@ -97,9 +97,11 @@
if (message.sender_id !== null) { if (message.sender_id !== null) {
ids.add(message.sender_id); ids.add(message.sender_id);
} }
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
if (message.reply?.sender_id != null) { if (message.reply?.sender_id != null) {
ids.add(message.reply.sender_id); ids.add(message.reply.sender_id);
} }
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
if (message.forward?.from_id != null) { if (message.forward?.from_id != null) {
ids.add(message.forward.from_id); ids.add(message.forward.from_id);
} }
@@ -160,9 +162,9 @@
hasNewer = false; hasNewer = false;
try { try {
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
include_deleted: true,
limit: PAGE, limit: PAGE,
offset: 0, offset: 0,
include_deleted: true,
}); });
messages = [...page].reverse(); messages = [...page].reverse();
hasMore = page.length > 0; hasMore = page.length > 0;
@@ -213,9 +215,9 @@
const prevTop = el.scrollTop; const prevTop = el.scrollTop;
try { try {
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
limit: PAGE,
before_id: messages[0].message_id, before_id: messages[0].message_id,
include_deleted: true, include_deleted: true,
limit: PAGE,
}); });
const known = new Set(messages.map((m) => m.message_id)); const known = new Set(messages.map((m) => m.message_id));
const fresh = page.filter((m) => !known.has(m.message_id)); const fresh = page.filter((m) => !known.has(m.message_id));
@@ -241,9 +243,9 @@
loadingNewer = true; loadingNewer = true;
try { try {
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
limit: PAGE,
after_id: messages.at(-1)?.message_id, after_id: messages.at(-1)?.message_id,
include_deleted: true, include_deleted: true,
limit: PAGE,
}); });
const known = new Set(messages.map((m) => m.message_id)); const known = new Set(messages.map((m) => m.message_id));
const fresh = page.filter((m) => !known.has(m.message_id)); const fresh = page.filter((m) => !known.has(m.message_id));
@@ -266,14 +268,14 @@
try { try {
const [older, newer] = await Promise.all([ const [older, newer] = await Promise.all([
listMessages(chatId, { listMessages(chatId, {
limit: PAGE,
before_id: messageId + 1, before_id: messageId + 1,
include_deleted: true, include_deleted: true,
limit: PAGE,
}), }),
listMessages(chatId, { listMessages(chatId, {
limit: PAGE,
after_id: messageId, after_id: messageId,
include_deleted: true, include_deleted: true,
limit: PAGE,
}), }),
]); ]);
messages = [...[...older].reverse(), ...newer]; messages = [...[...older].reverse(), ...newer];
@@ -395,9 +397,9 @@
return; return;
} }
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
include_deleted: true,
limit: PAGE, limit: PAGE,
offset: 0, offset: 0,
include_deleted: true,
}); });
const fresh = [...page].reverse(); const fresh = [...page].reverse();
const known = new Map(messages.map((m) => [m.message_id, m])); const known = new Map(messages.map((m) => [m.message_id, m]));
@@ -460,9 +462,9 @@
<div class="message-pane"> <div class="message-pane">
<PinnedBar {chatId} onjump={jumpToMessage} /> <PinnedBar {chatId} onjump={jumpToMessage} />
<div <div
bind:this={container}
class="message-list custom-scroll" class="message-list custom-scroll"
onscroll={onScroll} onscroll={onScroll}
bind:this={container}
> >
{#if loading && messages.length === 0} {#if loading && messages.length === 0}
<div class="messages-container"> <div class="messages-container">
@@ -481,8 +483,8 @@
{#if row.daySeparator} {#if row.daySeparator}
<div <div
class="day-separator" class="day-separator"
class:idle={!scrolling && row.dayKey === stuckDay}
data-day={row.dayKey} data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay}
> >
<span>{row.daySeparator}</span> <span>{row.daySeparator}</span>
</div> </div>
@@ -491,16 +493,16 @@
<ActionMessage message={row.message} onjump={jumpToMessage} /> <ActionMessage message={row.message} onjump={jumpToMessage} />
{:else} {:else}
<MessageBubble <MessageBubble
message={row.message}
own={row.own}
{isGroupChat}
firstInGroup={row.firstInGroup}
lastInGroup={row.lastInGroup}
highlighted={highlightId === row.message.message_id}
animate={!suppressAppear} animate={!suppressAppear}
firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id}
{isGroupChat}
lastInGroup={row.lastInGroup}
message={row.message}
onjump={jumpToMessage} onjump={jumpToMessage}
onmedia={(index) => openMedia(row.message, index)} onmedia={(index) => openMedia(row.message, index)}
onversions={() => openVersions(row.message.message_id)} onversions={() => openVersions(row.message.message_id)}
own={row.own}
/> />
{/if} {/if}
{/each} {/each}
@@ -512,10 +514,10 @@
</div> </div>
{#if hasNewer} {#if hasNewer}
<button <button
type="button" aria-label="К последним сообщениям"
class="to-latest" class="to-latest"
onclick={goToLatest} onclick={goToLatest}
aria-label="К последним сообщениям" type="button"
> >
<Icon name="arrow-down" /> <Icon name="arrow-down" />
</button> </button>
@@ -523,15 +525,15 @@
</div> </div>
<MediaViewer <MediaViewer
bind:open={mediaOpen}
bind:index={mediaIndex}
{chatId} {chatId}
items={mediaItems} items={mediaItems}
bind:index={mediaIndex}
bind:open={mediaOpen}
/> />
<MessageVersions <MessageVersions
bind:open={versionsOpen}
{chatId} {chatId}
messageId={versionsMessageId} messageId={versionsMessageId}
bind:open={versionsOpen}
/> />
<style lang="scss"> <style lang="scss">
+12 -11
View File
@@ -88,7 +88,8 @@
} }
async function poll() { async function poll() {
for (let i = 0; i < POLL_TRIES; i++) { for (let i = 0; i < POLL_TRIES; i += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
await delay(POLL_DELAY); await delay(POLL_DELAY);
const next = await loadInlineMedia(message.chat_id, message.message_id); const next = await loadInlineMedia(message.chat_id, message.message_id);
if (next.state === "ready") { if (next.state === "ready") {
@@ -149,47 +150,47 @@
<div class="media-skeleton"><Spinner /></div> <div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"} {:else if ready && kind === "voice"}
<VoiceMessage <VoiceMessage
url={ready.url}
transcript={ready.transcript}
chatId={message.chat_id} chatId={message.chat_id}
messageId={message.message_id} messageId={message.message_id}
{own} {own}
transcript={ready.transcript}
url={ready.url}
/> />
{:else if ready && kind === "video_note"} {:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} /> <VideoNote transcript={ready.transcript} url={ready.url} />
{:else if ready && kind === "audio"} {:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={fileName} {own} /> <AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage} {:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment"> <img alt="attachment" src={ready.url}>
</button> </button>
{:else if ready && isStaticSticker} {:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button"> <button class="media-sticker" onclick={onopen} type="button">
<img src={ready.url} alt="sticker"> <img alt="sticker" src={ready.url}>
</button> </button>
{:else if ready && isVideoSticker} {:else if ready && isVideoSticker}
<video <video
class="media-sticker-video"
src={ready.url}
autoplay autoplay
class="media-sticker-video"
loop loop
muted muted
playsinline playsinline
src={ready.url}
></video> ></video>
{:else if ready && isTgsSticker} {:else if ready && isTgsSticker}
<TgsSticker url={ready.url} /> <TgsSticker url={ready.url} />
{:else if ready && isAnimation} {:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<video src={ready.url} autoplay loop muted playsinline></video> <video autoplay loop muted playsinline src={ready.url}></video>
<span class="gif-badge">GIF</span> <span class="gif-badge">GIF</span>
</button> </button>
{:else if ready && isThumbVideo} {:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<video <video
src={ready.url}
muted muted
playsinline playsinline
preload="metadata" preload="metadata"
src={ready.url}
use:poster use:poster
></video> ></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span> <span class="play"><Icon name="large-play" size="2.5rem" /></span>
@@ -15,10 +15,10 @@
<span class="MessageMeta"> <span class="MessageMeta">
{#if message.edited_at} {#if message.edited_at}
<button <button
type="button"
class="edited" class="edited"
title="View edit history"
onclick={onversions} onclick={onversions}
title="View edit history"
type="button"
> >
edited edited
</button> </button>
@@ -47,7 +47,7 @@
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Edit history</Dialog.Title> <Dialog.Title class="dialog-title">Edit history</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Close"> <Dialog.Close aria-label="Close" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
+1 -1
View File
@@ -50,9 +50,9 @@
{#if pinned} {#if pinned}
<button <button
type="button"
class="PinnedBar" class="PinnedBar"
onclick={() => pinned && onjump(pinned.message_id)} onclick={() => pinned && onjump(pinned.message_id)}
type="button"
> >
<Icon name="pin" size="1.125rem" /> <Icon name="pin" size="1.125rem" />
<span class="body"> <span class="body">
+2 -2
View File
@@ -14,10 +14,10 @@
<div class="Reactions" class:own> <div class="Reactions" class:own>
{#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)} {#each reactions as reaction, index (reaction.custom_emoji_id ?? reaction.emoji ?? index)}
<button <button
type="button"
class="reaction" class="reaction"
class:chosen={reaction.chosen}
{onclick} {onclick}
type="button"
class:chosen={reaction.chosen}
> >
{#if reaction.custom_emoji_id} {#if reaction.custom_emoji_id}
<CustomEmoji id={reaction.custom_emoji_id} size={1.25} /> <CustomEmoji id={reaction.custom_emoji_id} size={1.25} />
@@ -34,11 +34,11 @@
</script> </script>
<button <button
type="button"
class="ReplyHeader peer-color-{colorIndex}" class="ReplyHeader peer-color-{colorIndex}"
class:clickable={canJump}
disabled={!canJump} disabled={!canJump}
onclick={jump} onclick={jump}
type="button"
class:clickable={canJump}
> >
<span class="bar"></span> <span class="bar"></span>
<span class="body"> <span class="body">
+13 -13
View File
@@ -19,32 +19,32 @@
import { type RightPanel, ui } from "$lib/stores/ui.svelte"; import { type RightPanel, ui } from "$lib/stores/ui.svelte";
const titles: Record<RightPanel, string> = { const titles: Record<RightPanel, string> = {
profile: "Профиль", alerts: "Алерты",
search: "Поиск",
versions: "Версии",
reactions: "Реакции",
callbacks: "Callback-данные",
links: "Ссылки",
annotations: "Заметки", annotations: "Заметки",
bots: "Боты",
callbacks: "Callback-данные",
jobs: "Данные и хранилище", jobs: "Данные и хранилище",
links: "Ссылки",
policy: "Политика захвата",
presence: "Аналитика", presence: "Аналитика",
profile: "Профиль",
reactions: "Реакции",
search: "Поиск",
shares: "Файлы по ссылке",
stories: "Сторис", stories: "Сторис",
"stories-all": "Все сторис", "stories-all": "Все сторис",
policy: "Политика захвата", versions: "Версии",
watches: "Отслеживания", watches: "Отслеживания",
alerts: "Алерты",
shares: "Файлы по ссылке",
bots: "Боты",
}; };
</script> </script>
<header class="right-header"> <header class="right-header">
<Button <Button
variant="translucent" aria-label="Закрыть"
onclick={() => ui.closePanel()}
round round
smaller smaller
onclick={() => ui.closePanel()} variant="translucent"
aria-label="Закрыть"
> >
<Icon name="close" /> <Icon name="close" />
</Button> </Button>
+2 -2
View File
@@ -71,12 +71,12 @@
</p> </p>
<div class="actions"> <div class="actions">
{#if tracked} {#if tracked}
<Button variant="secondary" pill loading={busy} onclick={backfill}> <Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" /> <Icon name="cloud-download" />
<span>Загрузить историю</span> <span>Загрузить историю</span>
</Button> </Button>
{:else} {:else}
<Button variant="primary" pill loading={busy} onclick={track}> <Button loading={busy} onclick={track} pill variant="primary">
<Icon name="stats" /> <Icon name="stats" />
<span>Начать отслеживать</span> <span>Начать отслеживать</span>
</Button> </Button>
+2 -2
View File
@@ -18,10 +18,10 @@
<div class="WebPage"> <div class="WebPage">
{#if web.has_photo} {#if web.has_photo}
<div class="photo"> <div class="photo">
<MessageMedia {message} {own} onopen={() => onmedia(0)} /> <MessageMedia {message} onopen={() => onmedia(0)} {own} />
</div> </div>
{/if} {/if}
<a class="card" href={safeHref(web.url)} target="_blank" rel="noopener"> <a class="card" href={safeHref(web.url)} rel="noopener" target="_blank">
{#if web.site_name} {#if web.site_name}
<div class="site">{web.site_name}</div> <div class="site">{web.site_name}</div>
{:else if web.display_url} {:else if web.display_url}
@@ -26,11 +26,11 @@
</div> </div>
{#if !alert.seen} {#if !alert.seen}
<Button <Button
variant="translucent" aria-label="Отметить прочитанным"
onclick={() => onseen(alert)}
round round
smaller smaller
onclick={() => onseen(alert)} variant="translucent"
aria-label="Отметить прочитанным"
> >
<Icon name="check" /> <Icon name="check" />
</Button> </Button>
@@ -41,15 +41,15 @@
}} }}
> >
<textarea <textarea
bind:value={text}
rows={oncancel ? 2 : 3}
placeholder="Заметка к сообщению…" placeholder="Заметка к сообщению…"
rows={oncancel ? 2 : 3}
bind:value={text}
></textarea> ></textarea>
<div class="actions"> <div class="actions">
{#if oncancel} {#if oncancel}
<Button variant="translucent" onclick={oncancel}>Отмена</Button> <Button onclick={oncancel} variant="translucent">Отмена</Button>
{/if} {/if}
<Button type="submit" disabled={saving || !valid}>{submitLabel}</Button> <Button disabled={saving || !valid} type="submit">{submitLabel}</Button>
</div> </div>
</form> </form>
@@ -21,7 +21,6 @@
{#if editingId === annotation.id} {#if editingId === annotation.id}
<AnnotationEditor <AnnotationEditor
initial={annotation.text} initial={annotation.text}
submitLabel="Сохранить"
oncancel={() => { oncancel={() => {
editingId = null; editingId = null;
}} }}
@@ -29,26 +28,27 @@
onupdate(annotation, text); onupdate(annotation, text);
editingId = null; editingId = null;
}} }}
submitLabel="Сохранить"
/> />
{:else} {:else}
<p class="text">{annotation.text}</p> <p class="text">{annotation.text}</p>
<div class="meta"> <div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span> <span class="time">{formatFull(annotation.updated_at)}</span>
<button <button
type="button"
class="action"
aria-label="Изменить" aria-label="Изменить"
class="action"
onclick={() => { onclick={() => {
editingId = annotation.id; editingId = annotation.id;
}} }}
type="button"
> >
<Icon name="edit" size="1rem" /> <Icon name="edit" size="1rem" />
</button> </button>
<button <button
type="button"
class="action"
aria-label="Удалить" aria-label="Удалить"
class="action"
onclick={() => ondelete(annotation)} onclick={() => ondelete(annotation)}
type="button"
> >
<Icon name="delete" size="1rem" /> <Icon name="delete" size="1rem" />
</button> </button>
@@ -32,7 +32,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
const current = token; const current = token;
loading = true; loading = true;
items = []; items = [];
@@ -75,10 +75,10 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState title="Заметки" description="Сообщение не выбрано" /> <EmptyState description="Сообщение не выбрано" title="Заметки" />
{:else} {:else}
<div class="panel-content"> <div class="panel-content">
<AnnotationEditor {saving} onsubmit={add} /> <AnnotationEditor onsubmit={add} {saving} />
{#if loading && items.length === 0} {#if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
@@ -18,11 +18,11 @@
let adding = $state(false); let adding = $state(false);
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
pending: "подключается",
ok: "слушает",
unauthorized: "токен отклонён",
error: "ошибка", error: "ошибка",
ok: "слушает",
pending: "подключается",
removed: "отключён", removed: "отключён",
unauthorized: "токен отклонён",
}; };
async function load() { async function load() {
@@ -62,7 +62,9 @@
} }
function botName(bot: WatchedBot): string { function botName(bot: WatchedBot): string {
return bot.username ? `@${bot.username}` : (bot.title ?? String(bot.bot_id)); return bot.username
? `@${bot.username}`
: (bot.title ?? String(bot.bot_id));
} }
function open(bot: WatchedBot) { function open(bot: WatchedBot) {
@@ -85,10 +87,10 @@
{#each bots as bot (bot.bot_id)} {#each bots as bot (bot.bot_id)}
<li class="bot"> <li class="bot">
<button <button
type="button"
class="open" class="open"
disabled={bot.account_id === null} disabled={bot.account_id === null}
onclick={() => open(bot)} onclick={() => open(bot)}
type="button"
> >
<span class="name">{botName(bot)}</span> <span class="name">{botName(bot)}</span>
<span class="meta"> <span class="meta">
@@ -103,11 +105,11 @@
</button> </button>
{#if bot.enabled} {#if bot.enabled}
<Button <Button
variant="translucent" aria-label="Отключить"
onclick={() => remove(bot)}
round round
smaller smaller
onclick={() => remove(bot)} variant="translucent"
aria-label="Отключить"
> >
<Icon name="delete" /> <Icon name="delete" />
</Button> </Button>
@@ -120,7 +122,7 @@
<div class="add"> <div class="add">
<label class="field"> <label class="field">
<span>Токен бота от @BotFather</span> <span>Токен бота от @BotFather</span>
<input type="password" bind:value={token} placeholder="123456:AA…"> <input placeholder="123456:AA…" type="password" bind:value={token}>
</label> </label>
<Button fluid loading={adding} onclick={add}>Добавить</Button> <Button fluid loading={adding} onclick={add}>Добавить</Button>
</div> </div>
@@ -17,21 +17,21 @@
const KIND_LABELS: Record<string, string> = { const KIND_LABELS: Record<string, string> = {
backfill: "Бэкфилл", backfill: "Бэкфилл",
backfill_stories: "Бэкфилл сторис", backfill_stories: "Бэкфилл сторис",
fetch_media: "Докачка медиа", enrich_chat: "Обогащение чата",
fetch_avatar: "Аватар", fetch_avatar: "Аватар",
fetch_custom_emoji: "Кастом-эмодзи", fetch_custom_emoji: "Кастом-эмодзи",
enrich_chat: "Обогащение чата", fetch_media: "Докачка медиа",
transcribe: "Расшифровка",
sync_dialogs: "Синхронизация диалогов", sync_dialogs: "Синхронизация диалогов",
transcribe: "Расшифровка",
}; };
const STATUS_LABELS: Record<JobStatus, string> = { const STATUS_LABELS: Record<JobStatus, string> = {
pending: "В очереди", canceled: "Отменено",
running: "Выполняется",
done: "Готово", done: "Готово",
failed: "Ошибка", failed: "Ошибка",
canceled: "Отменено",
paused: "Пауза", paused: "Пауза",
pending: "В очереди",
running: "Выполняется",
}; };
let jobs = $state<JobView[]>([]); let jobs = $state<JobView[]>([]);
@@ -125,10 +125,10 @@
<span class="kind">{kindLabel(job)}</span> <span class="kind">{kindLabel(job)}</span>
{#if canCancel(job)} {#if canCancel(job)}
<button <button
type="button"
class="stop" class="stop"
onclick={() => cancel(job)}
disabled={canceling === job.id} disabled={canceling === job.id}
onclick={() => cancel(job)}
type="button"
> >
Стоп Стоп
</button> </button>
@@ -111,7 +111,7 @@
и доступны для бэкфилла. и доступны для бэкфилла.
</p> </p>
<div class="action"> <div class="action">
<Button variant="secondary" fluid loading={syncing} onclick={sync}> <Button fluid loading={syncing} onclick={sync} variant="secondary">
<Icon name="reload" /> <Icon name="reload" />
<span>Синхронизировать диалоги</span> <span>Синхронизировать диалоги</span>
</Button> </Button>
@@ -122,10 +122,10 @@
</p> </p>
<div class="action"> <div class="action">
<Button <Button
variant="secondary"
fluid fluid
loading={syncingContacts} loading={syncingContacts}
onclick={syncContactList} onclick={syncContactList}
variant="secondary"
> >
<Icon name="user" /> <Icon name="user" />
<span>Синхронизировать контакты</span> <span>Синхронизировать контакты</span>
@@ -141,11 +141,11 @@
</p> </p>
<button <button
type="button"
class="select" class="select"
onclick={() => { onclick={() => {
picking = !picking; picking = !picking;
}} }}
type="button"
> >
<Icon name="comments" size="1.25rem" /> <Icon name="comments" size="1.25rem" />
<span class="select-label">{chatTitle(selected)}</span> <span class="select-label">{chatTitle(selected)}</span>
@@ -156,15 +156,15 @@
<div class="picker"> <div class="picker">
<input <input
class="filter" class="filter"
type="text"
placeholder="Поиск чата" placeholder="Поиск чата"
type="text"
bind:value={filter} bind:value={filter}
> >
{#each availableChats as chat (chat.chat_id)} {#each availableChats as chat (chat.chat_id)}
<button <button
type="button"
class="pick-row" class="pick-row"
onclick={() => selectChat(chat.chat_id)} onclick={() => selectChat(chat.chat_id)}
type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {chat.title ?? `Чат ${chat.chat_id}`}
</button> </button>
@@ -173,13 +173,13 @@
{/if} {/if}
<button <button
type="button"
class="toggle-row"
role="switch"
aria-checked={media} aria-checked={media}
class="toggle-row"
onclick={() => { onclick={() => {
media = !media; media = !media;
}} }}
role="switch"
type="button"
> >
<span class="label">Скачивать медиа</span> <span class="label">Скачивать медиа</span>
<span class="switch" class:on={media}> <span class="switch" class:on={media}>
@@ -189,11 +189,11 @@
<div class="action"> <div class="action">
<Button <Button
variant="primary"
fluid
disabled={selected === null} disabled={selected === null}
fluid
loading={starting} loading={starting}
onclick={start} onclick={start}
variant="primary"
> >
<Icon name="cloud-download" /> <Icon name="cloud-download" />
<span>Запустить полный бэкфилл</span> <span>Запустить полный бэкфилл</span>
@@ -48,17 +48,17 @@
{#if ready && isVideo} {#if ready && isVideo}
<button class="tile" onclick={onopen} type="button"> <button class="tile" onclick={onopen} type="button">
<video <video
src={ready.url}
muted muted
playsinline playsinline
preload="metadata" preload="metadata"
src={ready.url}
use:poster use:poster
></video> ></video>
<span class="play"><Icon name="large-play" size="2rem" /></span> <span class="play"><Icon name="large-play" size="2rem" /></span>
</button> </button>
{:else if ready} {:else if ready}
<button class="tile" onclick={onopen} type="button"> <button class="tile" onclick={onopen} type="button">
<img src={ready.url} alt="attachment"> <img alt="attachment" src={ready.url}>
</button> </button>
{:else if inline?.state === "not-downloaded"} {:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button"> <button class="tile placeholder" onclick={queue} type="button">
@@ -41,7 +41,7 @@
</script> </script>
<div class="Audio" class:own> <div class="Audio" class:own>
<button class="toggle" onclick={toggle} type="button" aria-label="Play audio"> <button aria-label="Play audio" class="toggle" onclick={toggle} type="button">
<Icon name={paused ? "play" : "pause"} size="1.625rem" /> <Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button> </button>
<div class="content"> <div class="content">
@@ -58,14 +58,14 @@
</div> </div>
<!-- biome-ignore lint/a11y/useMediaCaption: archived audio track has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived audio track has no captions -->
<audio <audio
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onended={() => element && releasePlayback(element)} onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)} onplay={() => element && claimPlayback(element)}
preload="metadata" preload="metadata"
src={url} src={url}
bind:this={element}
bind:currentTime
bind:duration
bind:paused
></audio> ></audio>
</div> </div>
@@ -42,7 +42,7 @@
</script> </script>
<div class="FileChip" class:own> <div class="FileChip" class:own>
<button class="file-main" type="button" title={name} onclick={save}> <button class="file-main" onclick={save} title={name} type="button">
<span class="file-glyph" class:own> <span class="file-glyph" class:own>
<Icon name={saving ? "timer" : "document"} size="1.25rem" /> <Icon name={saving ? "timer" : "document"} size="1.25rem" />
</span> </span>
@@ -52,10 +52,10 @@
</span> </span>
</button> </button>
<button <button
class="file-share"
type="button"
aria-label="Доступ по ссылке" aria-label="Доступ по ссылке"
class="file-share"
onclick={share} onclick={share}
type="button"
> >
<Icon name="allow-share" size="1.125rem" /> <Icon name="allow-share" size="1.125rem" />
</button> </button>
@@ -28,11 +28,11 @@
return; return;
} }
anim = lottieModule.default.loadAnimation({ anim = lottieModule.default.loadAnimation({
container: target,
renderer: "svg",
loop: true,
autoplay: true,
animationData: json, animationData: json,
autoplay: true,
container: target,
loop: true,
renderer: "svg",
}); });
} catch { } catch {
// tgs decode/render failed — leave empty // tgs decode/render failed — leave empty
@@ -47,8 +47,8 @@
<div <div
class="TgsSticker" class="TgsSticker"
bind:this={container}
style="--tgs-size: {size}rem;" style="--tgs-size: {size}rem;"
bind:this={container}
></div> ></div>
<style lang="scss"> <style lang="scss">
@@ -38,26 +38,26 @@
<div class="RoundVideoWrap"> <div class="RoundVideoWrap">
<button <button
aria-label="Play video message"
class="RoundVideo" class="RoundVideo"
onclick={toggle} onclick={toggle}
type="button" type="button"
aria-label="Play video message"
> >
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
<video <video
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onended={() => element && releasePlayback(element)} onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)} onplay={() => element && claimPlayback(element)}
playsinline playsinline
preload="auto" preload="auto"
src={url} src={url}
bind:this={element}
bind:currentTime
bind:duration
bind:paused
use:poster use:poster
></video> ></video>
<svg class="RoundVideoRing" viewBox="0 0 200 200" aria-hidden="true"> <svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200">
<circle <circle
class="RoundVideoProgress" class="RoundVideoProgress"
cx="100" cx="100"
@@ -78,9 +78,11 @@
{#if transcript} {#if transcript}
<button <button
class="transcribe" class="transcribe"
class:active={showTranscript} onclick={() => {
onclick={() => (showTranscript = !showTranscript)} showTranscript = !showTranscript;
}}
type="button" type="button"
class:active={showTranscript}
> >
<Icon name="transcribe" size="1rem" /> <Icon name="transcribe" size="1rem" />
Transcription Transcription
@@ -96,15 +96,15 @@
<div class="VoiceWrap" class:own> <div class="VoiceWrap" class:own>
<div class="Voice"> <div class="Voice">
<button <button
aria-label="Play voice"
class="toggle" class="toggle"
onclick={toggle} onclick={toggle}
type="button" type="button"
aria-label="Play voice"
> >
<Icon <Icon
class={paused ? "nudge" : ""}
name={paused ? "play" : "pause"} name={paused ? "play" : "pause"}
size="1.5rem" size="1.5rem"
class={paused ? "nudge" : ""}
/> />
</button> </button>
<div class="body"> <div class="body">
@@ -113,20 +113,20 @@
{#each peaks as peak, i (i)} {#each peaks as peak, i (i)}
<span <span
class="bar" class="bar"
class:filled={i / peaks.length < progress}
style:height={`${Math.round(peak * 100)}%`} style:height={`${Math.round(peak * 100)}%`}
class:filled={i / peaks.length < progress}
></span> ></span>
{/each} {/each}
</div> </div>
<div class="time">{formatDuration(elapsed)}</div> <div class="time">{formatDuration(elapsed)}</div>
</div> </div>
<button <button
aria-label="Show transcription"
class="transcribe" class="transcribe"
class:active={showTranscript && Boolean(text)}
disabled={pending} disabled={pending}
onclick={onTranscribe} onclick={onTranscribe}
type="button" type="button"
aria-label="Show transcription" class:active={showTranscript && Boolean(text)}
> >
{#if pending} {#if pending}
<Spinner color={own ? "white" : "gray"} size="1rem" /> <Spinner color={own ? "white" : "gray"} size="1rem" />
@@ -136,14 +136,14 @@
</button> </button>
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio <audio
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
preload="metadata"
src={url}
bind:this={element} bind:this={element}
bind:currentTime bind:currentTime
bind:duration bind:duration
bind:paused bind:paused
onplay={() => element && claimPlayback(element)}
onended={() => element && releasePlayback(element)}
preload="metadata"
src={url}
></audio> ></audio>
</div> </div>
{#if text && showTranscript} {#if text && showTranscript}
@@ -26,12 +26,12 @@
<div class="toggles"> <div class="toggles">
{#each rows as row (row.key)} {#each rows as row (row.key)}
<button <button
type="button"
class="toggle-row"
role="switch"
aria-checked={toggles[row.key]} aria-checked={toggles[row.key]}
class="toggle-row"
{disabled} {disabled}
onclick={() => onchange(row.key, !toggles[row.key])} onclick={() => onchange(row.key, !toggles[row.key])}
role="switch"
type="button"
> >
<span class="label">{row.label}</span> <span class="label">{row.label}</span>
<span class="switch" class:on={toggles[row.key]}> <span class="switch" class:on={toggles[row.key]}>
@@ -28,36 +28,36 @@
let chatFilter = $state(""); let chatFilter = $state("");
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [ const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
{ scope: "default_dm", label: "Личные чаты" }, { label: "Личные чаты", scope: "default_dm" },
{ scope: "default_group", label: "Группы" }, { label: "Группы", scope: "default_group" },
{ scope: "default_channel", label: "Каналы" }, { label: "Каналы", scope: "default_channel" },
]; ];
const ALL_FALSE: CaptureToggles = { 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, backfill: false,
media: false,
messages: false,
presence: false,
profile_history: false,
reactions: false,
self_destruct_media: false,
stories: false,
stt: false,
track_edits_deletes: false,
}; };
function pickToggles(t: CaptureToggles): CaptureToggles { function pickToggles(t: CaptureToggles): CaptureToggles {
return { 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, backfill: t.backfill,
media: t.media,
messages: t.messages,
presence: t.presence,
profile_history: t.profile_history,
reactions: t.reactions,
self_destruct_media: t.self_destruct_media,
stories: t.stories,
stt: t.stt,
track_edits_deletes: t.track_edits_deletes,
}; };
} }
@@ -133,8 +133,8 @@
try { try {
const record = await createPolicy({ const record = await createPolicy({
...ALL_FALSE, ...ALL_FALSE,
scope_type: scope,
scope_id: scopeId, scope_id: scopeId,
scope_type: scope,
}); });
policies = [...policies, record]; policies = [...policies, record];
pickFolder = false; pickFolder = false;
@@ -181,18 +181,18 @@
{/if} {/if}
{#if onremove && record.account_id !== null} {#if onremove && record.account_id !== null}
<button <button
type="button"
class="remove"
aria-label="Удалить оверрайд" aria-label="Удалить оверрайд"
class="remove"
onclick={onremove} onclick={onremove}
type="button"
> >
<Icon name="close" size="1.125rem" /> <Icon name="close" size="1.125rem" />
</button> </button>
{/if} {/if}
</div> </div>
<CaptureToggleList <CaptureToggleList
toggles={record}
onchange={(key, value) => toggle(record, key, value)} onchange={(key, value) => toggle(record, key, value)}
toggles={record}
/> />
</div> </div>
{/snippet} {/snippet}
@@ -217,11 +217,11 @@
{/each} {/each}
{#if availableFolders.length > 0} {#if availableFolders.length > 0}
<button <button
type="button"
class="add" class="add"
onclick={() => { onclick={() => {
pickFolder = !pickFolder; pickFolder = !pickFolder;
}} }}
type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Добавить папку</span> <span>Добавить папку</span>
@@ -230,9 +230,9 @@
<div class="picker"> <div class="picker">
{#each availableFolders as folder (folder.folder_id)} {#each availableFolders as folder (folder.folder_id)}
<button <button
type="button"
class="pick-row" class="pick-row"
onclick={() => addOverride("folder", folder.folder_id)} onclick={() => addOverride("folder", folder.folder_id)}
type="button"
> >
{folder.title} {folder.title}
</button> </button>
@@ -250,11 +250,11 @@
)} )}
{/each} {/each}
<button <button
type="button"
class="add" class="add"
onclick={() => { onclick={() => {
pickChat = !pickChat; pickChat = !pickChat;
}} }}
type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Добавить чат</span> <span>Добавить чат</span>
@@ -263,15 +263,15 @@
<div class="picker"> <div class="picker">
<input <input
class="filter" class="filter"
type="text"
placeholder="Поиск чата" placeholder="Поиск чата"
type="text"
bind:value={chatFilter} bind:value={chatFilter}
> >
{#each availableChats as chat (chat.chat_id)} {#each availableChats as chat (chat.chat_id)}
<button <button
type="button"
class="pick-row" class="pick-row"
onclick={() => addOverride("chat", chat.chat_id)} onclick={() => addOverride("chat", chat.chat_id)}
type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {chat.title ?? `Чат ${chat.chat_id}`}
</button> </button>
@@ -119,7 +119,7 @@
</script> </script>
{#if chatId === null} {#if chatId === null}
<EmptyState title="Аналитика" description="Откройте чат" /> <EmptyState description="Откройте чат" title="Аналитика" />
{:else} {:else}
<div class="analytics"> <div class="analytics">
<section> <section>
@@ -50,9 +50,9 @@
{#each row as cell, hour (hour)} {#each row as cell, hour (hour)}
<span <span
class="cell" class="cell"
class:empty={cell.total === 0}
style:--i={intensity(cell)}
title={cellTitle(day, hour, cell)} title={cellTitle(day, hour, cell)}
style:--i={intensity(cell)}
class:empty={cell.total === 0}
></span> ></span>
{/each} {/each}
</div> </div>
@@ -19,8 +19,8 @@
{#each volume as bucket (bucket.bucket)} {#each volume as bucket (bucket.bucket)}
<div <div
class="bar" class="bar"
style:height={`${(bucket.total / max) * 100}%`}
title={barTitle(bucket)} title={barTitle(bucket)}
style:height={`${(bucket.total / max) * 100}%`}
> >
<div class="seg out" style:flex-grow={bucket.outgoing}></div> <div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div> <div class="seg in" style:flex-grow={bucket.incoming}></div>
@@ -64,7 +64,7 @@
{#each items as item (item.unique_id)} {#each items as item (item.unique_id)}
<figure title={formatFull(item.first_seen_at)}> <figure title={formatFull(item.first_seen_at)}>
{#if urls[item.unique_id]} {#if urls[item.unique_id]}
<img src={urls[item.unique_id]} alt={name}> <img alt={name} src={urls[item.unique_id]}>
{:else} {:else}
<div class="placeholder"></div> <div class="placeholder"></div>
{/if} {/if}
@@ -49,8 +49,8 @@
const now = new Date(); const now = new Date();
const currentMonth: YearMonth = { const currentMonth: YearMonth = {
year: now.getUTCFullYear(),
month: now.getUTCMonth(), month: now.getUTCMonth(),
year: now.getUTCFullYear(),
}; };
const byKey = $derived( const byKey = $derived(
@@ -94,7 +94,7 @@
function shift(months: number) { function shift(months: number) {
const total = Math.min(index(view) + months, index(currentMonth)); const total = Math.min(index(view) + months, index(currentMonth));
cursor = { year: Math.floor(total / 12), month: total % 12 }; cursor = { month: total % 12, year: Math.floor(total / 12) };
} }
const lead = $derived( const lead = $derived(
@@ -105,12 +105,12 @@
const cells = $derived.by<DayCell[]>(() => { const cells = $derived.by<DayCell[]>(() => {
const count = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate(); const count = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate();
const result: DayCell[] = []; const result: DayCell[] = [];
for (let day = 1; day <= count; day++) { for (let day = 1; day <= count; day += 1) {
const month = String(view.month + 1).padStart(2, "0"); const month = String(view.month + 1).padStart(2, "0");
const date = String(day).padStart(2, "0"); const date = String(day).padStart(2, "0");
const key = `${view.year}-${month}-${date}`; const key = `${view.year}-${month}-${date}`;
const value = byKey.get(key) ?? 0; const value = byKey.get(key) ?? 0;
result.push({ day, key, count: value, level: level(value) }); result.push({ count: value, day, key, level: level(value) });
} }
return result; return result;
}); });
@@ -174,37 +174,37 @@
<div class="calendar"> <div class="calendar">
<header class="nav"> <header class="nav">
<button <button
type="button" aria-label="Предыдущий год"
class="step" class="step"
onclick={() => shift(-12)} onclick={() => shift(-12)}
aria-label="Предыдущий год" type="button"
> >
« «
</button> </button>
<button <button
type="button" aria-label="Предыдущий месяц"
class="step" class="step"
onclick={() => shift(-1)} onclick={() => shift(-1)}
aria-label="Предыдущий месяц" type="button"
> >
<Icon name="arrow-left" /> <Icon name="arrow-left" />
</button> </button>
<span class="label">{MONTHS[view.month]} {view.year}</span> <span class="label">{MONTHS[view.month]} {view.year}</span>
<button <button
type="button"
class="step"
onclick={() => shift(1)}
disabled={!canNext}
aria-label="Следующий месяц" aria-label="Следующий месяц"
class="step"
disabled={!canNext}
onclick={() => shift(1)}
type="button"
> >
<Icon name="arrow-right" /> <Icon name="arrow-right" />
</button> </button>
<button <button
type="button"
class="step"
onclick={() => shift(12)}
disabled={!canNext}
aria-label="Следующий год" aria-label="Следующий год"
class="step"
disabled={!canNext}
onclick={() => shift(12)}
type="button"
> >
» »
</button> </button>
@@ -223,10 +223,10 @@
{#each cells as cell (cell.key)} {#each cells as cell (cell.key)}
{#if cell.count > 0} {#if cell.count > 0}
<button <button
type="button"
class="cell day lvl-{cell.level}" class="cell day lvl-{cell.level}"
title={`${cell.count} сообщений`}
onclick={() => jump(cell)} onclick={() => jump(cell)}
title={`${cell.count} сообщений`}
type="button"
> >
{cell.day} {cell.day}
</button> </button>
@@ -136,9 +136,9 @@
{#if storyCount > 0} {#if storyCount > 0}
<section> <section>
<button <button
type="button"
class="entry" class="entry"
onclick={() => ui.openPanel("stories")} onclick={() => ui.openPanel("stories")}
type="button"
> >
<span class="entry-icon"><Icon name="play-story" /></span> <span class="entry-icon"><Icon name="play-story" /></span>
<span class="entry-label">Сторис</span> <span class="entry-label">Сторис</span>
@@ -148,7 +148,7 @@
</section> </section>
{/if} {/if}
<AvatarHistory kind={avatarKind} id={chatId} {name} /> <AvatarHistory id={chatId} kind={avatarKind} {name} />
{#if changes.length > 1} {#if changes.length > 1}
<section> <section>
@@ -16,12 +16,12 @@
type Tab = "info" | "media" | "files" | "links" | "stories" | "calendar"; type Tab = "info" | "media" | "files" | "links" | "stories" | "calendar";
const TABS: { id: Tab; icon: string; label: string }[] = [ const TABS: { id: Tab; icon: string; label: string }[] = [
{ id: "info", icon: "info", label: "Инфо" }, { icon: "info", id: "info", label: "Инфо" },
{ id: "media", icon: "photo", label: "Медиа" }, { icon: "photo", id: "media", label: "Медиа" },
{ id: "files", icon: "document", label: "Файлы" }, { icon: "document", id: "files", label: "Файлы" },
{ id: "links", icon: "link", label: "Ссылки" }, { icon: "link", id: "links", label: "Ссылки" },
{ id: "stories", icon: "play-story", label: "Сторис" }, { icon: "play-story", id: "stories", label: "Сторис" },
{ id: "calendar", icon: "calendar", label: "Календарь" }, { icon: "calendar", id: "calendar", label: "Календарь" },
]; ];
const MEDIA_KINDS = ["photo", "video", "animation", "video_note"]; const MEDIA_KINDS = ["photo", "video", "animation", "video_note"];
@@ -75,17 +75,17 @@
</script> </script>
{#if chatId === null} {#if chatId === null}
<EmptyState title="Профиль" description="Откройте чат" /> <EmptyState description="Откройте чат" title="Профиль" />
{:else} {:else}
<div class="profile"> <div class="profile">
<header class="hero"> <header class="hero">
<Avatar <Avatar
name={title} avatar={{ id: chatId, kind: avatarKind }}
colorKey={chatId} colorKey={chatId}
size={5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false} deleted={peer?.is_deleted_account ?? false}
{hasAvatar}
name={title}
size={5}
/> />
<h2 class="name">{title}</h2> <h2 class="name">{title}</h2>
<span class="sub">{subtitle}</span> <span class="sub">{subtitle}</span>
@@ -94,13 +94,13 @@
<nav class="tabs"> <nav class="tabs">
{#each TABS as item (item.id)} {#each TABS as item (item.id)}
<button <button
type="button" aria-label={item.label}
class="tab" class="tab"
class:active={tab === item.id}
onclick={() => { onclick={() => {
tab = item.id; tab = item.id;
}} }}
aria-label={item.label} type="button"
class:active={tab === item.id}
> >
<Icon name={item.icon} /> <Icon name={item.icon} />
</button> </button>
@@ -109,7 +109,7 @@
<div class="tab-body"> <div class="tab-body">
{#if tab === "info"} {#if tab === "info"}
<ProfileInfo {chatId} {isDm} {peer} name={title} {avatarKind} /> <ProfileInfo {avatarKind} {chatId} {isDm} name={title} {peer} />
{:else if tab === "media"} {:else if tab === "media"}
<SharedMedia {chatId} kinds={MEDIA_KINDS} layout="grid" /> <SharedMedia {chatId} kinds={MEDIA_KINDS} layout="grid" />
{:else if tab === "files"} {:else if tab === "files"}
@@ -57,7 +57,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
items = []; items = [];
done = false; done = false;
loading = false; loading = false;
@@ -89,16 +89,16 @@
<ul class="list"> <ul class="list">
{#each items as item (`${item.message_id}:${item.url}`)} {#each items as item (`${item.message_id}:${item.url}`)}
<li> <li>
<button type="button" onclick={() => open(item.message_id)}> <button onclick={() => open(item.message_id)} type="button">
<span class="link-icon"><Icon name="link" /></span> <span class="link-icon"><Icon name="link" /></span>
<span class="meta"> <span class="meta">
<span class="title">{item.web_title ?? host(item.url)}</span> <span class="title">{item.web_title ?? host(item.url)}</span>
<a <a
class="url" class="url"
href={safeHref(item.url)} href={safeHref(item.url)}
target="_blank"
rel="noopener"
onclick={(event) => event.stopPropagation()} onclick={(event) => event.stopPropagation()}
rel="noopener"
target="_blank"
> >
{item.url} {item.url}
</a> </a>
@@ -117,10 +117,10 @@
{#if !done && items.length > 0} {#if !done && items.length > 0}
<button <button
type="button"
class="more" class="more"
onclick={() => loadMore()}
disabled={loading} disabled={loading}
onclick={() => loadMore()}
type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? "Загрузка…" : "Показать ещё"}
</button> </button>
@@ -45,12 +45,12 @@
const viewerItems = $derived<ViewerItem[]>( const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({ items.map((item) => ({
messageId: item.message_id,
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded, downloaded: item.downloaded,
fileName: item.file_name, fileName: item.file_name,
fileSize: item.file_size, fileSize: item.file_size,
kind: item.kind,
mediaId: item.id,
messageId: item.message_id,
mime: item.mime, mime: item.mime,
})) }))
); );
@@ -102,7 +102,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
items = []; items = [];
done = false; done = false;
loading = false; loading = false;
@@ -125,21 +125,21 @@
continue; continue;
} }
previews[item.id] = { previews[item.id] = {
state: "not-downloaded",
mediaId: item.id,
kind: item.kind, kind: item.kind,
mediaId: item.id,
state: "not-downloaded",
}; };
loadMediaItem({ loadMediaItem({
id: item.id,
message_id: item.message_id,
kind: item.kind,
downloaded: item.downloaded, downloaded: item.downloaded,
mime: item.mime, duration: null,
file_name: item.file_name, file_name: item.file_name,
file_size: item.file_size, file_size: item.file_size,
ttl_seconds: item.ttl_seconds,
duration: null,
height: null, height: null,
id: item.id,
kind: item.kind,
message_id: item.message_id,
mime: item.mime,
ttl_seconds: item.ttl_seconds,
width: null, width: null,
}).then((result) => { }).then((result) => {
if (active) { if (active) {
@@ -184,10 +184,10 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
class="tile" class="tile"
tabindex="0"
onclick={() => open(index)} onclick={() => open(index)}
tabindex="0"
type="button"
> >
{#if preview(item)?.state === "ready"} {#if preview(item)?.state === "ready"}
{@const ready = preview(item) as Extract< {@const ready = preview(item) as Extract<
@@ -196,15 +196,15 @@
>} >}
{#if visualKind(item.kind) === "video"} {#if visualKind(item.kind) === "video"}
<video <video
src={ready.url}
muted muted
playsinline playsinline
preload="metadata" preload="metadata"
src={ready.url}
use:poster use:poster
></video> ></video>
<span class="play"><Icon name="play" size="1.5rem" /></span> <span class="play"><Icon name="play" size="1.5rem" /></span>
{:else} {:else}
<img src={ready.url} alt=""> <img alt="" src={ready.url}>
{/if} {/if}
{:else} {:else}
<span class="ph" <span class="ph"
@@ -240,9 +240,9 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
tabindex="0"
onclick={() => open(index)} onclick={() => open(index)}
tabindex="0"
type="button"
> >
<span class="file-icon"><Icon name="document" /></span> <span class="file-icon"><Icon name="document" /></span>
<span class="meta"> <span class="meta">
@@ -280,20 +280,20 @@
{#if !done && items.length > 0} {#if !done && items.length > 0}
<button <button
type="button"
class="more" class="more"
onclick={() => loadMore()}
disabled={loading} disabled={loading}
onclick={() => loadMore()}
type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? "Загрузка…" : "Показать ещё"}
</button> </button>
{/if} {/if}
<MediaViewer <MediaViewer
bind:open={viewerOpen}
bind:index={viewerIndex}
{chatId} {chatId}
items={viewerItems} items={viewerItems}
bind:index={viewerIndex}
bind:open={viewerOpen}
/> />
<style lang="scss"> <style lang="scss">
@@ -22,7 +22,8 @@
let seq = 0; let seq = 0;
async function run(value: string, id: number) { async function run(value: string, id: number) {
const current = ++seq; seq += 1;
const current = seq;
loading = true; loading = true;
try { try {
const result = await searchMessages(value, { chat_id: id }); const result = await searchMessages(value, { chat_id: id });
@@ -47,7 +48,7 @@
clearTimeout(timer); clearTimeout(timer);
} }
if (value.length === 0 || chatId === null) { if (value.length === 0 || chatId === null) {
seq++; seq += 1;
hits = []; hits = [];
loading = false; loading = false;
return; return;
@@ -69,12 +70,12 @@
<div class="chat-search"> <div class="chat-search">
<div class="search-input"> <div class="search-input">
<Icon name="search" size="1.25rem" class="chat-search-icon" /> <Icon class="chat-search-icon" name="search" size="1.25rem" />
<input <input
type="text"
placeholder="Поиск в чате"
value={query}
oninput={onInput} oninput={onInput}
placeholder="Поиск в чате"
type="text"
value={query}
> >
</div> </div>
<div class="results custom-scroll"> <div class="results custom-scroll">
@@ -37,12 +37,12 @@
}); });
</script> </script>
<button type="button" class="Discover ListItem-button" use:ripple {onclick}> <button class="Discover ListItem-button" {onclick} type="button" use:ripple>
<Avatar <Avatar
name={title} avatar={{ id: item.chat_id, kind: avatarKind }}
colorKey={item.chat_id} colorKey={item.chat_id}
avatar={{ kind: avatarKind, id: item.chat_id }}
hasAvatar={item.has_avatar} hasAvatar={item.has_avatar}
name={title}
/> />
<div class="info"> <div class="info">
<h3 class="title">{title}</h3> <h3 class="title">{title}</h3>
@@ -15,17 +15,17 @@
</script> </script>
<div class="search-input"> <div class="search-input">
<Icon name="search" size="1.25rem" class="search-icon" /> <Icon class="search-icon" name="search" size="1.25rem" />
<input <input
bind:this={input}
type="text"
placeholder="Поиск"
value={search.query}
oninput={onInput}
onfocus={() => search.open()} onfocus={() => search.open()}
oninput={onInput}
placeholder="Поиск"
type="text"
value={search.query}
bind:this={input}
> >
{#if search.query} {#if search.query}
<button type="button" class="clear" onclick={clear} aria-label="Очистить"> <button aria-label="Очистить" class="clear" onclick={clear} type="button">
<Icon name="close" size="1.125rem" /> <Icon name="close" size="1.125rem" />
</button> </button>
{/if} {/if}
@@ -56,12 +56,12 @@
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat"); const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
</script> </script>
<button type="button" class="hit" use:ripple {onclick}> <button class="hit" {onclick} type="button" use:ripple>
<Avatar <Avatar
name={title} avatar={{ id: hit.chat_id, kind: avatarKind }}
colorKey={hit.chat_id} colorKey={hit.chat_id}
avatar={{ kind: avatarKind, id: hit.chat_id }}
hasAvatar={chat?.has_avatar ?? false} hasAvatar={chat?.has_avatar ?? false}
name={title}
size={3} size={3}
/> />
<div class="body"> <div class="body">
@@ -38,8 +38,8 @@
{#each search.chatHits as chat (chat.chat_id)} {#each search.chatHits as chat (chat.chat_id)}
<ChatListItem <ChatListItem
{chat} {chat}
selected={chat.chat_id === activeChatId}
onclick={() => openChat(chat.chat_id)} onclick={() => openChat(chat.chat_id)}
selected={chat.chat_id === activeChatId}
/> />
{/each} {/each}
{/if} {/if}
@@ -9,8 +9,8 @@
<a <a
class="line link" class="line link"
href="https://github.com/Ajaxy/telegram-tt" href="https://github.com/Ajaxy/telegram-tt"
target="_blank"
rel="noopener" rel="noopener"
target="_blank"
> >
UI портирован из Telegram A UI портирован из Telegram A
</a> </a>
@@ -31,10 +31,10 @@
let attempt = 0; let attempt = 0;
const hints: Record<Step, string> = { const hints: Record<Step, string> = {
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
phone: "Номер телефона в международном формате, например +79991234567.",
code: "Код отправлен в Telegram на этот номер.", code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.", password: "Аккаунт защищён двухэтапной аутентификацией.",
phone: "Номер телефона в международном формате, например +79991234567.",
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
}; };
const filled = $derived.by(() => { const filled = $derived.by(() => {
@@ -81,6 +81,7 @@
while (mine === attempt && state.stage === "qr") { while (mine === attempt && state.stage === "qr") {
loginId = state.login_id; loginId = state.login_id;
qrUrl = state.qr_url ?? ""; qrUrl = state.qr_url ?? "";
// biome-ignore lint/performance/noAwaitInLoops: qr login is polled step by step
state = await pollQrLogin(state.login_id); state = await pollQrLogin(state.login_id);
} }
if (mine === attempt) { if (mine === attempt) {
@@ -153,13 +154,13 @@
}); });
</script> </script>
<Dialog.Root bind:open {onOpenChange}> <Dialog.Root {onOpenChange} bind:open>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title> <Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть"> <Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -168,12 +169,12 @@
<p class="hint">{hints.qr}</p> <p class="hint">{hints.qr}</p>
<div class="qr-slot"> <div class="qr-slot">
{#if qrUrl} {#if qrUrl}
<QrCode value={qrUrl} label="QR-код для входа в Telegram" /> <QrCode label="QR-код для входа в Telegram" value={qrUrl} />
{/if} {/if}
</div> </div>
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button variant="text" pill onclick={usePhone}> <Button onclick={usePhone} pill variant="text">
Войти по номеру телефона Войти по номеру телефона
</Button> </Button>
</div> </div>
@@ -184,11 +185,11 @@
{#if step === "phone"} {#if step === "phone"}
<div class="input-group"> <div class="input-group">
<input <input
id="login-phone"
class="form-control"
type="tel"
autocomplete="tel" autocomplete="tel"
class="form-control"
id="login-phone"
placeholder="+7 999 123-45-67" placeholder="+7 999 123-45-67"
type="tel"
bind:value={phone} bind:value={phone}
> >
<label for="login-phone">Номер телефона</label> <label for="login-phone">Номер телефона</label>
@@ -196,12 +197,12 @@
{:else if step === "code"} {:else if step === "code"}
<div class="input-group"> <div class="input-group">
<input <input
id="login-code"
class="form-control"
type="text"
inputmode="numeric"
autocomplete="one-time-code" autocomplete="one-time-code"
class="form-control"
id="login-code"
inputmode="numeric"
placeholder="12345" placeholder="12345"
type="text"
bind:value={code} bind:value={code}
> >
<label for="login-code">Код подтверждения</label> <label for="login-code">Код подтверждения</label>
@@ -209,11 +210,11 @@
{:else} {:else}
<div class="input-group"> <div class="input-group">
<input <input
id="login-password"
class="form-control"
type="password"
autocomplete="current-password" autocomplete="current-password"
class="form-control"
id="login-password"
placeholder="Пароль" placeholder="Пароль"
type="password"
bind:value={password} bind:value={password}
> >
<label for="login-password">Облачный пароль</label> <label for="login-password">Облачный пароль</label>
@@ -222,13 +223,13 @@
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
{#if step === "phone"} {#if step === "phone"}
<Button variant="text" pill onclick={useQr}>QR-код</Button> <Button onclick={useQr} pill variant="text">QR-код</Button>
{/if} {/if}
<Button <Button
type="submit"
pill
loading={busy}
disabled={busy || !filled} disabled={busy || !filled}
loading={busy}
pill
type="submit"
> >
{step === "phone" ? "Отправить код" : "Продолжить"} {step === "phone" ? "Отправить код" : "Продолжить"}
</Button> </Button>
@@ -3,19 +3,19 @@
import { theme } from "$lib/stores/theme.svelte"; import { theme } from "$lib/stores/theme.svelte";
const options = [ const options = [
{ value: "light", label: "Светлая" }, { label: "Светлая", value: "light" },
{ value: "dark", label: "Тёмная" }, { label: "Тёмная", value: "dark" },
{ value: "system", label: "Системная" }, { label: "Системная", value: "system" },
] as const; ] as const;
</script> </script>
<div class="appearance"> <div class="appearance">
{#each options as option (option.value)} {#each options as option (option.value)}
<button <button
type="button"
class="theme-row" class="theme-row"
use:ripple
onclick={() => theme.set(option.value)} onclick={() => theme.set(option.value)}
type="button"
use:ripple
> >
<span class="dot" class:on={theme.preference === option.value}></span> <span class="dot" class:on={theme.preference === option.value}></span>
<span class="label">{option.label}</span> <span class="label">{option.label}</span>
@@ -75,8 +75,8 @@
{#if current} {#if current}
<div class="profile"> <div class="profile">
<Avatar <Avatar
name={accountName(current)}
colorKey={current.account_id} colorKey={current.account_id}
name={accountName(current)}
size={5} size={5}
/> />
<div class="name">{accountName(current)}</div> <div class="name">{accountName(current)}</div>
@@ -93,21 +93,21 @@
<div class="switch"> <div class="switch">
{#each others as account (account.account_id)} {#each others as account (account.account_id)}
<button <button
type="button"
class="switch-row" class="switch-row"
use:ripple
onclick={() => accounts.select(account.account_id)} onclick={() => accounts.select(account.account_id)}
type="button"
use:ripple
> >
<Avatar <Avatar
name={accountName(account)}
colorKey={account.account_id} colorKey={account.account_id}
name={accountName(account)}
size={2.25} size={2.25}
/> />
<span>{accountName(account)}</span> <span>{accountName(account)}</span>
{#if !account.is_active} {#if !account.is_active}
<span class="tag">архив</span> <span class="tag">архив</span>
{/if} {/if}
<Icon name="arrow-right" size="1rem" class="chevron" /> <Icon class="chevron" name="arrow-right" size="1rem" />
</button> </button>
{/each} {/each}
</div> </div>
@@ -118,8 +118,8 @@
<SettingsItem <SettingsItem
icon="active-sessions" icon="active-sessions"
label="Имя устройства" label="Имя устройства"
value={deviceLabel}
onclick={openRename} onclick={openRename}
value={deviceLabel}
/> />
{/if} {/if}
<SettingsItem <SettingsItem
@@ -149,7 +149,7 @@
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title> <Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть"> <Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -162,11 +162,11 @@
</p> </p>
<div class="input-group"> <div class="input-group">
<input <input
id="device-name"
class="form-control" class="form-control"
type="text" id="device-name"
maxlength={DEVICE_LIMIT} maxlength={DEVICE_LIMIT}
placeholder={DEFAULT_DEVICE} placeholder={DEFAULT_DEVICE}
type="text"
bind:value={deviceName} bind:value={deviceName}
> >
<label for="device-name">Название</label> <label for="device-name">Название</label>
@@ -174,10 +174,10 @@
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button <Button
type="submit"
pill
loading={busy}
disabled={busy || deviceName.trim().length === 0} disabled={busy || deviceName.trim().length === 0}
loading={busy}
pill
type="submit"
> >
Сохранить Сохранить
</Button> </Button>
@@ -193,7 +193,7 @@
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title> <Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть"> <Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -206,15 +206,15 @@
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button <Button
variant="text"
pill
onclick={() => { onclick={() => {
confirming = false; confirming = false;
}} }}
pill
variant="text"
> >
Отмена Отмена
</Button> </Button>
<Button variant="danger" pill loading={busy} onclick={logout}> <Button loading={busy} onclick={logout} pill variant="danger">
Выйти Выйти
</Button> </Button>
</div> </div>
@@ -17,11 +17,11 @@
<div class="settings"> <div class="settings">
<header class="settings-header"> <header class="settings-header">
<Button <Button
variant="translucent" aria-label="Назад"
onclick={() => ui.closeSettings()}
round round
smaller smaller
onclick={() => ui.closeSettings()} variant="translucent"
aria-label="Назад"
> >
<Icon name="arrow-left" /> <Icon name="arrow-left" />
</Button> </Button>
@@ -14,14 +14,14 @@
</script> </script>
<button <button
type="button"
class="settings-item" class="settings-item"
class:soon
use:ripple
disabled={soon} disabled={soon}
{onclick} {onclick}
type="button"
class:soon
use:ripple
> >
<Icon name={icon} size="1.5rem" class="leading" /> <Icon class="leading" name={icon} size="1.5rem" />
<span class="label">{label}</span> <span class="label">{label}</span>
{#if soon} {#if soon}
<span class="soon-tag">скоро</span> <span class="soon-tag">скоро</span>
@@ -29,7 +29,7 @@
<span class="value">{value}</span> <span class="value">{value}</span>
{/if} {/if}
{#if onclick && !soon} {#if onclick && !soon}
<Icon name="arrow-right" size="1rem" class="chevron" /> <Icon class="chevron" name="arrow-right" size="1rem" />
{/if} {/if}
</button> </button>
@@ -88,7 +88,8 @@
showQr = false; showQr = false;
expiresIn = null; expiresIn = null;
maxDownloads = null; maxDownloads = null;
const current = ++token; token += 1;
const current = token;
try { try {
const found = await lookupShare(target); const found = await lookupShare(target);
if (current === token) { if (current === token) {
@@ -172,7 +173,7 @@
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title> <Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть"> <Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -195,11 +196,11 @@
<div class="qr-row"> <div class="qr-row">
<button <button
type="button"
class="qr-toggle" class="qr-toggle"
onclick={() => { onclick={() => {
showQr = !showQr; showQr = !showQr;
}} }}
type="button"
> >
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" /> <Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"} {showQr ? "Скрыть QR-код" : "Показать QR-код"}
@@ -207,7 +208,7 @@
</div> </div>
{#if showQr} {#if showQr}
<div class="qr-slot"> <div class="qr-slot">
<QrCode value={shareUrl(share)} label="QR-код ссылки на файл" /> <QrCode label="QR-код ссылки на файл" value={shareUrl(share)} />
</div> </div>
{/if} {/if}
@@ -222,12 +223,12 @@
<div class="segments"> <div class="segments">
{#each limitChoices as choice (choice.label)} {#each limitChoices as choice (choice.label)}
<button <button
type="button"
class="segment" class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => { onclick={() => {
maxDownloads = choice.value; maxDownloads = choice.value;
}} }}
type="button"
class:selected={maxDownloads === choice.value}
> >
{choice.label} {choice.label}
</button> </button>
@@ -240,12 +241,12 @@
<div class="segments"> <div class="segments">
{#each liveExpiryChoices as choice (choice.label)} {#each liveExpiryChoices as choice (choice.label)}
<button <button
type="button"
class="segment" class="segment"
class:selected={expiresIn === choice.value}
onclick={() => { onclick={() => {
expiresIn = choice.value; expiresIn = choice.value;
}} }}
type="button"
class:selected={expiresIn === choice.value}
> >
{choice.label} {choice.label}
</button> </button>
@@ -268,12 +269,12 @@
<div class="segments"> <div class="segments">
{#each expiryChoices as choice (choice.label)} {#each expiryChoices as choice (choice.label)}
<button <button
type="button"
class="segment" class="segment"
class:selected={expiresIn === choice.value}
onclick={() => { onclick={() => {
expiresIn = choice.value; expiresIn = choice.value;
}} }}
type="button"
class:selected={expiresIn === choice.value}
> >
{choice.label} {choice.label}
</button> </button>
@@ -286,12 +287,12 @@
<div class="segments"> <div class="segments">
{#each limitChoices as choice (choice.label)} {#each limitChoices as choice (choice.label)}
<button <button
type="button"
class="segment" class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => { onclick={() => {
maxDownloads = choice.value; maxDownloads = choice.value;
}} }}
type="button"
class:selected={maxDownloads === choice.value}
> >
{choice.label} {choice.label}
</button> </button>
@@ -309,12 +310,12 @@
{#if !loading} {#if !loading}
<div class="dialog-actions"> <div class="dialog-actions">
{#if share} {#if share}
<Button variant="danger" pill loading={busy} onclick={revoke}> <Button loading={busy} onclick={revoke} pill variant="danger">
Отозвать Отозвать
</Button> </Button>
<Button pill loading={busy} onclick={applyLimits}>Сохранить</Button> <Button loading={busy} onclick={applyLimits} pill>Сохранить</Button>
{:else} {:else}
<Button pill fluid loading={busy} onclick={publish}> <Button fluid loading={busy} onclick={publish} pill>
Открыть доступ Открыть доступ
</Button> </Button>
{/if} {/if}
@@ -30,7 +30,7 @@
} }
</script> </script>
<button type="button" class="link-field" class:copied onclick={copy}> <button class="link-field" onclick={copy} type="button" class:copied>
<span class="url">{url}</span> <span class="url">{url}</span>
<span class="action"> <span class="action">
<Icon name={copied ? "check" : "copy"} size="1.125rem" /> <Icon name={copied ? "check" : "copy"} size="1.125rem" />
@@ -27,9 +27,9 @@
const stateLabels: Record<string, string> = { const stateLabels: Record<string, string> = {
active: "Открыт", active: "Открыт",
revoked: "Отозван",
expired: "Истёк",
exhausted: "Лимит исчерпан", exhausted: "Лимит исчерпан",
expired: "Истёк",
revoked: "Отозван",
}; };
let items = $state<FileShare[]>([]); let items = $state<FileShare[]>([]);
@@ -51,7 +51,8 @@
); );
async function load() { async function load() {
const current = ++token; token += 1;
const current = token;
const activeOnly = filter === "active"; const activeOnly = filter === "active";
loading = true; loading = true;
try { try {
@@ -134,31 +135,31 @@
<div class="toolbar"> <div class="toolbar">
<div class="tabs"> <div class="tabs">
<button <button
type="button"
class="tab" class="tab"
class:selected={filter === "active"}
onclick={() => { onclick={() => {
filter = "active"; filter = "active";
}} }}
type="button"
class:selected={filter === "active"}
> >
Открытые{activeCount ? ` · ${activeCount}` : ""} Открытые{activeCount ? ` · ${activeCount}` : ""}
</button> </button>
<button <button
type="button"
class="tab" class="tab"
class:selected={filter === "all"}
onclick={() => { onclick={() => {
filter = "all"; filter = "all";
}} }}
type="button"
class:selected={filter === "all"}
> >
Все Все
</button> </button>
</div> </div>
<button <button
type="button"
class="reload"
aria-label="Обновить" aria-label="Обновить"
class="reload"
onclick={() => load()} onclick={() => load()}
type="button"
> >
<Icon name="reload" size="1.125rem" /> <Icon name="reload" size="1.125rem" />
</button> </button>
@@ -168,8 +169,8 @@
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if visible.length === 0} {:else if visible.length === 0}
<EmptyState <EmptyState
title="Ничего не открыто"
description="Откройте доступ к файлу из меню медиа" description="Откройте доступ к файлу из меню медиа"
title="Ничего не открыто"
/> />
{:else} {:else}
<ul class="list"> <ul class="list">
@@ -180,11 +181,11 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
class="head" class="head"
onclick={() => { onclick={() => {
expanded = expanded === share.id ? null : share.id; expanded = expanded === share.id ? null : share.id;
}} }}
type="button"
> >
<span class="glyph" class:muted={state !== "active"}> <span class="glyph" class:muted={state !== "active"}>
<Icon <Icon
@@ -248,23 +249,23 @@
{/if} {/if}
<div class="actions"> <div class="actions">
{#if share.message_id !== null || share.peer_id !== null} {#if share.message_id !== null || share.peer_id !== null}
<button type="button" class="pill" onclick={() => jump(share)}> <button class="pill" onclick={() => jump(share)} type="button">
<Icon name="reply" size="1rem" />К сообщению <Icon name="reply" size="1rem" />К сообщению
</button> </button>
{/if} {/if}
{#if state === "active"} {#if state === "active"}
<button <button
type="button"
class="pill danger" class="pill danger"
onclick={() => revoke(share)} onclick={() => revoke(share)}
type="button"
> >
<Icon name="link-broken" size="1rem" />Отозвать <Icon name="link-broken" size="1rem" />Отозвать
</button> </button>
{:else} {:else}
<button <button
type="button"
class="pill" class="pill"
onclick={() => reissue(share)} onclick={() => reissue(share)}
type="button"
> >
<Icon name="replace" size="1rem" />Новая ссылка <Icon name="replace" size="1rem" />Новая ссылка
</button> </button>
@@ -70,7 +70,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
const current = token; const current = token;
loading = true; loading = true;
items = []; items = [];
@@ -90,7 +90,7 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState title="Callback-данные" description="Сообщение не выбрано" /> <EmptyState description="Сообщение не выбрано" title="Callback-данные" />
{:else if loading && items.length === 0} {:else if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if items.length === 0} {:else if items.length === 0}
@@ -106,10 +106,10 @@
</div> </div>
{#if value} {#if value}
<button <button
type="button" aria-label="Скопировать"
class="copy" class="copy"
onclick={() => copy(item.position, value)} onclick={() => copy(item.position, value)}
aria-label="Скопировать" type="button"
> >
<Icon name={copied === item.position ? "check" : "copy"} /> <Icon name={copied === item.position ? "check" : "copy"} />
</button> </button>
@@ -36,7 +36,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
const current = token; const current = token;
loading = true; loading = true;
items = []; items = [];
@@ -56,7 +56,7 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState title="Ссылки" description="Сообщение не выбрано" /> <EmptyState description="Сообщение не выбрано" title="Ссылки" />
{:else if loading && items.length === 0} {:else if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if items.length === 0} {:else if items.length === 0}
@@ -73,8 +73,8 @@
<a <a
class="url" class="url"
href={safeHref(item.url)} href={safeHref(item.url)}
target="_blank"
rel="noopener" rel="noopener"
target="_blank"
> >
{item.url} {item.url}
</a> </a>
@@ -44,7 +44,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
const current = token; const current = token;
loading = true; loading = true;
items = []; items = [];
@@ -65,7 +65,7 @@
</script> </script>
{#if chatId === null || messageId === null} {#if chatId === null || messageId === null}
<EmptyState title="Реакции" description="Сообщение не выбрано" /> <EmptyState description="Сообщение не выбрано" title="Реакции" />
{:else if loading && items.length === 0} {:else if loading && items.length === 0}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else if items.length === 0} {:else if items.length === 0}
@@ -92,11 +92,11 @@
{@const peer = peers.get(reactor.peer_id)} {@const peer = peers.get(reactor.peer_id)}
<li class:removed={reactor.removed_at !== null}> <li class:removed={reactor.removed_at !== null}>
<Avatar <Avatar
name={peer ? peerName(peer) : String(reactor.peer_id)} avatar={{ id: reactor.peer_id, kind: "peer" }}
colorKey={peerColorIndex(reactor.peer_id)} colorKey={peerColorIndex(reactor.peer_id)}
size={2.25}
avatar={{ kind: "peer", id: reactor.peer_id }}
hasAvatar={peer?.has_avatar ?? false} hasAvatar={peer?.has_avatar ?? false}
name={peer ? peerName(peer) : String(reactor.peer_id)}
size={2.25}
/> />
<span class="info"> <span class="info">
<span class="name" <span class="name"
@@ -70,23 +70,23 @@
} }
const peerById = new Map(peers.map((peer) => [peer.peer_id, peer])); const peerById = new Map(peers.map((peer) => [peer.peer_id, peer]));
const chatById = new Map(fetched.map((item) => [item.chat_id, item])); const chatById = new Map(fetched.map((item) => [item.chat_id, item]));
groups = [...byPeer.entries()].map(([peerId, stories]) => { groups = [...byPeer.entries()].map(([peerId, peerStories]) => {
if (peerId > 0) { if (peerId > 0) {
const peer = peerById.get(peerId) ?? null; const peer = peerById.get(peerId) ?? null;
return { return {
peerId, hasAvatar: Boolean(peer?.has_avatar),
kind: "peer" as const, kind: "peer" as const,
name: peerName(peer) || `ID ${peerId}`, name: peerName(peer) || `ID ${peerId}`,
hasAvatar: Boolean(peer?.has_avatar), peerId,
stories, stories,
}; };
} }
const chat = chatById.get(peerId); const chat = chatById.get(peerId);
return { return {
peerId, hasAvatar: Boolean(chat?.has_avatar),
kind: "chat" as const, kind: "chat" as const,
name: chat?.title ?? `Chat ${peerId}`, name: chat?.title ?? `Chat ${peerId}`,
hasAvatar: Boolean(chat?.has_avatar), peerId,
stories, stories,
}; };
}); });
@@ -102,7 +102,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
groups = []; groups = [];
loading = false; loading = false;
for (const key of Object.keys(previews)) { for (const key of Object.keys(previews)) {
@@ -176,17 +176,17 @@
<section class="group"> <section class="group">
<header class="group-head"> <header class="group-head">
<button <button
type="button"
class="group-toggle"
aria-expanded={Boolean(expanded[group.peerId])} aria-expanded={Boolean(expanded[group.peerId])}
class="group-toggle"
onclick={() => toggle(group.peerId)} onclick={() => toggle(group.peerId)}
type="button"
> >
<Avatar <Avatar
name={group.name} avatar={{ id: group.peerId, kind: group.kind }}
colorKey={group.peerId} colorKey={group.peerId}
size={2}
avatar={{ kind: group.kind, id: group.peerId }}
hasAvatar={group.hasAvatar} hasAvatar={group.hasAvatar}
name={group.name}
size={2}
/> />
<span class="group-name">{group.name}</span> <span class="group-name">{group.name}</span>
<span class="group-count">{group.stories.length}</span> <span class="group-count">{group.stories.length}</span>
@@ -195,12 +195,12 @@
</span> </span>
</button> </button>
<Button <Button
variant="translucent" aria-label="Загрузить старые сторис"
round
smaller
loading={backfilling === group.peerId} loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)} onclick={() => backfill(group.peerId)}
aria-label="Загрузить старые сторис" round
smaller
variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name="cloud-download" />
</Button> </Button>
@@ -209,9 +209,9 @@
<div class="grid"> <div class="grid">
{#each group.stories as item, index (item.story_id)} {#each group.stories as item, index (item.story_id)}
<StoryTile <StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(group, index)} onopen={() => openViewer(group, index)}
preview={previews[item.story_id]}
story={item}
/> />
{/each} {/each}
</div> </div>
@@ -220,8 +220,8 @@
{/each} {/each}
<StoryViewer <StoryViewer
peerId={viewerPeerId}
items={viewerItems} items={viewerItems}
peerId={viewerPeerId}
bind:index={viewerIndex} bind:index={viewerIndex}
bind:open={viewerOpen} bind:open={viewerOpen}
/> />
@@ -65,7 +65,7 @@
return; return;
} }
untrack(() => { untrack(() => {
token++; token += 1;
items = []; items = [];
done = false; done = false;
loading = false; loading = false;
@@ -118,7 +118,7 @@
} }
async function reload(id: number) { async function reload(id: number) {
token++; token += 1;
items = []; items = [];
done = false; done = false;
loading = false; loading = false;
@@ -127,24 +127,24 @@
</script> </script>
{#if peerId === null} {#if peerId === null}
<EmptyState title="Сторис" description="Откройте чат" /> <EmptyState description="Откройте чат" title="Сторис" />
{:else} {:else}
<div class="toolbar"> <div class="toolbar">
<Button <Button
variant="secondary"
pill
smaller
loading={backfilling} loading={backfilling}
onclick={() => peerId !== null && backfill(peerId)} onclick={() => peerId !== null && backfill(peerId)}
pill
smaller
variant="secondary"
> >
<Icon name="cloud-download" />Загрузить старые <Icon name="cloud-download" />Загрузить старые
</Button> </Button>
<Button <Button
variant="translucent" aria-label="Обновить"
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
round round
smaller smaller
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)} variant="translucent"
aria-label="Обновить"
> >
<Icon name="reload" /> <Icon name="reload" />
</Button> </Button>
@@ -155,35 +155,35 @@
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState <EmptyState
title="Нет сторис"
description="Нажмите «Загрузить старые», чтобы забрать архив" description="Нажмите «Загрузить старые», чтобы забрать архив"
title="Нет сторис"
/> />
{/if} {/if}
{:else} {:else}
<div class="grid"> <div class="grid">
{#each items as item, index (item.story_id)} {#each items as item, index (item.story_id)}
<StoryTile <StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(index)} onopen={() => openViewer(index)}
preview={previews[item.story_id]}
story={item}
/> />
{/each} {/each}
</div> </div>
{#if !done} {#if !done}
<button <button
type="button"
class="more" class="more"
onclick={() => peerId !== null && loadMore(peerId)}
disabled={loading} disabled={loading}
onclick={() => peerId !== null && loadMore(peerId)}
type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? "Загрузка…" : "Показать ещё"}
</button> </button>
{/if} {/if}
<StoryViewer <StoryViewer
{peerId}
{items} {items}
{peerId}
bind:index={viewerIndex} bind:index={viewerIndex}
bind:open={viewerOpen} bind:open={viewerOpen}
/> />
@@ -48,23 +48,23 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
class="tile" class="tile"
class:expired={story.deleted}
onclick={onopen} onclick={onopen}
type="button"
class:expired={story.deleted}
> >
{#if preview} {#if preview}
{#if story.media_kind === "video"} {#if story.media_kind === "video"}
<video <video
src={preview}
muted muted
playsinline playsinline
preload="metadata" preload="metadata"
src={preview}
use:poster use:poster
></video> ></video>
<span class="play"><Icon name="play" size="1.5rem" /></span> <span class="play"><Icon name="play" size="1.5rem" /></span>
{:else} {:else}
<img src={preview} alt=""> <img alt="" src={preview}>
{/if} {/if}
{:else} {:else}
<span class="ph"><Icon name="play-story" /></span> <span class="ph"><Icon name="play-story" /></span>
@@ -97,7 +97,8 @@
ready = false; ready = false;
url = null; url = null;
videoProgress = 0; videoProgress = 0;
const current = ++token; token += 1;
const current = token;
const next = await loadStoryMedia(item.peer_id, item.story_id); const next = await loadStoryMedia(item.peer_id, item.story_id);
if (current === token) { if (current === token) {
url = next; url = next;
@@ -195,9 +196,9 @@
{#if ready && !isVideo} {#if ready && !isVideo}
<div <div
class="fill anim" class="fill anim"
class:paused={held}
style="animation-duration: {PHOTO_SECONDS}s"
onanimationend={advance} onanimationend={advance}
style="animation-duration: {PHOTO_SECONDS}s"
class:paused={held}
></div> ></div>
{:else if isVideo} {:else if isVideo}
<div class="fill" style="width: {videoProgress * 100}%"></div> <div class="fill" style="width: {videoProgress * 100}%"></div>
@@ -213,12 +214,12 @@
<div class="head-actions"> <div class="head-actions">
{#if isVideo} {#if isVideo}
<button <button
type="button"
class="round"
aria-label={muted ? "Включить звук" : "Выключить звук"} aria-label={muted ? "Включить звук" : "Выключить звук"}
class="round"
onclick={() => { onclick={() => {
muted = !muted; muted = !muted;
}} }}
type="button"
> >
<Icon name={muted ? "speaker-muted-story" : "speaker-story"} /> <Icon name={muted ? "speaker-muted-story" : "speaker-story"} />
</button> </button>
@@ -228,10 +229,10 @@
{#snippet children({ props })} {#snippet children({ props })}
<button <button
{...props} {...props}
type="button"
class="round"
aria-label="Скачать сторис" aria-label="Скачать сторис"
class="round"
onclick={save} onclick={save}
type="button"
> >
<Icon name={saving ? "timer" : "download"} /> <Icon name={saving ? "timer" : "download"} />
</button> </button>
@@ -246,7 +247,7 @@
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
{/if} {/if}
<Dialog.Close class="round" aria-label="Закрыть"> <Dialog.Close aria-label="Закрыть" class="round">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
</Dialog.Close> </Dialog.Close>
</div> </div>
@@ -256,28 +257,28 @@
{#if loading} {#if loading}
<Spinner color="white" /> <Spinner color="white" />
{:else if url && isVideo} {:else if url && isVideo}
<!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions -->
<video <video
bind:this={video}
class="media"
src={url}
autoplay autoplay
class="media"
{muted} {muted}
playsinline onended={advance}
onloadeddata={() => { onloadeddata={() => {
ready = true; ready = true;
}} }}
ontimeupdate={onVideoTime} ontimeupdate={onVideoTime}
onended={advance} playsinline
src={url}
bind:this={video}
></video> ></video>
{:else if url} {:else if url}
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: onload is not a user interaction -->
<img <img
class="media"
src={url}
alt="" alt=""
class="media"
onload={() => { onload={() => {
ready = true; ready = true;
}} }}
src={url}
> >
{:else} {:else}
<p class="missing">Медиа недоступно</p> <p class="missing">Медиа недоступно</p>
@@ -298,20 +299,20 @@
{/if} {/if}
<button <button
type="button"
class="tap prev"
aria-label="Назад" aria-label="Назад"
onpointerdown={startHold} class="tap prev"
onpointercancel={releaseHold}
onclick={() => onTap(-1)} onclick={() => onTap(-1)}
onpointercancel={releaseHold}
onpointerdown={startHold}
type="button"
></button> ></button>
<button <button
type="button"
class="tap next"
aria-label="Вперёд" aria-label="Вперёд"
onpointerdown={startHold} class="tap next"
onpointercancel={releaseHold}
onclick={() => onTap(1)} onclick={() => onTap(1)}
onpointercancel={releaseHold}
onpointerdown={startHold}
type="button"
></button> ></button>
{#if held} {#if held}
+2 -2
View File
@@ -54,15 +54,15 @@
<div <div
class="Avatar" class="Avatar"
class:deleted
style=" style="
--avatar-size: {size}rem; --avatar-size: {size}rem;
--avatar-from: {pair[0]}; --avatar-from: {pair[0]};
--avatar-to: {pair[1]}; --avatar-to: {pair[1]};
" "
class:deleted
> >
{#if url} {#if url}
<img class="photo" src={url} alt={name}> <img alt={name} class="photo" src={url}>
{:else} {:else}
<span class="initials">{initials}</span> <span class="initials">{initials}</span>
{/if} {/if}
+7 -7
View File
@@ -40,15 +40,15 @@
<button <button
class="Button {variant}" class="Button {variant}"
class:round {disabled}
class:tiny {type}
class:smaller class:disabled
class:pill
class:fluid class:fluid
class:loading class:loading
class:disabled class:pill
{type} class:round
{disabled} class:smaller
class:tiny
use:ripple use:ripple
{...rest} {...rest}
> >
@@ -23,6 +23,13 @@
const onpointerup = props.onpointerup as PointerHandler; const onpointerup = props.onpointerup as PointerHandler;
return { return {
...props, ...props,
onclickcapture(event: MouseEvent) {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
},
onpointerdown(event: PointerEvent) { onpointerdown(event: PointerEvent) {
suppressClick = false; suppressClick = false;
if (claimed.has(event)) { if (claimed.has(event)) {
@@ -35,13 +42,6 @@
suppressClick = open && event.pointerType !== "mouse"; suppressClick = open && event.pointerType !== "mouse";
onpointerup(event); onpointerup(event);
}, },
onclickcapture(event: MouseEvent) {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
},
}; };
} }
</script> </script>
+1 -1
View File
@@ -9,7 +9,7 @@
</script> </script>
<i <i
aria-hidden="true"
class="icon icon-{name} {extra}" class="icon icon-{name} {extra}"
style={size ? `font-size: ${size}` : undefined} style={size ? `font-size: ${size}` : undefined}
aria-hidden="true"
></i> ></i>
@@ -12,11 +12,11 @@
</script> </script>
<button <button
type="button"
class="ListItem-button" class="ListItem-button"
{onclick}
type="button"
class:selected class:selected
use:ripple use:ripple
{onclick}
> >
{@render children()} {@render children()}
</button> </button>
+5 -5
View File
@@ -6,7 +6,7 @@
let { value, label }: { value: string; label: string } = $props(); let { value, label }: { value: string; label: string } = $props();
const code = $derived.by(() => { const code = $derived.by(() => {
const qr = encode(value, { ecc: "M", border: 0 }); const qr = encode(value, { border: 0, ecc: "M" });
const path = qr.data const path = qr.data
.flatMap((row, y) => .flatMap((row, y) =>
row.flatMap((filled, x) => (filled ? [`M${x} ${y}h1v1h-1z`] : [])) row.flatMap((filled, x) => (filled ? [`M${x} ${y}h1v1h-1z`] : []))
@@ -17,12 +17,12 @@
</script> </script>
<svg <svg
class="qr"
viewBox="0 0 {code.extent} {code.extent}"
role="img"
aria-label={label} aria-label={label}
class="qr"
role="img"
viewBox="0 0 {code.extent} {code.extent}"
> >
<rect width={code.extent} height={code.extent} fill="var(--qr-bg, #fff)" /> <rect fill="var(--qr-bg, #fff)" height={code.extent} width={code.extent} />
<path <path
d={code.path} d={code.path}
fill="var(--qr-fg, #000)" fill="var(--qr-fg, #000)"
@@ -16,7 +16,7 @@
<div <div
class="skeleton" class="skeleton"
style:width
style:height
style:border-radius={circle ? "50%" : radius} style:border-radius={circle ? "50%" : radius}
style:height
style:width
></div> ></div>
@@ -5,9 +5,9 @@
<div class="Notification-container"> <div class="Notification-container">
{#each toasts.items as toast (toast.id)} {#each toasts.items as toast (toast.id)}
<button <button
type="button"
class="Notification {toast.type}" class="Notification {toast.type}"
onclick={() => toasts.dismiss(toast.id)} onclick={() => toasts.dismiss(toast.id)}
type="button"
> >
{toast.message} {toast.message}
</button> </button>
@@ -13,8 +13,8 @@
const { saving = false, onsave }: Props = $props(); const { saving = false, onsave }: Props = $props();
const KINDS = [ const KINDS = [
{ value: "keyword", label: "Keyword" }, { label: "Keyword", value: "keyword" },
{ value: "peer_online", label: "Peer online" }, { label: "Peer online", value: "peer_online" },
]; ];
let kind = $state("keyword"); let kind = $state("keyword");
@@ -34,7 +34,7 @@
const params: Record<string, unknown> = isKeyword const params: Record<string, unknown> = isKeyword
? { keyword: keyword.trim() } ? { keyword: keyword.trim() }
: { peer_id: Number(peerId.trim()) }; : { peer_id: Number(peerId.trim()) };
onsave({ kind, params, enabled }); onsave({ enabled, kind, params });
keyword = ""; keyword = "";
peerId = ""; peerId = "";
enabled = true; enabled = true;
@@ -60,16 +60,16 @@
{#if isKeyword} {#if isKeyword}
<label class="field"> <label class="field">
<span class="field-label">Keyword</span> <span class="field-label">Keyword</span>
<input type="text" bind:value={keyword} placeholder="Text to match…"> <input placeholder="Text to match…" type="text" bind:value={keyword}>
</label> </label>
{:else} {:else}
<label class="field"> <label class="field">
<span class="field-label">Peer ID</span> <span class="field-label">Peer ID</span>
<input <input
type="text"
inputmode="numeric" inputmode="numeric"
bind:value={peerId}
placeholder="123456789" placeholder="123456789"
type="text"
bind:value={peerId}
> >
</label> </label>
{/if} {/if}
@@ -79,7 +79,7 @@
<span>Enabled</span> <span>Enabled</span>
</label> </label>
<Button type="submit" disabled={saving || !valid}>Add watch</Button> <Button disabled={saving || !valid} type="submit">Add watch</Button>
</form> </form>
<style lang="scss"> <style lang="scss">
@@ -39,20 +39,20 @@
</div> </div>
<div class="row-actions"> <div class="row-actions">
<Button <Button
variant="translucent" aria-label="Переключить"
onclick={() => ontoggle(watch)}
round round
smaller smaller
onclick={() => ontoggle(watch)} variant="translucent"
aria-label="Переключить"
> >
<Icon name={watch.enabled ? "eye" : "eye-crossed"} /> <Icon name={watch.enabled ? "eye" : "eye-crossed"} />
</Button> </Button>
<Button <Button
variant="translucent" aria-label="Удалить"
onclick={() => ondelete(watch)}
round round
smaller smaller
onclick={() => ondelete(watch)} variant="translucent"
aria-label="Удалить"
> >
<Icon name="delete" /> <Icon name="delete" />
</Button> </Button>
@@ -54,14 +54,14 @@
</script> </script>
<div class="panel-content"> <div class="panel-content">
<WatchEditor {saving} onsave={add} /> <WatchEditor onsave={add} {saving} />
{#if loading} {#if loading}
<Spinner /> <Spinner />
{:else if watches.length === 0} {:else if watches.length === 0}
<p class="empty">Пока нет отслеживаний.</p> <p class="empty">Пока нет отслеживаний.</p>
{:else} {:else}
<WatchList {watches} ontoggle={toggle} ondelete={remove} /> <WatchList ondelete={remove} ontoggle={toggle} {watches} />
{/if} {/if}
</div> </div>
+2 -2
View File
@@ -8,10 +8,10 @@ const day = new Intl.DateTimeFormat(undefined, {
}); });
const full = new Intl.DateTimeFormat(undefined, { const full = new Intl.DateTimeFormat(undefined, {
day: "numeric", day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
month: "short",
year: "numeric",
}); });
export function formatTime(iso: string): string { export function formatTime(iso: string): string {
+2 -2
View File
@@ -36,9 +36,9 @@ function buildNodes(
next += 1; next += 1;
} }
nodes.push({ nodes.push({
kind: "entity",
entity,
children: buildNodes(text, nested, entity.offset, entityEnd), children: buildNodes(text, nested, entity.offset, entityEnd),
entity,
kind: "entity",
}); });
cursor = entityEnd; cursor = entityEnd;
i = next; i = next;
+11 -11
View File
@@ -1,20 +1,20 @@
const MEDIA_KIND_LABELS: Record<string, string> = { const MEDIA_KIND_LABELS: Record<string, string> = {
photo: "Фото",
video: "Видео",
animation: "GIF", animation: "GIF",
gif: "GIF",
voice: "Голосовое",
audio: "Аудио", audio: "Аудио",
video_note: "Кружок",
sticker: "Стикер",
document: "Файл",
contact: "Контакт", contact: "Контакт",
location: "Геопозиция",
venue: "Геопозиция",
poll: "Опрос",
dice: "Кубик", dice: "Кубик",
document: "Файл",
game: "Игра", game: "Игра",
gif: "GIF",
location: "Геопозиция",
photo: "Фото",
poll: "Опрос",
sticker: "Стикер",
story: "Сторис", story: "Сторис",
venue: "Геопозиция",
video: "Видео",
video_note: "Кружок",
voice: "Голосовое",
}; };
export function mediaKindLabel(kind: string | null): string | null { export function mediaKindLabel(kind: string | null): string | null {
@@ -35,7 +35,7 @@ export function formatBytes(bytes: number | null): string {
let unit = 0; let unit = 0;
while (value >= BYTE_STEP && unit < BYTE_UNITS.length - 1) { while (value >= BYTE_STEP && unit < BYTE_UNITS.length - 1) {
value /= BYTE_STEP; value /= BYTE_STEP;
unit++; unit += 1;
} }
const rounded = const rounded =
value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1); value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1);
+1 -1
View File
@@ -38,7 +38,7 @@ export function initials(name: string): string {
if (words.length === 0) { if (words.length === 0) {
return "?"; return "?";
} }
const first = words[0][0]; const first = words[0].charAt(0);
if (words.length === 1) { if (words.length === 1) {
return first.toUpperCase(); return first.toUpperCase();
} }
+2 -2
View File
@@ -13,10 +13,10 @@ export async function computeWaveform(
const data = audio.getChannelData(0); const data = audio.getChannelData(0);
const block = Math.max(1, Math.floor(data.length / bars)); const block = Math.max(1, Math.floor(data.length / bars));
const peaks: number[] = []; const peaks: number[] = [];
for (let i = 0; i < bars; i++) { for (let i = 0; i < bars; i += 1) {
const start = i * block; const start = i * block;
let max = 0; let max = 0;
for (let j = 0; j < block; j++) { for (let j = 0; j < block; j += 1) {
const value = Math.abs(data[start + j] ?? 0); const value = Math.abs(data[start + j] ?? 0);
if (value > max) { if (value > max) {
max = value; max = value;
+14 -14
View File
@@ -56,23 +56,10 @@ function createAccounts() {
get list() { get list() {
return list; return list;
}, },
get selectedId() { load,
return selectedId;
},
get selected() {
return selected;
},
get loaded() { get loaded() {
return loaded; return loaded;
}, },
load,
select,
async renameDevice(id: number, deviceModel: string) {
const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) =>
account.account_id === id ? updated : account
);
},
async logout(id: number) { async logout(id: number) {
await logoutAccount(id); await logoutAccount(id);
if (id === selectedId) { if (id === selectedId) {
@@ -80,6 +67,19 @@ function createAccounts() {
} }
await load(); await load();
}, },
async renameDevice(id: number, deviceModel: string) {
const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) =>
account.account_id === id ? updated : account
);
},
select,
get selected() {
return selected;
},
get selectedId() {
return selectedId;
},
}; };
} }
+3 -3
View File
@@ -13,9 +13,6 @@ function createAuth() {
let token = $state<string | null>(readToken()); let token = $state<string | null>(readToken());
return { return {
get token() {
return token;
},
get isAuthenticated() { get isAuthenticated() {
return token !== null && token.length > 0; return token !== null && token.length > 0;
}, },
@@ -31,6 +28,9 @@ function createAuth() {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
}, },
get token() {
return token;
},
}; };
} }
@@ -30,7 +30,8 @@ export function createChatPicker() {
return results; return results;
}, },
search(query: string) { search(query: string) {
const current = ++seq; seq += 1;
const current = seq;
if (timer !== null) { if (timer !== null) {
clearTimeout(timer); clearTimeout(timer);
} }
+39 -38
View File
@@ -25,7 +25,7 @@ interface Bucket {
} }
function createBucket(): Bucket { function createBucket(): Bucket {
return { list: [], loaded: false, loading: false, hasMore: true }; return { hasMore: true, list: [], loaded: false, loading: false };
} }
function bucketKey(folderId: number | null): string { function bucketKey(folderId: number | null): string {
@@ -63,9 +63,9 @@ function createChats() {
async function fetchPage(key: string, offset: number): Promise<Chat[]> { async function fetchPage(key: string, offset: number): Promise<Chat[]> {
return await listChats({ return await listChats({
folder_id: folderQuery(key),
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
folder_id: folderQuery(key),
}); });
} }
@@ -160,8 +160,8 @@ function createChats() {
if (event.type !== "message") { if (event.type !== "message") {
return; return;
} }
const message = event.message; const { message } = event;
let known = false; let seenChat = false;
for (const bucket of Object.values(buckets)) { for (const bucket of Object.values(buckets)) {
const existing = bucket.list.find( const existing = bucket.list.find(
(chat) => chat.chat_id === message.chat_id (chat) => chat.chat_id === message.chat_id
@@ -169,20 +169,21 @@ function createChats() {
if (existing === undefined) { if (existing === undefined) {
continue; continue;
} }
known = true; seenChat = true;
existing.last_date = message.date; existing.last_date = message.date;
existing.last_sender_id = message.sender_id; existing.last_sender_id = message.sender_id;
existing.last_text = message.text; existing.last_text = message.text;
existing.message_count++; existing.message_count += 1;
hoist(bucket, existing); hoist(bucket, existing);
} }
if (!known && buckets[ALL]?.loaded) { if (!seenChat && buckets[ALL]?.loaded) {
insertUnknown(message.chat_id).catch(() => undefined); insertUnknown(message.chat_id).catch(() => undefined);
} }
} }
async function waitForJob(jobId: number) { async function waitForJob(jobId: number) {
for (let attempt = 0; attempt < POLL_MAX; attempt++) { for (let attempt = 0; attempt < POLL_MAX; attempt += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(resolve, POLL_INTERVAL); setTimeout(resolve, POLL_INTERVAL);
}); });
@@ -215,19 +216,6 @@ function createChats() {
}); });
return { return {
get list(): Chat[] {
return active()?.list ?? EMPTY;
},
get loaded(): boolean {
return active()?.loaded ?? false;
},
get loading(): boolean {
return active()?.loading ?? false;
},
get hasMore(): boolean {
return active()?.hasMore ?? false;
},
loadMore,
byId(id: number): Chat | undefined { byId(id: number): Chat | undefined {
for (const bucket of Object.values(buckets)) { for (const bucket of Object.values(buckets)) {
const found = bucket.list.find((chat) => chat.chat_id === id); const found = bucket.list.find((chat) => chat.chat_id === id);
@@ -237,6 +225,23 @@ function createChats() {
} }
return extra[id]; return extra[id];
}, },
async enrich(chatId: number) {
syncAccount();
if (account === null || enriched.has(chatId)) {
return;
}
enriched.add(chatId);
try {
const { job_id } = await enrichChat(chatId);
await waitForJob(job_id);
const chat = await getChat(chatId);
if (chat !== null) {
replaceEverywhere(chat);
}
} catch {
enriched.delete(chatId);
}
},
ensure(id: number) { ensure(id: number) {
syncAccount(); syncAccount();
if (account === null || resolving.has(id) || known(id)) { if (account === null || resolving.has(id) || known(id)) {
@@ -277,29 +282,25 @@ function createChats() {
} }
}); });
}, },
get hasMore(): boolean {
return active()?.hasMore ?? false;
},
get list(): Chat[] {
return active()?.list ?? EMPTY;
},
load(folderId: number | null = folders.selectedId) { load(folderId: number | null = folders.selectedId) {
return load(folderId, false); return load(folderId, false);
}, },
get loaded(): boolean {
return active()?.loaded ?? false;
},
get loading(): boolean {
return active()?.loading ?? false;
},
loadMore,
refresh(folderId: number | null = folders.selectedId) { refresh(folderId: number | null = folders.selectedId) {
return load(folderId, true); return load(folderId, true);
}, },
async enrich(chatId: number) {
syncAccount();
if (account === null || enriched.has(chatId)) {
return;
}
enriched.add(chatId);
try {
const { job_id } = await enrichChat(chatId);
await waitForJob(job_id);
const chat = await getChat(chatId);
if (chat !== null) {
replaceEverywhere(chat);
}
} catch {
enriched.delete(chatId);
}
},
}; };
} }
+6 -6
View File
@@ -16,12 +16,6 @@ function createDiscover() {
} }
return { return {
get(chatId: number): DiscoverItem | undefined {
return items[chatId];
},
set(item: DiscoverItem) {
items = { ...items, [item.chat_id]: item };
},
async ensure(chatId: number) { async ensure(chatId: number) {
syncAccount(); syncAccount();
if (account === null || items[chatId] || pending.has(chatId)) { if (account === null || items[chatId] || pending.has(chatId)) {
@@ -35,6 +29,12 @@ function createDiscover() {
pending.delete(chatId); pending.delete(chatId);
} }
}, },
get(chatId: number): DiscoverItem | undefined {
return items[chatId];
},
set(item: DiscoverItem) {
items = { ...items, [item.chat_id]: item };
},
}; };
} }
+7 -5
View File
@@ -70,7 +70,7 @@ function createEvents() {
if (!response.body) { if (!response.body) {
return; return;
} }
epoch++; epoch += 1;
lastFrameAt = Date.now(); lastFrameAt = Date.now();
for (const listener of reconnectListeners) { for (const listener of reconnectListeners) {
listener(); listener();
@@ -79,6 +79,7 @@ function createEvents() {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
while (!signal.aborted) { while (!signal.aborted) {
// biome-ignore lint/performance/noAwaitInLoops: stream is read frame by frame
const result = await readWithTimeout(reader); const result = await readWithTimeout(reader);
if (result === STALLED) { if (result === STALLED) {
await reader.cancel().catch(() => undefined); await reader.cancel().catch(() => undefined);
@@ -95,6 +96,7 @@ function createEvents() {
async function run(accountId: number, signal: AbortSignal) { async function run(accountId: number, signal: AbortSignal) {
while (!signal.aborted) { while (!signal.aborted) {
try { try {
// biome-ignore lint/performance/noAwaitInLoops: reconnect attempts are sequential by design
const response = await fetch(`${BASE}/events?account_id=${accountId}`, { const response = await fetch(`${BASE}/events?account_id=${accountId}`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {}, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
signal, signal,
@@ -141,10 +143,6 @@ function createEvents() {
get epoch(): number { get epoch(): number {
return epoch; return epoch;
}, },
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
onReconnect(listener: () => void): () => void { onReconnect(listener: () => void): () => void {
reconnectListeners.add(listener); reconnectListeners.add(listener);
return () => reconnectListeners.delete(listener); return () => reconnectListeners.delete(listener);
@@ -160,6 +158,10 @@ function createEvents() {
} }
start(accountId); start(accountId);
}, },
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
}; };
} }
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more