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
+845 -817

No files matched your search

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