Compare commits

...
8 Commits
41 changed files with 548 additions and 300 deletions

No files matched your search

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
@@ -10,7 +11,7 @@
} }
const tabs = $derived<Tab[]>([ const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" }, { id: null, title: t("folders.all") },
...folders.list.map((folder) => ({ ...folders.list.map((folder) => ({
id: folder.folder_id, id: folder.folder_id,
title: folder.title, title: folder.title,
@@ -93,7 +94,7 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}> <ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата {t("settings.policy")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ForwardView } from "#lib/api/types.js"; import type { ForwardView } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
interface Props { interface Props {
@@ -14,20 +15,20 @@
); );
const name = $derived.by(() => { const name = $derived.by(() => {
if (forward.kind === "channel") { if (forward.kind === "channel") {
return forward.chat_title ?? "Channel"; return forward.chat_title ?? t("forward.channel");
} }
if (forward.kind === "hidden") { if (forward.kind === "hidden") {
return forward.from_name ?? "Hidden account"; return forward.from_name ?? t("forward.hidden");
} }
if (peer) { if (peer) {
return peerName(peer); return peerName(peer);
} }
return forward.from_name ?? "Unknown"; return forward.from_name ?? t("forward.unknown");
}); });
</script> </script>
<div class="ForwardHeader"> <div class="ForwardHeader">
<span class="label">Forwarded from</span> <span class="label">{t("forward.from")}</span>
<span class="name">{name}</span> <span class="name">{name}</span>
{#if forward.signature} {#if forward.signature}
<span class="signature">({forward.signature})</span> <span class="signature">({forward.signature})</span>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { InlineButton } from "#lib/api/types.js"; import type { InlineButton } from "#lib/api/types.js";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
rows: InlineButton[][]; rows: InlineButton[][];
@@ -43,7 +44,7 @@
type="button" type="button"
> >
<span class="label" <span class="label"
>{copied === key ? "Скопировано" : button.text}</span >{copied === key ? t("inline.copied") : button.text}</span
> >
</button> </button>
{:else} {:else}
+10 -2
View File
@@ -17,6 +17,7 @@
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -49,6 +50,7 @@
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let token = 0; let token = 0;
const queued = createFlash<number>();
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : ""); const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo"); const isImage = $derived(mime.startsWith("image/") || kind === "photo");
@@ -140,9 +142,10 @@
if (messageId === null) { if (messageId === null) {
return; return;
} }
const target = messageId;
try { try {
await fetchMedia(chatId, messageId); await fetchMedia(chatId, target);
toasts.success(t("media.queued")); queued.show(target);
} catch { } catch {
toasts.error(t("media.queueFailed")); toasts.error(t("media.queueFailed"));
} }
@@ -312,7 +315,12 @@
<div class="media-message"> <div class="media-message">
<p>{t("media.notDownloaded")}</p> <p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary"> <Button fluid onclick={queueFetch} variant="primary">
{#if queued.current === messageId}
<Icon name="check" />
{t("chat.media.queued")}
{:else}
{t("media.downloadToArchive")} {t("media.downloadToArchive")}
{/if}
</Button> </Button>
</div> </div>
{:else if result?.state === "missing"} {:else if result?.state === "missing"}
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -33,7 +34,7 @@
versions = text; versions = text;
mediaVersions = media; mediaVersions = media;
}) })
.catch(() => toasts.error("Failed to load edit history")) .catch(() => toasts.error(t("versions.failed")))
.finally(() => { .finally(() => {
loading = false; loading = false;
}); });
@@ -46,8 +47,8 @@
<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">Edit history</Dialog.Title> <Dialog.Title class="dialog-title">{t("versions.title")}</Dialog.Title>
<Dialog.Close aria-label="Close" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -55,11 +56,11 @@
{#if loading} {#if loading}
<div class="centered"><Spinner /></div> <div class="centered"><Spinner /></div>
{:else if versions.length === 0 && mediaVersions.length === 0} {:else if versions.length === 0 && mediaVersions.length === 0}
<p class="empty">No versions recorded.</p> <p class="empty">{t("versions.none")}</p>
{:else} {:else}
{#if mediaVersions.length > 0} {#if mediaVersions.length > 0}
<div class="media-versions"> <div class="media-versions">
<span class="media-label">Media versions</span> <span class="media-label">{t("versions.media")}</span>
<div class="media-strip"> <div class="media-strip">
{#each mediaVersions as media (media.id)} {#each mediaVersions as media (media.id)}
<MediaVersionThumb version={media} /> <MediaVersionThumb version={media} />
@@ -70,12 +71,14 @@
{#each versions as version, index (version.observed_at)} {#each versions as version, index (version.observed_at)}
<div class="version"> <div class="version">
<div class="version-meta"> <div class="version-meta">
<span class="version-label">Version {index + 1}</span> <span class="version-label"
>{t("versions.version", { number: index + 1 })}</span
>
<span class="version-date" <span class="version-date"
>{formatFull(version.observed_at)}</span >{formatFull(version.observed_at)}</span
> >
</div> </div>
<div class="version-text">{version.text ?? "(no text)"}</div> <div class="version-text">{version.text ?? t("feed.noText")}</div>
</div> </div>
{/each} {/each}
{/if} {/if}
+3 -2
View File
@@ -3,6 +3,7 @@
import type { PinnedView } from "#lib/api/types.js"; import type { PinnedView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "#lib/format/media.js"; import { mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
interface Props { interface Props {
@@ -19,7 +20,7 @@
? (pinned.text ?? ? (pinned.text ??
(pinned.media_kind (pinned.media_kind
? mediaKindLabel(pinned.media_kind) ? mediaKindLabel(pinned.media_kind)
: "Pinned message")) : t("pinned.message")))
: "" : ""
); );
@@ -56,7 +57,7 @@
> >
<Icon name="pin" size="1.125rem" /> <Icon name="pin" size="1.125rem" />
<span class="body"> <span class="body">
<span class="label">Pinned message</span> <span class="label">{t("pinned.message")}</span>
<span class="preview">{preview}</span> <span class="preview">{preview}</span>
</span> </span>
</button> </button>
+8 -4
View File
@@ -6,6 +6,7 @@
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -15,6 +16,7 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
let busy = $state(false); let busy = $state(false);
const started = createFlash<true>();
const item = $derived(discover.get(chatId)); const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false); const tracked = $derived(item?.tracked ?? false);
@@ -34,7 +36,7 @@
busy = true; busy = true;
try { try {
discover.set(await trackChat(chatId, true)); discover.set(await trackChat(chatId, true));
toasts.success(t("track.started")); started.show(true);
chats.refresh(); chats.refresh();
} catch { } catch {
toasts.error(t("track.failed")); toasts.error(t("track.failed"));
@@ -50,7 +52,7 @@
busy = true; busy = true;
try { try {
await enqueueBackfill(chatId, true, true); await enqueueBackfill(chatId, true, true);
toasts.success(t("jobs.backfillStarted")); started.show(true);
} catch { } catch {
toasts.error(t("jobs.backfillFailed")); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
@@ -67,8 +69,10 @@
<div class="actions"> <div class="actions">
{#if tracked} {#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary"> <Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" /> <Icon name={started.current ? "check" : "cloud-download"} />
<span>{t("track.loadHistory")}</span> <span
>{started.current ? t("track.loading") : t("track.loadHistory")}</span
>
</Button> </Button>
{:else} {:else}
<Button loading={busy} onclick={track} pill variant="primary"> <Button loading={busy} onclick={track} pill variant="primary">
@@ -11,6 +11,7 @@
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js"; import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -24,6 +25,7 @@
let starting = $state(false); let starting = $state(false);
let syncing = $state(false); let syncing = $state(false);
let syncingContacts = $state(false); let syncingContacts = $state(false);
const started = createFlash<"backfill" | "sync" | "contacts">();
const picker = createChatPicker(); const picker = createChatPicker();
const availableChats = $derived(picker.results); const availableChats = $derived(picker.results);
@@ -58,7 +60,7 @@
starting = true; starting = true;
try { try {
await enqueueBackfill(selected, media, true); await enqueueBackfill(selected, media, true);
toasts.success(t("jobs.backfillStarted")); started.show("backfill");
version += 1; version += 1;
} catch { } catch {
toasts.error(t("jobs.backfillFailed")); toasts.error(t("jobs.backfillFailed"));
@@ -74,7 +76,7 @@
syncing = true; syncing = true;
try { try {
await syncDialogs(); await syncDialogs();
toasts.success(t("jobs.syncStarted")); started.show("sync");
version += 1; version += 1;
} catch { } catch {
toasts.error(t("jobs.syncFailed")); toasts.error(t("jobs.syncFailed"));
@@ -90,7 +92,7 @@
syncingContacts = true; syncingContacts = true;
try { try {
await syncContacts(); await syncContacts();
toasts.success(t("jobs.contactsStarted")); started.show("contacts");
version += 1; version += 1;
} catch { } catch {
toasts.error(t("jobs.contactsFailed")); toasts.error(t("jobs.contactsFailed"));
@@ -110,8 +112,13 @@
<p class="hint">{t("jobs.dialogsHint")}</p> <p class="hint">{t("jobs.dialogsHint")}</p>
<div class="action"> <div class="action">
<Button fluid loading={syncing} onclick={sync} variant="secondary"> <Button fluid loading={syncing} onclick={sync} variant="secondary">
{#if started.current === "sync"}
<Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="reload" /> <Icon name="reload" />
<span>{t("jobs.syncDialogs")}</span> <span>{t("jobs.syncDialogs")}</span>
{/if}
</Button> </Button>
</div> </div>
<p class="hint">{t("jobs.contactsHint")}</p> <p class="hint">{t("jobs.contactsHint")}</p>
@@ -122,8 +129,13 @@
onclick={syncContactList} onclick={syncContactList}
variant="secondary" variant="secondary"
> >
{#if started.current === "contacts"}
<Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="user" /> <Icon name="user" />
<span>{t("jobs.syncContacts")}</span> <span>{t("jobs.syncContacts")}</span>
{/if}
</Button> </Button>
</div> </div>
</section> </section>
@@ -188,8 +200,13 @@
onclick={start} onclick={start}
variant="primary" variant="primary"
> >
{#if started.current === "backfill"}
<Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="cloud-download" /> <Icon name="cloud-download" />
<span>{t("jobs.startBackfill")}</span> <span>{t("jobs.startBackfill")}</span>
{/if}
</Button> </Button>
</div> </div>
</section> </section>
@@ -11,6 +11,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -24,7 +25,17 @@
let loaded = $state(false); let loaded = $state(false);
let inline = $state<InlineMedia | null>(null); let inline = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
const queued = createFlash<true>();
const queueIcon = $derived.by(() => {
if (queuing) {
return "timer";
}
return queued.current ? "check" : "download";
});
const queueLabel = $derived(
queued.current ? t("chat.media.queued") : t("media.downloadToArchive")
);
const ready = $derived(inline?.state === "ready" ? inline : null); const ready = $derived(inline?.state === "ready" ? inline : null);
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false); const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
@@ -40,7 +51,7 @@
queuing = true; queuing = true;
try { try {
await fetchMedia(chatId, media.message_id); await fetchMedia(chatId, media.message_id);
toasts.success(t("media.queued")); queued.show(true);
} catch { } catch {
toasts.error(t("media.queueFailed")); toasts.error(t("media.queueFailed"));
} finally { } finally {
@@ -66,8 +77,14 @@
<img alt={t("media.attachment")} src={ready.url}> <img alt={t("media.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
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> aria-label={queueLabel}
class="tile placeholder"
onclick={queue}
title={queueLabel}
type="button"
>
<Icon name={queueIcon} size="1.5rem" />
</button> </button>
{:else if loaded} {:else if loaded}
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div> <div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
@@ -24,35 +24,11 @@
formatSpan, formatSpan,
isOnline, isOnline,
} from "#lib/format/presence.js"; } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
const TEXT = {
average: "В среднем за день",
byDay: "По дням",
byHour: "По часам недели",
failed: "Не удалось загрузить аналитику.",
firstOnline: "Обычно появляется",
hidden:
"Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
lastOffline: "Обычно уходит",
median: "Обычная сессия",
messages: "Сообщения",
mine: "Я отвечаю",
missing: "Нет данных",
noData: "нет данных",
noMessages: "За этот период сообщений нет.",
noStatus: "Статусов пока нет.",
openChat: "Откройте чат",
period: (count: number) => `${count} дней`,
retry: "Повторить",
sessions: "Сессии",
theirs: "Мне отвечают",
title: "Аналитика",
total: (count: number) => `в сети за ${count} дней`,
};
const PERIODS = [7, 30, 90]; const PERIODS = [7, 30, 90];
const DEFAULT_PERIOD = 30; const DEFAULT_PERIOD = 30;
const SESSION_PAGE = 30; const SESSION_PAGE = 30;
@@ -60,7 +36,7 @@
const MS_IN_SECOND = 1000; const MS_IN_SECOND = 1000;
const tz = browserTimeZone(); const tz = browserTimeZone();
const zone = timeZoneLabel(); const zone = $derived(timeZoneLabel());
const chatId = $derived( const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
@@ -97,25 +73,25 @@
const result: { label: string; value: string }[] = []; const result: { label: string; value: string }[] = [];
if (summary.daily_seconds !== null) { if (summary.daily_seconds !== null) {
result.push({ result.push({
label: TEXT.average, label: t("presence.average"),
value: formatSpan(summary.daily_seconds), value: formatSpan(summary.daily_seconds),
}); });
} }
if (summary.median_session_seconds !== null) { if (summary.median_session_seconds !== null) {
result.push({ result.push({
label: TEXT.median, label: t("presence.median"),
value: formatSpan(summary.median_session_seconds), value: formatSpan(summary.median_session_seconds),
}); });
} }
if (summary.first_online !== null) { if (summary.first_online !== null) {
result.push({ result.push({
label: TEXT.firstOnline, label: t("presence.firstOnline"),
value: formatClock(summary.first_online), value: formatClock(summary.first_online),
}); });
} }
if (summary.last_offline !== null) { if (summary.last_offline !== null) {
result.push({ result.push({
label: TEXT.lastOffline, label: t("presence.lastOffline"),
value: formatClock(summary.last_offline), value: formatClock(summary.last_offline),
}); });
} }
@@ -124,13 +100,16 @@
MS_IN_SECOND; MS_IN_SECOND;
const missing = period - summary.covered_seconds; const missing = period - summary.covered_seconds;
if (missing >= SECONDS_IN_HOUR) { if (missing >= SECONDS_IN_HOUR) {
result.push({ label: TEXT.missing, value: formatLongSpan(missing) }); result.push({
label: t("presence.missing"),
value: formatLongSpan(missing),
});
} }
return result; return result;
}); });
function reply(seconds: number | null): string { function reply(seconds: number | null): string {
return seconds === null ? TEXT.noData : formatSpan(seconds); return seconds === null ? t("presence.noData") : formatSpan(seconds);
} }
async function load(id: number, period: number) { async function load(id: number, period: number) {
@@ -172,7 +151,7 @@
</script> </script>
{#if chatId === null} {#if chatId === null}
<EmptyState description={TEXT.openChat} title={TEXT.title} /> <EmptyState description={t("profile.empty")} title={t("profile.analytics")} />
{:else} {:else}
<div class="analytics"> <div class="analytics">
<div class="periods"> <div class="periods">
@@ -185,20 +164,20 @@
}} }}
type="button" type="button"
> >
{TEXT.period(period)} {t("presence.days", { count: period })}
</button> </button>
{/each} {/each}
</div> </div>
{#if failed} {#if failed}
<div class="state"> <div class="state">
<p>{TEXT.failed}</p> <p>{t("presence.failed")}</p>
<Button <Button
onclick={() => chatId !== null && load(chatId, days)} onclick={() => chatId !== null && load(chatId, days)}
smaller smaller
variant="text" variant="text"
> >
{TEXT.retry} {t("ui.retry")}
</Button> </Button>
</div> </div>
{:else if volume === null || (isDm && stats === null)} {:else if volume === null || (isDm && stats === null)}
@@ -211,7 +190,9 @@
<span class="total" <span class="total"
>{formatSpan(stats.summary.online_seconds)}</span >{formatSpan(stats.summary.online_seconds)}</span
> >
<span class="count">{TEXT.total(shownDays)}</span> <span class="count"
>{t("presence.total", { count: shownDays })}</span
>
</div> </div>
{/if} {/if}
{#if sample} {#if sample}
@@ -220,9 +201,9 @@
</p> </p>
{/if} {/if}
{#if stats.hidden} {#if stats.hidden}
<p class="note">{TEXT.hidden}</p> <p class="note">{t("presence.hidden")}</p>
{:else if !tracked} {:else if !tracked}
<p class="note">{TEXT.noStatus}</p> <p class="note">{t("presence.noStatus")}</p>
{/if} {/if}
</section> </section>
@@ -242,34 +223,34 @@
<section> <section>
<div class="section-title"> <div class="section-title">
<h3>{TEXT.byHour}</h3> <h3>{t("presence.byHour")}</h3>
<span>{zone}</span> <span>{zone}</span>
</div> </div>
<PresenceHeatmap week={stats.week} /> <PresenceHeatmap week={stats.week} />
</section> </section>
<section> <section>
<div class="section-title"><h3>{TEXT.byDay}</h3></div> <div class="section-title"><h3>{t("presence.byDay")}</h3></div>
<OnlineDays days={stats.days} /> <OnlineDays days={stats.days} />
</section> </section>
{/if} {/if}
{/if} {/if}
<section> <section>
<div class="section-title"><h3>{TEXT.messages}</h3></div> <div class="section-title"><h3>{t("presence.messages")}</h3></div>
{#if quiet} {#if quiet}
<p class="state">{TEXT.noMessages}</p> <p class="state">{t("presence.noMessages")}</p>
{:else} {:else}
<VolumeChart {volume} /> <VolumeChart {volume} />
{/if} {/if}
{#if response && response.mine_count + response.their_count > 0} {#if response && response.mine_count + response.their_count > 0}
<dl class="rows replies"> <dl class="rows replies">
<div class="row"> <div class="row">
<dt>{TEXT.mine}</dt> <dt>{t("presence.mine")}</dt>
<dd>{reply(response.mine_median_seconds)}</dd> <dd>{reply(response.mine_median_seconds)}</dd>
</div> </div>
<div class="row"> <div class="row">
<dt>{TEXT.theirs}</dt> <dt>{t("presence.theirs")}</dt>
<dd>{reply(response.their_median_seconds)}</dd> <dd>{reply(response.their_median_seconds)}</dd>
</div> </div>
</dl> </dl>
@@ -278,7 +259,7 @@
{#if isDm && stats && tracked && !stats.hidden} {#if isDm && stats && tracked && !stats.hidden}
<section> <section>
<div class="section-title"><h3>{TEXT.sessions}</h3></div> <div class="section-title"><h3>{t("presence.sessions")}</h3></div>
{#key `${chatId}:${shownDays}:${stats.until}`} {#key `${chatId}:${shownDays}:${stats.until}`}
<PresenceSessions <PresenceSessions
days={shownDays} days={shownDays}
@@ -2,6 +2,7 @@
import type { PresenceDay } from "#lib/api/types.js"; import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js"; import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js"; import { formatSpan } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
days: PresenceDay[]; days: PresenceDay[];
@@ -12,18 +13,6 @@
const SECONDS_IN_HOUR = 3600; const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400; const SECONDS_IN_DAY = 86_400;
const TEXT = {
bar: (day: string, online: number, missing: number) => {
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return `${day}, нет данных`;
}
const gap =
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
return `${day}, ${formatSpan(online)} в сети${gap}`;
},
today: "сегодня",
};
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds))); const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
const first = $derived(days[0]); const first = $derived(days[0]);
@@ -31,6 +20,23 @@
return formatDay(parseDay(day).toISOString()); return formatDay(parseDay(day).toISOString());
} }
function title(day: PresenceDay, index: number): string {
const date = label(day.day);
const missing = absent(day, index);
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return t("presence.dayNoData", { day: date });
}
const span = formatSpan(day.online_seconds);
if (missing >= SECONDS_IN_HOUR) {
return t("presence.dayGap", {
day: date,
gap: formatSpan(missing),
span,
});
}
return t("presence.day", { day: date, span });
}
function absent(day: PresenceDay, index: number): number { function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) { if (index === days.length - 1) {
return 0; return 0;
@@ -46,11 +52,7 @@
{#each days as day, index (day.day)} {#each days as day, index (day.day)}
<div <div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]} class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar( title={title(day, index)}
label(day.day),
day.online_seconds,
absent(day, index)
)}
> >
<span <span
class={["bar", { zero: day.online_seconds === 0 }]} class={["bar", { zero: day.online_seconds === 0 }]}
@@ -63,7 +65,7 @@
{#if first} {#if first}
<div class="axis"> <div class="axis">
<span>{label(first.day)}</span> <span>{label(first.day)}</span>
<span>{TEXT.today}</span> <span>{t("presence.axisToday")}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PresenceHour } from "#lib/api/types.js"; import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js"; import { formatSpan, weekdayLabels } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
week: PresenceHour[]; week: PresenceHour[];
@@ -8,28 +9,14 @@
const { week }: Props = $props(); const { week }: Props = $props();
const TEXT = {
cell: (day: string, cell: PresenceHour) => {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
const HOURS_IN_DAY = 24; const HOURS_IN_DAY = 24;
const SECONDS_IN_MINUTE = 60;
const HOUR_TICKS = [0, 6, 12, 18]; const HOUR_TICKS = [0, 6, 12, 18];
const MIN_SHADE = 0.18; const MIN_SHADE = 0.18;
const weekdays = $derived(weekdayLabels());
const rows = $derived( const rows = $derived(
TEXT.days.map((_, weekday) => weekdays.map((_, weekday) =>
week week
.filter((cell) => cell.weekday === weekday) .filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour) .sort((a, b) => a.hour - b.hour)
@@ -44,6 +31,20 @@
return cell.hours > 0 && cell.online_minutes === null; return cell.hours > 0 && cell.online_minutes === null;
} }
function title(day: string, cell: PresenceHour): string {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return t("presence.cellNoData", { slot });
}
return t("presence.cellAverage", {
slot,
span: formatSpan(cell.online_minutes * SECONDS_IN_MINUTE),
});
}
function shade(minutes: number | null): number { function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) { if (minutes === null || minutes === 0 || peak === 0) {
return 0; return 0;
@@ -55,7 +56,7 @@
<div class="heatmap"> <div class="heatmap">
{#each rows as cells, weekday (weekday)} {#each rows as cells, weekday (weekday)}
<div class="row"> <div class="row">
<span class="day">{TEXT.days[weekday]}</span> <span class="day">{weekdays[weekday]}</span>
<div class="cells"> <div class="cells">
{#each cells as cell (cell.hour)} {#each cells as cell (cell.hour)}
<span <span
@@ -67,7 +68,7 @@
zero: cell.online_minutes === 0, zero: cell.online_minutes === 0,
}, },
]} ]}
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)} title={title(weekdays[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%" style:--shade="{shade(cell.online_minutes) * 100}%"
></span> ></span>
{/each} {/each}
@@ -87,13 +88,13 @@
{#if peak > 0} {#if peak > 0}
<span class="key"> <span class="key">
<span class="scale"></span> <span class="scale"></span>
{TEXT.max(peak)} {t("presence.max", { span: formatSpan(peak * SECONDS_IN_MINUTE) })}
</span> </span>
{/if} {/if}
{#if hasGaps} {#if hasGaps}
<span class="key"> <span class="key">
<span class="swatch empty"></span> <span class="swatch empty"></span>
{TEXT.empty} {t("presence.noData")}
</span> </span>
{/if} {/if}
</div> </div>
@@ -115,7 +116,7 @@
.day { .day {
flex-shrink: 0; flex-shrink: 0;
width: 1.375rem; width: 1.75rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@@ -173,7 +174,7 @@
gap: 0.25rem 0.75rem; gap: 0.25rem 0.75rem;
align-items: center; align-items: center;
margin: 0.375rem 0 0 1.875rem; margin: 0.375rem 0 0 2.25rem;
font-size: 0.75rem; font-size: 0.75rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -5,6 +5,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js"; import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js"; import { formatSpan } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
days: number; days: number;
@@ -16,18 +17,6 @@
const { days, first, pageSize, peerId, tz }: Props = $props(); const { days, first, pageSize, peerId, tz }: Props = $props();
const TEXT = {
failed: "Не удалось загрузить сессии.",
more: "Показать ещё",
none: "За этот период в сети не был(а).",
now: "в сети",
retry: "Повторить",
since: (time: string) => `с ${time}`,
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
today: "Сегодня",
yesterday: "Вчера",
};
const MS_IN_DAY = 86_400_000; const MS_IN_DAY = 86_400_000;
let extra = $state<PresenceSession[]>([]); let extra = $state<PresenceSession[]>([]);
@@ -54,18 +43,22 @@
function dayLabel(date: Date): string { function dayLabel(date: Date): string {
const today = new Date(); const today = new Date();
if (date.toDateString() === today.toDateString()) { if (date.toDateString() === today.toDateString()) {
return TEXT.today; return t("presence.today");
} }
if ( if (
date.toDateString() === date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString() new Date(today.getTime() - MS_IN_DAY).toDateString()
) { ) {
return TEXT.yesterday; return t("presence.yesterday");
} }
const text = formatLongDay(date); const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
} }
function span(from: string, to: string): string {
return from === to ? from : `${from}–${to}`;
}
async function loadMore() { async function loadMore() {
if (loading) { if (loading) {
return; return;
@@ -92,7 +85,7 @@
</script> </script>
{#if sessions.length === 0} {#if sessions.length === 0}
<p class="state">{TEXT.none}</p> <p class="state">{t("presence.noSessions")}</p>
{:else} {:else}
{#each groups as group (group.label)} {#each groups as group (group.label)}
<h4 class="day">{group.label}</h4> <h4 class="day">{group.label}</h4>
@@ -101,14 +94,13 @@
<li> <li>
<span> <span>
{session.ongoing {session.ongoing
? TEXT.since(formatTime(session.started)) ? t("presence.since", { time: formatTime(session.started) })
: TEXT.span( : span(formatTime(session.started), formatTime(session.ended))}
formatTime(session.started),
formatTime(session.ended)
)}
</span> </span>
<span class={["length", { online: session.ongoing }]}> <span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)} {session.ongoing
? t("presence.online")
: formatSpan(session.seconds)}
</span> </span>
</li> </li>
{/each} {/each}
@@ -118,11 +110,13 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>{TEXT.failed}</p> <p>{t("presence.sessionsFailed")}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button> <Button onclick={loadMore} smaller variant="text">{t("ui.retry")}</Button>
</div> </div>
{:else if !exhausted} {:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button> <button class="more" onclick={loadMore} type="button">
{t("ui.showMore")}
</button>
{/if} {/if}
{/if} {/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js"; import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js"; import { formatDay, parseDay } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
volume: VolumeBucket[]; volume: VolumeBucket[];
@@ -8,22 +9,25 @@
const { volume }: Props = $props(); const { volume }: Props = $props();
const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total))); const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const first = $derived(volume[0]); const first = $derived(volume[0]);
function label(day: string): string { function label(day: string): string {
return formatDay(parseDay(day).toISOString()); return formatDay(parseDay(day).toISOString());
} }
function title(bucket: VolumeBucket): string {
const day = label(bucket.bucket);
if (bucket.total === 0) {
return t("presence.volumeEmpty", { day });
}
return t("presence.volume", {
day,
incoming: bucket.incoming,
outgoing: bucket.outgoing,
total: bucket.total,
});
}
</script> </script>
<div class="volume"> <div class="volume">
@@ -31,7 +35,7 @@
<span class="peak">{peak}</span> <span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}> <div class={["bars", { dense: volume.length > 45 }]}>
{#each volume as bucket (bucket.bucket)} {#each volume as bucket (bucket.bucket)}
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}> <div class="slot" title={title(bucket)}>
<div <div
class={["bar", { zero: bucket.total === 0 }]} class={["bar", { zero: bucket.total === 0 }]}
style:height="{(bucket.total / peak) * 100}%" style:height="{(bucket.total / peak) * 100}%"
@@ -46,12 +50,16 @@
{#if first} {#if first}
<div class="axis"> <div class="axis">
<span>{label(first.bucket)}</span> <span>{label(first.bucket)}</span>
<span>{TEXT.today}</span> <span>{t("presence.axisToday")}</span>
</div> </div>
{/if} {/if}
<div class="legend"> <div class="legend">
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span> <span class="key"
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span> ><span class="swatch in"></span>{t("presence.volumeIncoming")}</span
>
<span class="key"
><span class="swatch out"></span>{t("presence.volumeOutgoing")}</span
>
</div> </div>
</div> </div>
@@ -18,6 +18,7 @@
import ProfileSection from "#lib/components/profile/ProfileSection.svelte"; import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatDate, formatFull } from "#lib/format/datetime.js"; import { formatDate, formatFull } from "#lib/format/datetime.js";
import { formatPhone } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
@@ -56,7 +57,7 @@
result.push({ result.push({
icon: "phone", icon: "phone",
label: t("profile.phone"), label: t("profile.phone"),
value: `+${peer.phone}`, value: formatPhone(peer.phone),
}); });
} }
if (peer?.username) { if (peer?.username) {
@@ -9,7 +9,7 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js"; import { formatPhone, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -73,7 +73,7 @@
if (peer?.username) { if (peer?.username) {
return `@${peer.username}`; return `@${peer.username}`;
} }
return peer?.phone ? `+${peer.phone}` : `ID ${targetId}`; return peer?.phone ? formatPhone(peer.phone) : `ID ${targetId}`;
} }
const count = chat?.message_count ?? 0; const count = chat?.message_count ?? 0;
return count > 0 ? t("profile.messages", { count }) : t("profile.group"); return count > 0 ? t("profile.messages", { count }) : t("profile.group");
@@ -7,6 +7,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -83,7 +84,7 @@
{#if loading} {#if loading}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState title="Нет ссылок" /> <div class="center"><EmptyState title={t("social.links.empty")} /></div>
{/if} {/if}
{:else} {:else}
<ul class="list"> <ul class="list">
@@ -122,7 +123,7 @@
onclick={() => loadMore()} onclick={() => loadMore()}
type="button" type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? t("ui.loading") : t("ui.showMore")}
</button> </button>
{/if} {/if}
@@ -17,6 +17,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
@@ -56,14 +57,16 @@
); );
function displayName(item: MediaView): string { function displayName(item: MediaView): string {
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл"; return (
item.file_name ?? mediaKindLabel(item.kind) ?? t("media.kind.document")
);
} }
async function save(item: MediaView) { async function save(item: MediaView) {
try { try {
await downloadMedia(item.id); await downloadMedia(item.id);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} }
} }
@@ -175,7 +178,7 @@
{#if loading} {#if loading}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState title="Пусто" /> <div class="center"><EmptyState title={t("shared.media.empty")} /></div>
{/if} {/if}
{:else if layout === "grid"} {:else if layout === "grid"}
<div class="grid"> <div class="grid">
@@ -217,17 +220,17 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)} <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть на весь экран</ContextMenuItem >{t("shared.media.fullscreen")}</ContextMenuItem
> >
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)} <ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem >{t("ui.jumpToMessage")}</ContextMenuItem
> >
{#if item.downloaded} {#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)} <ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem >{t("ui.downloadFile")}</ContextMenuItem
> >
<ContextMenuItem icon="allow-share" onselect={() => share(item)} <ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem >{t("ui.shareLink")}</ContextMenuItem
> >
{/if} {/if}
{/snippet} {/snippet}
@@ -262,17 +265,17 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)} <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть</ContextMenuItem >{t("ui.open")}</ContextMenuItem
> >
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)} <ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem >{t("ui.jumpToMessage")}</ContextMenuItem
> >
{#if item.downloaded} {#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)} <ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem >{t("ui.downloadFile")}</ContextMenuItem
> >
<ContextMenuItem icon="allow-share" onselect={() => share(item)} <ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem >{t("ui.shareLink")}</ContextMenuItem
> >
{/if} {/if}
{/snippet} {/snippet}
@@ -289,7 +292,7 @@
onclick={() => loadMore()} onclick={() => loadMore()}
type="button" type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? t("ui.loading") : t("ui.showMore")}
</button> </button>
{/if} {/if}
@@ -55,9 +55,8 @@
loginId = ""; loginId = "";
await accounts.load(); await accounts.load();
if (state.account) { if (state.account) {
accounts.select(state.account.account_id); accounts.switchTo(state.account.account_id);
} }
toasts.success(t("login.added"));
open = false; open = false;
} }
@@ -7,7 +7,7 @@
import SettingsItem from "#lib/components/settings/SettingsItem.svelte"; import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } from "#lib/format/peer.js"; import { accountName, formatPhone } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -78,7 +78,7 @@
<AccountAvatar account={current} size={5} /> <AccountAvatar account={current} size={5} />
<div class="name">{accountName(current)}</div> <div class="name">{accountName(current)}</div>
{#if current.phone} {#if current.phone}
<div class="phone">+{current.phone}</div> <div class="phone">{formatPhone(current.phone)}</div>
{/if} {/if}
{#if !current.is_active} {#if !current.is_active}
<div class="inactive">{t("account.inactive")}</div> <div class="inactive">{t("account.inactive")}</div>
@@ -19,6 +19,7 @@
import { formatShareLimits } from "#lib/format/shares.js"; import { formatShareLimits } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -72,6 +73,8 @@
let maxDownloads = $state<number | null>(null); let maxDownloads = $state<number | null>(null);
let showQr = $state(false); let showQr = $state(false);
let token = 0; let token = 0;
const saved = createFlash<number>();
const revoked = createFlash<ShareSubject>();
const settings = $derived<ShareSettings>({ const settings = $derived<ShareSettings>({
expiresInSeconds: expiresIn === KEEP ? null : expiresIn, expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
@@ -125,7 +128,6 @@
busy = true; busy = true;
try { try {
adopt(await createShare(subject, settings)); adopt(await createShare(subject, settings));
toasts.success(t("shares.opened"));
} catch (error) { } catch (error) {
fail(error, "shares.openFailed"); fail(error, "shares.openFailed");
} finally { } finally {
@@ -139,8 +141,9 @@
} }
busy = true; busy = true;
try { try {
adopt(await updateShare(share.id, settings)); const next = await updateShare(share.id, settings);
toasts.success(t("shares.updated")); adopt(next);
saved.show(next.id);
} catch (error) { } catch (error) {
fail(error, "shares.updateFailed"); fail(error, "shares.updateFailed");
} finally { } finally {
@@ -149,15 +152,15 @@
} }
async function revoke() { async function revoke() {
if (!share || busy) { if (!(share && subject) || busy) {
return; return;
} }
const target = subject;
busy = true; busy = true;
try { try {
await revokeShare(share.id); await revokeShare(share.id);
adopt(null); adopt(null);
toasts.success(t("shares.revokedToast")); revoked.show(target);
open = false;
} catch (error) { } catch (error) {
fail(error, "shares.revokeFailed"); fail(error, "shares.revokeFailed");
} finally { } finally {
@@ -314,12 +317,22 @@
{#if !loading} {#if !loading}
<div class="dialog-actions"> <div class="dialog-actions">
{#if share} {#if subject && revoked.current === subject}
<p class="done">
<Icon name="check" size="1.25rem" />
{t("shares.accessRevoked")}
</p>
{:else if share}
<Button loading={busy} onclick={revoke} pill variant="danger"> <Button loading={busy} onclick={revoke} pill variant="danger">
{t("shares.revoke")} {t("shares.revoke")}
</Button> </Button>
<Button loading={busy} onclick={applyLimits} pill> <Button loading={busy} onclick={applyLimits} pill>
{#if saved.current === share.id}
<Icon name="check" />
{t("shares.saved")}
{:else}
{t("common.save")} {t("common.save")}
{/if}
</Button> </Button>
{:else} {:else}
<Button fluid loading={busy} onclick={publish} pill> <Button fluid loading={busy} onclick={publish} pill>
@@ -333,6 +346,20 @@
</Dialog.Root> </Dialog.Root>
<style lang="scss"> <style lang="scss">
.done {
display: flex;
flex: 1;
gap: 0.375rem;
align-items: center;
justify-content: center;
height: 3rem;
margin: 0;
font-weight: var(--font-weight-medium);
color: var(--color-text-green);
}
.center { .center {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { SvelteSet } from "svelte/reactivity";
import { import {
deleteShare, deleteShare,
listShares, listShares,
@@ -11,6 +12,7 @@
import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte"; import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
@@ -19,6 +21,7 @@
import { formatDownloads, formatExpiry } from "#lib/format/shares.js"; import { formatDownloads, formatExpiry } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -31,10 +34,14 @@
let filter = $state<Filter>("active"); let filter = $state<Filter>("active");
let expanded = $state<number | null>(null); let expanded = $state<number | null>(null);
let token = 0; let token = 0;
const kept = new SvelteSet<number>();
const done = createFlash<number>();
const visible = $derived( const visible = $derived(
filter === "active" filter === "active"
? items.filter((item) => shareState(item) === "active") ? items.filter(
(item) => kept.has(item.id) || shareState(item) === "active"
)
: items : items
); );
const activeCount = $derived( const activeCount = $derived(
@@ -50,6 +57,7 @@
const rows = await listShares(activeOnly); const rows = await listShares(activeOnly);
if (current === token) { if (current === token) {
items = rows; items = rows;
kept.clear();
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -79,15 +87,11 @@
ui.closePanel(); ui.closePanel();
} }
async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share));
toasts.success(t("shares.copied"));
}
async function revoke(share: FileShare) { async function revoke(share: FileShare) {
try { try {
replace(await revokeShare(share.id)); replace(await revokeShare(share.id));
toasts.success(t("shares.revokedToast")); kept.add(share.id);
done.show(share.id);
} catch { } catch {
toasts.error(t("shares.revokeFailed")); toasts.error(t("shares.revokeFailed"));
} }
@@ -96,7 +100,7 @@
async function reissue(share: FileShare) { async function reissue(share: FileShare) {
try { try {
replace(await reissueShare(share.id)); replace(await reissueShare(share.id));
toasts.success(t("shares.reissued")); done.show(share.id);
} catch { } catch {
toasts.error(t("shares.reissueFailed")); toasts.error(t("shares.reissueFailed"));
} }
@@ -205,9 +209,12 @@
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}> <CopyItem
{t("shares.copy")} done={t("shares.copied")}
</ContextMenuItem> icon="copy"
label={t("shares.copy")}
text={() => shareUrl(share)}
/>
{#if share.message_id !== null || share.peer_id !== null} {#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}> <ContextMenuItem icon="reply" onselect={() => jump(share)}>
{t("ui.jumpToMessage")} {t("ui.jumpToMessage")}
@@ -247,7 +254,14 @@
<Icon name="reply" size="1rem" />{t("shares.toMessage")} <Icon name="reply" size="1rem" />{t("shares.toMessage")}
</button> </button>
{/if} {/if}
{#if state === "active"} {#if done.current === share.id}
<span class="done">
<Icon name="check" size="1rem" />
{state === "active"
? t("shares.reissued")
: t("shares.accessRevoked")}
</span>
{:else if state === "active"}
<button <button
class="pill danger" class="pill danger"
onclick={() => revoke(share)} onclick={() => revoke(share)}
@@ -461,6 +475,18 @@
} }
} }
.done {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0;
border: 1px solid transparent;
font-size: 0.8125rem;
color: var(--color-text-green);
}
.footnote { .footnote {
margin: 0; margin: 0;
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
@@ -18,6 +18,7 @@
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Group { interface Group {
@@ -45,6 +46,7 @@
let viewerItems = $state<StoryView[]>([]); let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0); let viewerPeerId = $state(0);
let backfilling = $state<number | null>(null); let backfilling = $state<number | null>(null);
const started = createFlash<number>();
async function load() { async function load() {
const current = token; const current = token;
@@ -150,7 +152,7 @@
backfilling = peerId; backfilling = peerId;
try { try {
await enqueueStoriesBackfill(peerId); await enqueueStoriesBackfill(peerId);
toasts.success(t("stories.backfillStarted")); started.show(peerId);
} catch { } catch {
toasts.error(t("stories.backfillFailed")); toasts.error(t("stories.backfillFailed"));
} finally { } finally {
@@ -178,6 +180,10 @@
{/if} {/if}
{:else} {:else}
{#each groups as group (group.peerId)} {#each groups as group (group.peerId)}
{@const fresh = started.current === group.peerId}
{@const backfillLabel = fresh
? t("stories.backfillStarted")
: t("stories.loadOlderStories")}
<section class="group"> <section class="group">
<header class="group-head"> <header class="group-head">
<button <button
@@ -200,14 +206,15 @@
</span> </span>
</button> </button>
<Button <Button
aria-label={t("stories.loadOlderStories")} aria-label={backfillLabel}
loading={backfilling === group.peerId} loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)} onclick={() => backfill(group.peerId)}
round round
smaller smaller
title={backfillLabel}
variant="translucent" variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name={fresh ? "check" : "cloud-download"} />
</Button> </Button>
</header> </header>
{#if expanded[group.peerId]} {#if expanded[group.peerId]}
@@ -11,6 +11,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -27,6 +28,7 @@
); );
let backfilling = $state(false); let backfilling = $state(false);
const started = createFlash<number>();
let items = $state<StoryView[]>([]); let items = $state<StoryView[]>([]);
let loading = $state(false); let loading = $state(false);
@@ -109,7 +111,7 @@
backfilling = true; backfilling = true;
try { try {
await enqueueStoriesBackfill(id); await enqueueStoriesBackfill(id);
toasts.success(t("stories.backfillStarted")); started.show(id);
} catch { } catch {
toasts.error(t("stories.backfillFailed")); toasts.error(t("stories.backfillFailed"));
} finally { } finally {
@@ -137,7 +139,11 @@
smaller smaller
variant="secondary" variant="secondary"
> >
{#if started.current === peerId}
<Icon name="check" />{t("stories.backfillStarted")}
{:else}
<Icon name="cloud-download" />{t("stories.loadOlder")} <Icon name="cloud-download" />{t("stories.loadOlder")}
{/if}
</Button> </Button>
<Button <Button
aria-label={t("ui.refresh")} aria-label={t("ui.refresh")}
@@ -63,7 +63,7 @@
{/snippet} {/snippet}
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
<ContextMenu.Content class="bg-menu-content"> <ContextMenu.Content class="bg-menu-content" collisionPadding={8}>
{@render menu()} {@render menu()}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Portal> </ContextMenu.Portal>
@@ -49,9 +49,5 @@
&.error { &.error {
background-color: var(--color-error); background-color: var(--color-error);
} }
&.success {
background-color: var(--color-green);
}
} }
</style> </style>
+7 -2
View File
@@ -1,4 +1,5 @@
import type { Account, PeerView } from "#lib/api/types.js"; import type { Account, PeerView } from "#lib/api/types.js";
import { t } from "#lib/i18n/locale.svelte.js";
const PEER_COLOR_COUNT = 7; const PEER_COLOR_COUNT = 7;
@@ -11,7 +12,7 @@ export function peerName(peer: PeerView | null): string {
return ""; return "";
} }
if (peer.is_deleted_account) { if (peer.is_deleted_account) {
return "Удалённый аккаунт"; return t("chat.deletedAccount");
} }
const parts = [peer.first_name, peer.last_name].filter(Boolean); const parts = [peer.first_name, peer.last_name].filter(Boolean);
if (parts.length > 0) { if (parts.length > 0) {
@@ -20,7 +21,7 @@ export function peerName(peer: PeerView | null): string {
if (peer.username) { if (peer.username) {
return `@${peer.username}`; return `@${peer.username}`;
} }
return "Удалённый аккаунт"; return t("chat.deletedAccount");
} }
export function accountName(account: Account): string { export function accountName(account: Account): string {
@@ -46,3 +47,7 @@ export function initials(name: string): string {
const last = lastWord ? lastWord[0] : ""; const last = lastWord ? lastWord[0] : "";
return (first + last).toUpperCase(); return (first + last).toUpperCase();
} }
export function formatPhone(phone: string): string {
return phone.startsWith("+") ? phone : `+${phone}`;
}
+38 -40
View File
@@ -1,6 +1,7 @@
import type { PresenceSample } from "#lib/api/types.js"; import type { PresenceSample } from "#lib/api/types.js";
import { formatDay, formatTime } from "#lib/format/datetime.js"; import { formatDay, formatTime } from "#lib/format/datetime.js";
import { plural } from "#lib/format/shares.js"; import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
const SECONDS_IN_MINUTE = 60; const SECONDS_IN_MINUTE = 60;
const MINUTES_IN_HOUR = 60; const MINUTES_IN_HOUR = 60;
@@ -8,32 +9,14 @@ const SECONDS_IN_HOUR = 3600;
const MS_IN_MINUTE = 60_000; const MS_IN_MINUTE = 60_000;
const SECONDS_IN_DAY = 86_400; const SECONDS_IN_DAY = 86_400;
const MS_IN_DAY = 86_400_000; const MS_IN_DAY = 86_400_000;
const DAYS_IN_WEEK = 7;
const MONDAY = Date.UTC(2024, 0, 1);
const TEXT = { const FUZZY: Record<string, MessageKey> = {
ago: (minutes: number) => last_month: "presence.lastMonth",
`был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`, last_week: "presence.lastWeek",
days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`, long_time_ago: "presence.longAgo",
hours: (hours: number) => `${hours} ч`, recently: "presence.recently",
hoursMinutes: (hours: number, minutes: number) => `${hours} ч ${minutes} мин`,
justNow: "был(а) только что",
lastMonth: "был(а) в этом месяце",
lastWeek: "был(а) на этой неделе",
longAgo: "был(а) давно",
minutes: (minutes: number) => `${minutes} мин`,
offline: "не в сети",
on: (day: string, time: string) => `был(а) ${day} в ${time}`,
online: "в сети",
recently: "был(а) недавно",
seconds: (seconds: number) => `${seconds} с`,
today: (time: string) => `был(а) сегодня в ${time}`,
yesterday: (time: string) => `был(а) вчера в ${time}`,
};
const FUZZY: Record<string, string> = {
last_month: TEXT.lastMonth,
last_week: TEXT.lastWeek,
long_time_ago: TEXT.longAgo,
recently: TEXT.recently,
}; };
export function isOnline( export function isOnline(
@@ -50,20 +33,20 @@ function lastSeen(iso: string, now: number): string {
const date = new Date(iso); const date = new Date(iso);
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE); const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
if (minutes < 1) { if (minutes < 1) {
return TEXT.justNow; return t("presence.justNow");
} }
if (minutes < MINUTES_IN_HOUR) { if (minutes < MINUTES_IN_HOUR) {
return TEXT.ago(minutes); return t("presence.ago", { count: minutes });
} }
const time = formatTime(iso); const time = formatTime(iso);
const today = new Date(now); const today = new Date(now);
if (date.toDateString() === today.toDateString()) { if (date.toDateString() === today.toDateString()) {
return TEXT.today(time); return t("presence.todayAt", { time });
} }
if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) { if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
return TEXT.yesterday(time); return t("presence.yesterdayAt", { time });
} }
return TEXT.on(formatDay(iso), time); return t("presence.on", { day: formatDay(iso), time });
} }
export function formatPresence( export function formatPresence(
@@ -72,29 +55,31 @@ export function formatPresence(
): string { ): string {
if (sample.status === "online") { if (sample.status === "online") {
return isOnline(sample, now) return isOnline(sample, now)
? TEXT.online ? t("presence.online")
: lastSeen(sample.next_offline_date ?? sample.ts, now); : lastSeen(sample.next_offline_date ?? sample.ts, now);
} }
const fuzzy = FUZZY[sample.status]; const fuzzy = FUZZY[sample.status];
if (fuzzy) { if (fuzzy) {
return fuzzy; return t(fuzzy);
} }
return sample.last_online_date return sample.last_online_date
? lastSeen(sample.last_online_date, now) ? lastSeen(sample.last_online_date, now)
: TEXT.offline; : t("presence.offline");
} }
export function formatSpan(seconds: number): string { export function formatSpan(seconds: number): string {
if (seconds < SECONDS_IN_MINUTE) { if (seconds < SECONDS_IN_MINUTE) {
return TEXT.seconds(Math.round(seconds)); return t("presence.seconds", { seconds: Math.round(seconds) });
} }
const minutes = Math.round(seconds / SECONDS_IN_MINUTE); const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
if (minutes < MINUTES_IN_HOUR) { if (minutes < MINUTES_IN_HOUR) {
return TEXT.minutes(minutes); return t("presence.minutes", { minutes });
} }
const hours = Math.floor(minutes / MINUTES_IN_HOUR); const hours = Math.floor(minutes / MINUTES_IN_HOUR);
const rest = minutes % MINUTES_IN_HOUR; const rest = minutes % MINUTES_IN_HOUR;
return rest === 0 ? TEXT.hours(hours) : TEXT.hoursMinutes(hours, rest); return rest === 0
? t("presence.hours", { hours })
: t("presence.hoursMinutes", { hours, minutes: rest });
} }
export function formatClock(secondsOfDay: number): string { export function formatClock(secondsOfDay: number): string {
@@ -111,7 +96,20 @@ export function formatLongSpan(seconds: number): string {
} }
const days = Math.floor(seconds / SECONDS_IN_DAY); const days = Math.floor(seconds / SECONDS_IN_DAY);
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR); const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
return hours === 0 const text = t("presence.days", { count: days });
? TEXT.days(days) return hours === 0 ? text : `${text} ${t("presence.hours", { hours })}`;
: `${TEXT.days(days)} ${TEXT.hours(hours)}`; }
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function weekdayLabels(): string[] {
const format = new Intl.DateTimeFormat(locale.tag, {
timeZone: "UTC",
weekday: "short",
});
return Array.from({ length: DAYS_IN_WEEK }, (_, day) =>
capitalize(format.format(MONDAY + day * MS_IN_DAY))
);
} }
-24
View File
@@ -2,30 +2,6 @@ import type { FileShare } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
const TEEN_START = 11;
const TEEN_END = 14;
const FEW_END = 4;
export function plural(
count: number,
one: string,
few: string,
many: string
): string {
const tail = count % 100;
if (tail >= TEEN_START && tail <= TEEN_END) {
return many;
}
const last = count % 10;
if (last === 1) {
return one;
}
if (last >= 2 && last <= FEW_END) {
return few;
}
return many;
}
export function formatDownloads(share: FileShare): string { export function formatDownloads(share: FileShare): string {
if (share.max_downloads === null) { if (share.max_downloads === null) {
return t("shares.downloads", { count: share.download_count }); return t("shares.downloads", { count: share.download_count });
+6
View File
@@ -10,12 +10,15 @@ import { login } from "#lib/i18n/messages/login.js";
import { media } from "#lib/i18n/messages/media.js"; import { media } from "#lib/i18n/messages/media.js";
import { menu } from "#lib/i18n/messages/menu.js"; import { menu } from "#lib/i18n/messages/menu.js";
import { notes } from "#lib/i18n/messages/notes.js"; import { notes } from "#lib/i18n/messages/notes.js";
import { parts } from "#lib/i18n/messages/parts.js";
import { policy } from "#lib/i18n/messages/policy.js"; import { policy } from "#lib/i18n/messages/policy.js";
import { presence } from "#lib/i18n/messages/presence.js";
import { profile } from "#lib/i18n/messages/profile.js"; import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
import { search } from "#lib/i18n/messages/search.js"; import { search } from "#lib/i18n/messages/search.js";
import { service } from "#lib/i18n/messages/service.js"; import { service } from "#lib/i18n/messages/service.js";
import { settings } from "#lib/i18n/messages/settings.js"; import { settings } from "#lib/i18n/messages/settings.js";
import { shared } from "#lib/i18n/messages/shared.js";
import { shares } from "#lib/i18n/messages/shares.js"; import { shares } from "#lib/i18n/messages/shares.js";
import { social } from "#lib/i18n/messages/social.js"; import { social } from "#lib/i18n/messages/social.js";
import { storage } from "#lib/i18n/messages/storage.js"; import { storage } from "#lib/i18n/messages/storage.js";
@@ -37,12 +40,15 @@ export const messages = {
...media, ...media,
...menu, ...menu,
...notes, ...notes,
...parts,
...policy, ...policy,
...presence,
...profile, ...profile,
...read, ...read,
...search, ...search,
...service, ...service,
...settings, ...settings,
...shared,
...shares, ...shares,
...social, ...social,
...storage, ...storage,
-12
View File
@@ -10,10 +10,6 @@ export const jobs = {
en: "A full backfill rereads the whole chat history from the very beginning. The button in the chat header only loads messages newer than the last saved one.", en: "A full backfill rereads the whole chat history from the very beginning. The button in the chat header only loads messages newer than the last saved one.",
ru: "Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в шапке чата догружает только сообщения новее последнего сохранённого.", ru: "Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в шапке чата догружает только сообщения новее последнего сохранённого.",
}, },
"jobs.backfillStarted": {
en: "Full backfill started",
ru: "Полный бэкфилл запущен",
},
"jobs.chat": { en: "chat {id}", ru: "чат {id}" }, "jobs.chat": { en: "chat {id}", ru: "чат {id}" },
"jobs.contactsFailed": { "jobs.contactsFailed": {
en: "Could not sync contacts", en: "Could not sync contacts",
@@ -23,10 +19,6 @@ export const jobs = {
en: "Contacts are synced separately, then search finds people you haven't chatted with yet.", en: "Contacts are synced separately, then search finds people you haven't chatted with yet.",
ru: "Контакты подтягиваются отдельно, тогда в поиске появятся люди, с которыми ещё не было переписки.", ru: "Контакты подтягиваются отдельно, тогда в поиске появятся люди, с которыми ещё не было переписки.",
}, },
"jobs.contactsStarted": {
en: "Contact sync started",
ru: "Синхронизация контактов запущена",
},
"jobs.dialogs": { en: "Chats", ru: "Диалоги" }, "jobs.dialogs": { en: "Chats", ru: "Диалоги" },
"jobs.dialogsHint": { "jobs.dialogsHint": {
en: "Sync pulls in every chat of the account, so old chats show up and can be backfilled.", en: "Sync pulls in every chat of the account, so old chats show up and can be backfilled.",
@@ -76,9 +68,5 @@ export const jobs = {
en: "Could not sync chats", en: "Could not sync chats",
ru: "Не удалось синхронизировать диалоги", ru: "Не удалось синхронизировать диалоги",
}, },
"jobs.syncStarted": {
en: "Chat sync started",
ru: "Синхронизация диалогов запущена",
},
"jobs.title": { en: "Jobs", ru: "Задачи" }, "jobs.title": { en: "Jobs", ru: "Задачи" },
} satisfies Messages; } satisfies Messages;
-1
View File
@@ -1,7 +1,6 @@
import type { Messages } from "#lib/i18n/types.js"; import type { Messages } from "#lib/i18n/types.js";
export const login = { export const login = {
"login.added": { en: "Account added", ru: "Аккаунт добавлен" },
"login.byPhone": { "login.byPhone": {
en: "Log in by phone number", en: "Log in by phone number",
ru: "Войти по номеру телефона", ru: "Войти по номеру телефона",
-4
View File
@@ -27,10 +27,6 @@ export const media = {
en: "Play voice message", en: "Play voice message",
ru: "Воспроизвести голосовое", ru: "Воспроизвести голосовое",
}, },
"media.queued": {
en: "Download queued",
ru: "Скачивание поставлено в очередь",
},
"media.queueFailed": { "media.queueFailed": {
en: "Could not queue the download", en: "Could not queue the download",
ru: "Не удалось поставить в очередь", ru: "Не удалось поставить в очередь",
+22
View File
@@ -0,0 +1,22 @@
import type { Messages } from "#lib/i18n/types.js";
export const parts = {
"folders.all": { en: "All Chats", ru: "Все чаты" },
"forward.channel": { en: "Channel", ru: "Канал" },
"forward.from": { en: "Forwarded from", ru: "Переслано от" },
"forward.hidden": { en: "Hidden account", ru: "Скрытый аккаунт" },
"forward.unknown": { en: "Unknown sender", ru: "Неизвестный отправитель" },
"inline.copied": { en: "Copied", ru: "Скопировано" },
"pinned.message": { en: "Pinned Message", ru: "Закреплённое сообщение" },
"versions.failed": {
en: "Couldn't load the edit history",
ru: "Не удалось загрузить историю изменений",
},
"versions.media": { en: "Media versions", ru: "Версии медиа" },
"versions.none": {
en: "No versions saved.",
ru: "Версии не сохранились.",
},
"versions.title": { en: "Edit history", ru: "История изменений" },
"versions.version": { en: "Version {number}", ru: "Версия {number}" },
} satisfies Messages;
+134
View File
@@ -0,0 +1,134 @@
import type { Messages } from "#lib/i18n/types.js";
export const presence = {
"presence.ago": {
en: "last seen {count} min ago",
ru: {
few: "был(а) {count} минуты назад",
many: "был(а) {count} минут назад",
one: "был(а) {count} минуту назад",
other: "был(а) {count} минуты назад",
},
},
"presence.average": { en: "Daily average", ru: "В среднем за день" },
"presence.axisToday": { en: "today", ru: "сегодня" },
"presence.byDay": { en: "By day", ru: "По дням" },
"presence.byHour": { en: "By hour of the week", ru: "По часам недели" },
"presence.cellAverage": {
en: "{slot}, {span} online on average",
ru: "{slot}, в среднем {span} в сети",
},
"presence.cellNoData": { en: "{slot}, no data", ru: "{slot}, нет данных" },
"presence.day": { en: "{day}, {span} online", ru: "{day}, {span} в сети" },
"presence.dayGap": {
en: "{day}, {span} online, no data for {gap}",
ru: "{day}, {span} в сети, нет данных {gap}",
},
"presence.dayNoData": { en: "{day}, no data", ru: "{day}, нет данных" },
"presence.days": {
en: { one: "{count} day", other: "{count} days" },
ru: {
few: "{count} дня",
many: "{count} дней",
one: "{count} день",
other: "{count} дня",
},
},
"presence.failed": {
en: "Couldn't load analytics.",
ru: "Не удалось загрузить аналитику.",
},
"presence.firstOnline": {
en: "Usually comes online",
ru: "Обычно появляется",
},
"presence.hidden": {
en: "Hides their last seen time. Telegram only shows “last seen recently”, so time online can't be counted.",
ru: "Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
},
"presence.hours": { en: "{hours} h", ru: "{hours} ч" },
"presence.hoursMinutes": {
en: "{hours} h {minutes} min",
ru: "{hours} ч {minutes} мин",
},
"presence.justNow": { en: "last seen just now", ru: "был(а) только что" },
"presence.lastMonth": {
en: "last seen within a month",
ru: "был(а) в этом месяце",
},
"presence.lastOffline": { en: "Usually goes offline", ru: "Обычно уходит" },
"presence.lastWeek": {
en: "last seen within a week",
ru: "был(а) на этой неделе",
},
"presence.longAgo": {
en: "last seen a long time ago",
ru: "был(а) давно",
},
"presence.max": { en: "up to {span}", ru: "до {span}" },
"presence.median": { en: "Typical session", ru: "Обычная сессия" },
"presence.messages": { en: "Messages", ru: "Сообщения" },
"presence.mine": { en: "I reply in", ru: "Я отвечаю" },
"presence.minutes": { en: "{minutes} min", ru: "{minutes} мин" },
"presence.missing": { en: "No data", ru: "Нет данных" },
"presence.noData": { en: "no data", ru: "нет данных" },
"presence.noMessages": {
en: "No messages in this period.",
ru: "За этот период сообщений нет.",
},
"presence.noSessions": {
en: "Not online in this period.",
ru: "За этот период в сети не был(а).",
},
"presence.noStatus": {
en: "No online statuses yet.",
ru: "Статусов пока нет.",
},
"presence.offline": { en: "offline", ru: "не в сети" },
"presence.on": {
en: "last seen {day} at {time}",
ru: "был(а) {day} в {time}",
},
"presence.online": { en: "online", ru: "в сети" },
"presence.recently": { en: "last seen recently", ru: "был(а) недавно" },
"presence.seconds": { en: "{seconds} sec", ru: "{seconds} с" },
"presence.sessions": { en: "Sessions", ru: "Сессии" },
"presence.sessionsFailed": {
en: "Couldn't load sessions.",
ru: "Не удалось загрузить сессии.",
},
"presence.since": { en: "since {time}", ru: "с {time}" },
"presence.theirs": { en: "They reply in", ru: "Мне отвечают" },
"presence.today": { en: "Today", ru: "Сегодня" },
"presence.todayAt": {
en: "last seen today at {time}",
ru: "был(а) сегодня в {time}",
},
"presence.total": {
en: {
one: "online in the last {count} day",
other: "online in the last {count} days",
},
ru: {
few: "в сети за {count} дня",
many: "в сети за {count} дней",
one: "в сети за {count} день",
other: "в сети за {count} дня",
},
},
"presence.volume": {
en: "{day}, {total} in total: {incoming} received, {outgoing} sent",
ru: "{day}, всего {total}: входящих {incoming}, моих {outgoing}",
},
"presence.volumeEmpty": {
en: "{day}, no messages",
ru: "{day}, нет сообщений",
},
"presence.volumeIncoming": { en: "received", ru: "входящие" },
"presence.volumeOutgoing": { en: "sent", ru: "мои" },
"presence.yesterday": { en: "Yesterday", ru: "Вчера" },
"presence.yesterdayAt": {
en: "last seen yesterday at {time}",
ru: "был(а) вчера в {time}",
},
} satisfies Messages;
+9
View File
@@ -0,0 +1,9 @@
import type { Messages } from "#lib/i18n/types.js";
export const shared = {
"shared.media.empty": { en: "Nothing here yet", ru: "Здесь пока пусто" },
"shared.media.fullscreen": {
en: "Open full screen",
ru: "Открыть на весь экран",
},
} satisfies Messages;
+2 -6
View File
@@ -1,6 +1,7 @@
import type { Messages } from "#lib/i18n/types.js"; import type { Messages } from "#lib/i18n/types.js";
export const shares = { export const shares = {
"shares.accessRevoked": { en: "Access revoked", ru: "Доступ отозван" },
"shares.checkFailed": { "shares.checkFailed": {
en: "Could not check the link", en: "Could not check the link",
ru: "Не удалось проверить ссылку", ru: "Не удалось проверить ссылку",
@@ -79,7 +80,6 @@ export const shares = {
"shares.noChat": { en: "No chat", ru: "Без чата" }, "shares.noChat": { en: "No chat", ru: "Без чата" },
"shares.noExpiry": { en: "No expiry", ru: "Бессрочно" }, "shares.noExpiry": { en: "No expiry", ru: "Бессрочно" },
"shares.noLimit": { en: "No limit", ru: "Без лимита" }, "shares.noLimit": { en: "No limit", ru: "Без лимита" },
"shares.opened": { en: "Link sharing is on", ru: "Доступ по ссылке открыт" },
"shares.openFailed": { "shares.openFailed": {
en: "Could not share the file", en: "Could not share the file",
ru: "Не удалось открыть доступ", ru: "Не удалось открыть доступ",
@@ -115,11 +115,11 @@ export const shares = {
}, },
"shares.revoke": { en: "Revoke", ru: "Отозвать" }, "shares.revoke": { en: "Revoke", ru: "Отозвать" },
"shares.revoked": { en: "revoked", ru: "отозвана" }, "shares.revoked": { en: "revoked", ru: "отозвана" },
"shares.revokedToast": { en: "Access revoked", ru: "Доступ отозван" },
"shares.revokeFailed": { "shares.revokeFailed": {
en: "Could not revoke access", en: "Could not revoke access",
ru: "Не удалось отозвать доступ", ru: "Не удалось отозвать доступ",
}, },
"shares.saved": { en: "Saved", ru: "Сохранено" },
"shares.state.active": { en: "Active", ru: "Открыт" }, "shares.state.active": { en: "Active", ru: "Открыт" },
"shares.state.exhausted": { en: "Limit reached", ru: "Лимит исчерпан" }, "shares.state.exhausted": { en: "Limit reached", ru: "Лимит исчерпан" },
"shares.state.expired": { en: "Expired", ru: "Истёк" }, "shares.state.expired": { en: "Expired", ru: "Истёк" },
@@ -128,10 +128,6 @@ export const shares = {
"shares.tab.all": { en: "All", ru: "Все" }, "shares.tab.all": { en: "All", ru: "Все" },
"shares.toMessage": { en: "Go to message", ru: "К сообщению" }, "shares.toMessage": { en: "Go to message", ru: "К сообщению" },
"shares.until": { en: "until {date}", ru: "до {date}" }, "shares.until": { en: "until {date}", ru: "до {date}" },
"shares.updated": {
en: "Link settings updated",
ru: "Настройки ссылки обновлены",
},
"shares.updateFailed": { "shares.updateFailed": {
en: "Could not update the link", en: "Could not update the link",
ru: "Не удалось обновить ссылку", ru: "Не удалось обновить ссылку",
+1 -4
View File
@@ -11,11 +11,8 @@ export const track = {
ru: "Включите отслеживание, чтобы собирать сообщения, медиа и статистику по этому чату.", ru: "Включите отслеживание, чтобы собирать сообщения, медиа и статистику по этому чату.",
}, },
"track.loadHistory": { en: "Load history", ru: "Загрузить историю" }, "track.loadHistory": { en: "Load history", ru: "Загрузить историю" },
"track.loading": { en: "History is loading", ru: "История загружается" },
"track.start": { en: "Start tracking", ru: "Начать отслеживать" }, "track.start": { en: "Start tracking", ru: "Начать отслеживать" },
"track.started": {
en: "Tracking is on, loading the history",
ru: "Отслеживание включено, история загружается",
},
"track.thisChat": { en: "This chat", ru: "Этот чат" }, "track.thisChat": { en: "This chat", ru: "Этот чат" },
"track.tracked": { "track.tracked": {
en: "{title} is tracked, new messages and stats are collected automatically.", en: "{title} is tracked, new messages and stats are collected automatically.",
+19
View File
@@ -0,0 +1,19 @@
const FLASH_MS = 2000;
export function createFlash<T>() {
let current = $state<T | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
return {
get current(): T | null {
return current;
},
show(value: T) {
clearTimeout(timer);
current = value;
timer = setTimeout(() => {
current = null;
}, FLASH_MS);
},
};
}
+1 -4
View File
@@ -1,4 +1,4 @@
type ToastType = "info" | "error" | "success"; type ToastType = "info" | "error";
interface Toast { interface Toast {
id: number; id: number;
@@ -39,9 +39,6 @@ function createToasts() {
return items; return items;
}, },
show, show,
success(message: string) {
return show(message, "success");
},
}; };
} }
+1
View File
@@ -164,6 +164,7 @@ html.theme-transition * {
.bg-menu-content { .bg-menu-content {
z-index: var(--z-portal-menu); z-index: var(--z-portal-menu);
min-width: 12rem; min-width: 12rem;
max-width: calc(100vw - 1rem);
padding: 0.375rem; padding: 0.375rem;
border-radius: 0.75rem; border-radius: 0.75rem;
color: var(--color-text); color: var(--color-text);