style(frontend): apply ultracite 7.12 formatting and lint rules
This commit is contained in:
1 parent
fc4758aa0b
commit
c8e5cff871
109 files changed
+845
-817
No files matched your search
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user