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