feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes

This commit is contained in:
hh committed 2026-08-13 03:17:36 +02:00
1 parent 683b9a31a3
commit 3e08698b62
38 files changed
+2889 -134

No files matched your search

+125 -18
View File
@@ -2,12 +2,17 @@
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "$lib/api/client";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
import type { ViewerItem } from "$lib/api/media";
import Button from "$lib/components/ui/Button.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
@@ -26,8 +31,11 @@
let kind = $state("");
let messageId = $state<number | null>(null);
let currentMediaId = $state<number | null>(null);
let fileName = $state<string | null>(null);
let result = $state<MediaResult | null>(null);
let loading = $state(false);
let saving = $state(false);
let token = 0;
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
@@ -39,6 +47,10 @@
mime.startsWith("audio/") || kind === "voice" || kind === "audio"
);
const hasNav = $derived(items.length > 1);
const canSave = $derived(
currentMediaId !== null && result?.state === "ready"
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
function revoke() {
if (result?.state === "ready") {
@@ -52,21 +64,27 @@
result = null;
kind = item.kind;
messageId = item.messageId;
currentMediaId = null;
fileName = item.fileName;
const current = ++token;
try {
let mediaId = item.mediaId;
let downloaded = item.downloaded;
let name = item.fileName;
if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id;
downloaded = meta.downloaded;
kind = meta.kind;
name = meta.file_name;
}
const next = downloaded
? await requestMedia(mediaId)
: ({ state: "not-downloaded" } as MediaResult);
if (current === token) {
result = next;
currentMediaId = mediaId;
fileName = name;
}
} catch {
if (current === token) {
@@ -85,9 +103,29 @@
}
try {
await fetchMedia(chatId, messageId);
toasts.success("Download queued");
toasts.success("Скачивание поставлено в очередь");
} catch {
toasts.error("Failed to queue download");
toasts.error("Не удалось поставить в очередь");
}
}
async function save() {
if (currentMediaId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(currentMediaId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (currentMediaId !== null) {
shareUi.share({ kind: "media", mediaId: currentMediaId }, title);
}
}
@@ -135,10 +173,43 @@
<Dialog.Portal>
<Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content">
<Dialog.Title class="media-title">{kind || "Media"}</Dialog.Title>
<Dialog.Close class="media-close" aria-label="Close">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
<Dialog.Title class="media-title">{title}</Dialog.Title>
<div class="media-actions">
{#if canSave}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="media-action"
aria-label="Скачать файл"
onclick={save}
>
<Icon name={saving ? "timer" : "download"} size="1.375rem" />
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/snippet}
</ContextMenu>
<button
type="button"
class="media-action"
aria-label="Доступ по ссылке"
onclick={share}
>
<Icon name="allow-share" size="1.375rem" />
</button>
{/if}
<Dialog.Close class="media-close" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
</div>
{#if hasNav}
<span class="media-counter">{index + 1} / {items.length}</span>
{/if}
@@ -162,19 +233,19 @@
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio>
{:else if result?.state === "ready"}
<a class="media-download" href={result.url} download>
<button class="media-download" type="button" onclick={save}>
<Icon name="download" />
Download file
</a>
Скачать файл
</button>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
<p>This media has not been downloaded yet.</p>
<p>Файл ещё не скачан в архив.</p>
<Button variant="primary" fluid onclick={queueFetch}>
Fetch media
Скачать в архив
</Button>
</div>
{:else if result?.state === "missing"}
<p class="media-message">Media not found.</p>
<p class="media-message">Файл не найден.</p>
{/if}
</div>
{#if hasNav}
@@ -222,14 +293,18 @@
:global(.media-title) {
position: absolute;
top: 1rem;
top: 1.25rem;
left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
text-transform: capitalize;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-counter {
@@ -244,9 +319,6 @@
:global(.media-close) {
cursor: pointer;
position: absolute;
top: 0.75rem;
right: 1rem;
display: flex;
align-items: center;
@@ -287,8 +359,43 @@
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: 0;
border-radius: 1.5rem;
font-family: inherit;
font-size: 0.9375rem;
color: var(--color-white);
text-decoration: none;
cursor: pointer;
background-color: rgba(255, 255, 255, 0.12);
}
.media-actions {
position: absolute;
top: 0.75rem;
right: 1rem;
display: flex;
align-items: center;
gap: 0.375rem;
z-index: 1;
}
.media-action {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 50%;
color: var(--color-white);
background-color: rgba(255, 255, 255, 0.1);
}
.media-nav {
@@ -1,5 +1,6 @@
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
@@ -15,7 +16,9 @@
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -33,6 +36,7 @@
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let saving = $state(false);
const ready = $derived(media?.state === "ready" ? media : null);
const kind = $derived(ready?.kind ?? "");
@@ -56,6 +60,7 @@
const label = $derived(
media && media.state !== "missing" ? media.kind : "media"
);
const storedId = $derived(ready?.mediaId ?? null);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -86,14 +91,37 @@
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Download queued");
toasts.success("Скачивание поставлено в очередь");
poll();
} catch {
toasts.error("Failed to queue download");
toasts.error("Не удалось поставить в очередь");
} finally {
queuing = false;
}
}
async function save() {
if (storedId === null || saving) {
return;
}
saving = true;
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
} finally {
saving = false;
}
}
function share() {
if (storedId !== null) {
shareUi.share(
{ kind: "media", mediaId: storedId },
mediaKindLabel(kind) ?? "Файл"
);
}
}
</script>
<ContextMenu>
@@ -102,7 +130,7 @@
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Self-destruct media</span>
<span>Самоуничтожающееся медиа</span>
</button>
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
@@ -161,18 +189,18 @@
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Video" : "Photo"}</span>
<small>{queuing ? "Queued" : "Tap to download"}</small>
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "Queued" : `Download ${label}`}</span>
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Media</span>
<span>Медиа</span>
</button>
{/if}
</div>
@@ -186,7 +214,18 @@
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
>
<ContextMenuItem icon="download" onselect={queue}>Скачать</ContextMenuItem>
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
@@ -6,6 +6,7 @@
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "$lib/components/profile/ProfilePanel.svelte";
import ChatSearchPanel from "$lib/components/search/ChatSearchPanel.svelte";
import SharesPanel from "$lib/components/shares/SharesPanel.svelte";
import CallbacksPanel from "$lib/components/social/CallbacksPanel.svelte";
import LinksPanel from "$lib/components/social/LinksPanel.svelte";
import ReactionsPanel from "$lib/components/social/ReactionsPanel.svelte";
@@ -31,6 +32,7 @@
policy: "Политика захвата",
watches: "Отслеживания",
alerts: "Алерты",
shares: "Файлы по ссылке",
};
</script>
@@ -74,6 +76,8 @@
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
<AnnotationsPanel />
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { downloadMedia } from "$lib/api/download";
import { getChatMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
@@ -19,6 +20,8 @@
import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster";
import { accounts } from "$lib/stores/accounts.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { isMobile } from "$lib/viewport";
@@ -46,9 +49,26 @@
mediaId: item.id,
kind: item.kind,
downloaded: item.downloaded,
fileName: item.file_name,
}))
);
function displayName(item: MediaView): string {
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл";
}
async function save(item: MediaView) {
try {
await downloadMedia(item.id);
} catch {
toasts.error("Не удалось скачать файл");
}
}
function share(item: MediaView) {
shareUi.share({ kind: "media", mediaId: item.id }, displayName(item));
}
async function loadMore() {
if (loading || done) {
return;
@@ -113,6 +133,7 @@
kind: item.kind,
downloaded: item.downloaded,
mime: item.mime,
file_name: item.file_name,
file_size: item.file_size,
ttl_seconds: item.ttl_seconds,
duration: null,
@@ -197,6 +218,14 @@
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
{/each}
@@ -215,7 +244,7 @@
>
<span class="file-icon"><Icon name="document" /></span>
<span class="meta">
<span class="name">{mediaKindLabel(item.kind)}</span>
<span class="name">{displayName(item)}</span>
<span class="sub">
{formatListDate(item.created_at)}
{#if item.file_size}
@@ -232,6 +261,14 @@
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem
>
{#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
</li>
@@ -52,6 +52,11 @@
label="Сторис"
onclick={() => ui.openPanel("stories-all")}
/>
<SettingsItem
icon="allow-share"
label="Файлы по ссылке"
onclick={() => ui.openPanel("shares")}
/>
<SettingsItem
icon="eye"
label="Отслеживания"
@@ -0,0 +1,472 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { ApiError } from "$lib/api/client";
import {
createShare,
lookupShare,
revokeShare,
type ShareSettings,
shareUrl,
updateShare,
} from "$lib/api/shares";
import type { FileShare, ShareSubject } from "$lib/api/types";
import ShareLinkField from "$lib/components/shares/ShareLinkField.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import QrCode from "$lib/components/ui/QrCode.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatShareLimits } from "$lib/format/shares";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
label?: string;
onchange?: (share: FileShare | null) => void;
open: boolean;
subject: ShareSubject | null;
}
let {
open = $bindable(),
subject,
label = "файл",
onchange,
}: Props = $props();
const HOUR = 3600;
const DAY = 24 * HOUR;
const KEEP = -1;
const expiryChoices = [
{ label: "Бессрочно", value: null },
{ label: "1 час", value: HOUR },
{ label: "24 часа", value: DAY },
{ label: "7 дней", value: 7 * DAY },
{ label: "30 дней", value: 30 * DAY },
];
const keepChoice = { label: "Как есть", value: KEEP };
const limitChoices = [
{ label: "Без лимита", value: null },
{ label: "1", value: 1 },
{ label: "5", value: 5 },
{ label: "25", value: 25 },
{ label: "100", value: 100 },
];
let share = $state<FileShare | null>(null);
let loading = $state(false);
let busy = $state(false);
let expiresIn = $state<number | null>(null);
let maxDownloads = $state<number | null>(null);
let showQr = $state(false);
let token = 0;
const settings = $derived<ShareSettings>({
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
keepExpiry: expiresIn === KEEP,
maxDownloads,
});
const liveExpiryChoices = $derived(
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
);
function fail(error: unknown, fallback: string) {
toasts.error(error instanceof ApiError ? error.detail : fallback);
}
function adopt(next: FileShare | null) {
share = next;
onchange?.(next);
}
async function load(target: ShareSubject) {
loading = true;
share = null;
showQr = false;
expiresIn = null;
maxDownloads = null;
const current = ++token;
try {
const found = await lookupShare(target);
if (current === token) {
share = found;
maxDownloads = found?.max_downloads ?? null;
expiresIn = found?.expires_at ? KEEP : null;
}
} catch (error) {
if (current === token) {
fail(error, "Не удалось проверить ссылку");
open = false;
}
} finally {
if (current === token) {
loading = false;
}
}
}
async function publish() {
if (!subject || busy) {
return;
}
busy = true;
try {
adopt(await createShare(subject, settings));
toasts.success("Доступ по ссылке открыт");
} catch (error) {
fail(error, "Не удалось открыть доступ");
} finally {
busy = false;
}
}
async function applyLimits() {
if (!share || busy) {
return;
}
busy = true;
try {
adopt(await updateShare(share.id, settings));
toasts.success("Настройки ссылки обновлены");
} catch (error) {
fail(error, "Не удалось обновить ссылку");
} finally {
busy = false;
}
}
async function revoke() {
if (!share || busy) {
return;
}
busy = true;
try {
await revokeShare(share.id);
adopt(null);
toasts.success("Доступ отозван");
open = false;
} catch (error) {
fail(error, "Не удалось отозвать доступ");
} finally {
busy = false;
}
}
$effect(() => {
const target = subject;
const isOpen = open;
untrack(() => {
if (isOpen && target) {
load(target);
}
});
});
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if loading}
<div class="center"><Spinner /></div>
{:else if share}
<div class="subject live">
<span class="badge"
><Icon name="allow-share" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{share.file_name}</strong>
<span>{formatShareLimits(share)}</span>
</div>
</div>
<ShareLinkField url={shareUrl(share)} />
<div class="qr-row">
<button
type="button"
class="qr-toggle"
onclick={() => {
showQr = !showQr;
}}
>
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
</button>
</div>
{#if showQr}
<div class="qr-slot">
<QrCode value={shareUrl(share)} label="QR-код ссылки на файл" />
</div>
{/if}
<p class="hint raw">
Ссылка отдаёт файл как есть —
<code>curl -O {shareUrl(share)}</code>
скачает его без авторизации. Просмотры превью в Telegram не
списывают скачивания.
</p>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => {
maxDownloads = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<div class="segments">
{#each liveExpiryChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={expiresIn === choice.value}
onclick={() => {
expiresIn = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
{:else}
<div class="subject">
<span class="badge muted"
><Icon name="lock" size="1.125rem" /></span
>
<div class="subject-text">
<strong>{label}</strong>
<span>Сейчас доступен только вам</span>
</div>
</div>
<fieldset class="choices">
<legend>Срок жизни ссылки</legend>
<div class="segments">
{#each expiryChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={expiresIn === choice.value}
onclick={() => {
expiresIn = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<fieldset class="choices">
<legend>Лимит скачиваний</legend>
<div class="segments">
{#each limitChoices as choice (choice.label)}
<button
type="button"
class="segment"
class:selected={maxDownloads === choice.value}
onclick={() => {
maxDownloads = choice.value;
}}
>
{choice.label}
</button>
{/each}
</div>
</fieldset>
<p class="hint">
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
момент.
</p>
{/if}
</div>
{#if !loading}
<div class="dialog-actions">
{#if share}
<Button variant="danger" pill loading={busy} onclick={revoke}>
Отозвать
</Button>
<Button pill loading={busy} onclick={applyLimits}>Сохранить</Button>
{:else}
<Button pill fluid loading={busy} onclick={publish}>
Открыть доступ
</Button>
{/if}
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.subject {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.badge {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-green);
&.muted {
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
}
.subject-text {
display: flex;
flex-direction: column;
min-width: 0;
strong {
overflow: hidden;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
span {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.qr-row {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.qr-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 0;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-primary);
cursor: pointer;
background: transparent;
}
.qr-slot {
--qr-bg: transparent;
--qr-fg: var(--color-text);
width: min(11rem, 60%);
margin: 0.25rem auto 0;
}
.hint {
margin: 1rem 0 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-secondary);
&.raw {
margin-top: 0.875rem;
}
code {
overflow-wrap: anywhere;
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
background-color: var(--color-background-secondary);
}
}
.choices {
margin: 1.25rem 0 0;
padding: 0;
border: 0;
}
legend {
padding: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.segments {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.segment {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-borders);
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text);
cursor: pointer;
background-color: transparent;
transition:
background-color 0.15s,
border-color 0.15s,
color 0.15s;
&.selected {
border-color: transparent;
color: var(--color-white);
background-color: var(--color-primary);
}
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
url: string;
}
const { url }: Props = $props();
const COPIED_MS = 1600;
let copied = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
async function copy() {
try {
await navigator.clipboard.writeText(url);
} catch {
toasts.error("Не удалось скопировать ссылку");
return;
}
copied = true;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
copied = false;
}, COPIED_MS);
}
</script>
<button type="button" class="link-field" class:copied onclick={copy}>
<span class="url">{url}</span>
<span class="action">
<Icon name={copied ? "check" : "copy"} size="1.125rem" />
</span>
</button>
<style lang="scss">
.link-field {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.625rem 0.625rem 0.875rem;
border: 1px solid var(--color-borders);
border-radius: 0.75rem;
font-family: inherit;
text-align: start;
cursor: pointer;
background-color: var(--color-background-secondary);
transition:
border-color 0.15s,
color 0.15s;
&.copied {
border-color: var(--color-green);
color: var(--color-green);
}
}
.url {
overflow-wrap: anywhere;
flex: 1;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.8125rem;
line-height: 1.35;
color: inherit;
}
.action {
display: flex;
flex-shrink: 0;
color: inherit;
}
</style>
@@ -0,0 +1,472 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
deleteShare,
listShares,
reissueShare,
revokeShare,
shareState,
shareUrl,
} from "$lib/api/shares";
import type { FileShare } from "$lib/api/types";
import ShareLinkField from "$lib/components/shares/ShareLinkField.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { formatBytes } from "$lib/format/media";
import { formatDownloads, formatExpiry, plural } from "$lib/format/shares";
import { accounts } from "$lib/stores/accounts.svelte";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
type Filter = "active" | "all";
const stateLabels: Record<string, string> = {
active: "Открыт",
revoked: "Отозван",
expired: "Истёк",
exhausted: "Лимит исчерпан",
};
let items = $state<FileShare[]>([]);
let loading = $state(false);
let filter = $state<Filter>("active");
let expanded = $state<number | null>(null);
let token = 0;
const visible = $derived(
filter === "active"
? items.filter((item) => shareState(item) === "active")
: items
);
const activeCount = $derived(
items.filter((item) => shareState(item) === "active").length
);
async function load() {
const current = ++token;
loading = true;
try {
const rows = await listShares();
if (current === token) {
items = rows;
}
} catch {
if (current === token) {
toasts.error("Не удалось загрузить список ссылок");
}
} finally {
if (current === token) {
loading = false;
}
}
}
function replace(next: FileShare) {
items = items.map((item) => (item.id === next.id ? next : item));
}
function jump(share: FileShare) {
if (share.chat_id === null || share.message_id === null) {
if (share.peer_id !== null) {
goto(`/app/${share.peer_id}`);
ui.openPanel("stories");
}
return;
}
goto(`/app/${share.chat_id}`);
ui.requestJump(share.chat_id, share.message_id);
ui.closePanel();
}
async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share));
toasts.success("Ссылка скопирована");
}
async function revoke(share: FileShare) {
try {
replace(await revokeShare(share.id));
toasts.success("Доступ отозван");
} catch {
toasts.error("Не удалось отозвать доступ");
}
}
async function reissue(share: FileShare) {
try {
replace(await reissueShare(share.id));
toasts.success("Выдана новая ссылка");
} catch {
toasts.error("Не удалось перевыпустить ссылку");
}
}
async function forget(share: FileShare) {
try {
await deleteShare(share.id);
items = items.filter((item) => item.id !== share.id);
} catch {
toasts.error("Не удалось удалить запись");
}
}
let loadedKey = "";
$effect(() => {
const key = `${accounts.selectedId}:${shareUi.revision}`;
if (accounts.selectedId !== null && key !== loadedKey) {
loadedKey = key;
load();
}
});
</script>
<div class="toolbar">
<div class="tabs">
<button
type="button"
class="tab"
class:selected={filter === "active"}
onclick={() => {
filter = "active";
}}
>
Открытые{activeCount ? ` · ${activeCount}` : ""}
</button>
<button
type="button"
class="tab"
class:selected={filter === "all"}
onclick={() => {
filter = "all";
}}
>
Все
</button>
</div>
<button
type="button"
class="reload"
aria-label="Обновить"
onclick={() => load()}
>
<Icon name="reload" size="1.125rem" />
</button>
</div>
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if visible.length === 0}
<EmptyState
title="Ничего не открыто"
description="Откройте доступ к файлу из меню медиа"
/>
{:else}
<ul class="list">
{#each visible as share (share.id)}
{@const state = shareState(share)}
<li class="row" class:inactive={state !== "active"}>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="head"
onclick={() => {
expanded = expanded === share.id ? null : share.id;
}}
>
<span class="glyph" class:muted={state !== "active"}>
<Icon
name={state === "active" ? "allow-share" : "no-share"}
size="1.125rem"
/>
</span>
<span class="text">
<span class="name">{share.file_name}</span>
<span class="sub">
{stateLabels[state]}
· {formatDownloads(share)}
{#if share.file_size}
· {formatBytes(share.file_size)}
{/if}
</span>
<span class="sub">
{share.title ?? "Без чата"}
· {formatExpiry(share)}
</span>
</span>
<span class="chevron" class:open={expanded === share.id}>
<Icon name="down" size="1rem" />
</span>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}>
Копировать ссылку
</ContextMenuItem>
{#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
Перейти к сообщению
</ContextMenuItem>
{/if}
{#if state === "active"}
<ContextMenuItem
icon="link-broken"
onselect={() => revoke(share)}
>
Отозвать
</ContextMenuItem>
{:else}
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
Выдать новую ссылку
</ContextMenuItem>
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
Убрать из списка
</ContextMenuItem>
{/if}
{/snippet}
</ContextMenu>
{#if expanded === share.id}
<div class="details">
<ShareLinkField url={shareUrl(share)} />
{#if share.last_download_at}
<p class="last">
Последнее скачивание: {formatFull(share.last_download_at)}
</p>
{/if}
<div class="actions">
{#if share.message_id !== null || share.peer_id !== null}
<button type="button" class="pill" onclick={() => jump(share)}>
<Icon name="reply" size="1rem" />К сообщению
</button>
{/if}
{#if state === "active"}
<button
type="button"
class="pill danger"
onclick={() => revoke(share)}
>
<Icon name="link-broken" size="1rem" />Отозвать
</button>
{:else}
<button
type="button"
class="pill"
onclick={() => reissue(share)}
>
<Icon name="replace" size="1rem" />Новая ссылка
</button>
{/if}
</div>
</div>
{/if}
</li>
{/each}
</ul>
<p class="footnote">
{visible.length}
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
· превью-краулеры не списывают скачивания
</p>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--color-borders);
}
.tabs {
display: flex;
gap: 0.375rem;
}
.tab {
padding: 0.3125rem 0.75rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-background-secondary);
&.selected {
color: var(--color-white);
background-color: var(--color-primary);
}
}
.reload {
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.row {
border-radius: 0.75rem;
&.inactive .name {
color: var(--color-text-secondary);
text-decoration: line-through;
}
}
.head {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.5rem;
border: 0;
border-radius: 0.75rem;
font-family: inherit;
text-align: start;
cursor: pointer;
background: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-green);
&.muted {
color: var(--color-text-secondary);
background-color: var(--color-background-secondary);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
overflow: hidden;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: flex;
flex-shrink: 0;
color: var(--color-text-secondary);
transition: transform 0.15s;
&.open {
transform: rotate(180deg);
}
}
.details {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 0.5rem 0.75rem;
}
.last {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.6875rem;
border: 1px solid var(--color-borders);
border-radius: 1rem;
font-family: inherit;
font-size: 0.8125rem;
color: var(--color-text);
cursor: pointer;
background: transparent;
&.danger {
color: var(--color-error);
}
}
.footnote {
margin: 0;
padding: 0 1rem 1rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
</style>
@@ -8,6 +8,7 @@
} from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
@@ -211,34 +212,11 @@
{#if expanded[group.peerId]}
<div class="grid">
{#each group.stories as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(group, index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
<StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(group, index)}
/>
{/each}
</div>
{/if}
@@ -4,6 +4,7 @@
import { enqueueStoriesBackfill, getStories } from "$lib/api/endpoints";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import StoryTile from "$lib/components/stories/StoryTile.svelte";
import StoryViewer from "$lib/components/stories/StoryViewer.svelte";
import Button from "$lib/components/ui/Button.svelte";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
@@ -161,39 +162,11 @@
{:else}
<div class="grid">
{#each items as item, index (item.story_id)}
<button
type="button"
class="tile"
class:expired={item.deleted}
onclick={() => openViewer(index)}
>
{#if previews[item.story_id]}
{#if item.media_kind === "video"}
<video
src={previews[item.story_id]}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={previews[item.story_id]} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if item.pinned}
<span class="badge pin"
><Icon name="story-priority" size="0.875rem" /></span
>
{/if}
{#if item.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{item.views}
</span>
{/if}
</button>
<StoryTile
story={item}
preview={previews[item.story_id]}
onopen={() => openViewer(index)}
/>
{/each}
</div>
@@ -0,0 +1,171 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { downloadStory } from "$lib/api/download";
import type { StoryView } from "$lib/api/types";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { formatFull } from "$lib/format/datetime";
import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
onopen: () => void;
preview: string | null | undefined;
story: StoryView;
}
const { story, preview, onopen }: Props = $props();
const label = $derived(
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
);
async function save() {
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
}
}
function share() {
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
label
);
}
function openAuthor() {
goto(`/app/${story.peer_id}`);
ui.openPanel("profile");
}
</script>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="tile"
class:expired={story.deleted}
onclick={onopen}
>
{#if preview}
{#if story.media_kind === "video"}
<video
src={preview}
muted
playsinline
preload="metadata"
use:poster
></video>
<span class="play"><Icon name="play" size="1.5rem" /></span>
{:else}
<img src={preview} alt="">
{/if}
{:else}
<span class="ph"><Icon name="play-story" /></span>
{/if}
{#if story.pinned}
<span class="badge pin">
<Icon name="story-priority" size="0.875rem" />
</span>
{/if}
{#if story.views}
<span class="badge views">
<Icon name="eye" size="0.875rem" />{story.views}
</span>
{/if}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
Открыть
</ContextMenuItem>
{#if story.downloaded}
<ContextMenuItem icon="download" onselect={save}>
Скачать
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/if}
<ContextMenuItem icon="info" onselect={openAuthor}>
Профиль автора
</ContextMenuItem>
{/snippet}
</ContextMenu>
<style lang="scss">
.tile {
position: relative;
aspect-ratio: 9 / 16;
padding: 0;
border: 0;
cursor: pointer;
background-color: var(--color-background-secondary);
&.expired {
opacity: 0.55;
}
}
.tile img,
.tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
}
.ph {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--color-text-secondary);
}
.badge {
position: absolute;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
color: var(--color-white);
background-color: rgb(0 0 0 / 45%);
}
.pin {
top: 0.25rem;
left: 0.25rem;
}
.views {
bottom: 0.25rem;
left: 0.25rem;
}
</style>
@@ -1,11 +1,16 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { downloadStory } from "$lib/api/download";
import { loadStoryMedia } from "$lib/api/stories";
import type { StoryView } from "$lib/api/types";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
index: number;
@@ -22,16 +27,23 @@
}: Props = $props();
const PHOTO_SECONDS = 6;
const HOLD_MS = 180;
let url = $state<string | null>(null);
let loading = $state(false);
let ready = $state(false);
let videoProgress = $state(0);
let muted = $state(true);
let held = $state(false);
let saving = $state(false);
let video = $state<HTMLVideoElement | null>(null);
let token = 0;
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let suppressTap = false;
const story = $derived(items[index] ?? null);
const isVideo = $derived(story?.media_kind === "video");
const canSave = $derived(Boolean(story?.downloaded));
function step(delta: number) {
const next = index + delta;
@@ -49,6 +61,37 @@
step(1);
}
function startHold() {
suppressTap = false;
if (holdTimer) {
clearTimeout(holdTimer);
}
holdTimer = setTimeout(() => {
held = true;
video?.pause();
}, HOLD_MS);
}
function releaseHold() {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
if (held) {
suppressTap = true;
held = false;
video?.play().catch(() => undefined);
}
}
function onTap(delta: number) {
if (suppressTap) {
suppressTap = false;
return;
}
step(delta);
}
async function load(item: StoryView) {
loading = true;
ready = false;
@@ -63,12 +106,36 @@
}
function onVideoTime(event: Event) {
const video = event.currentTarget as HTMLVideoElement;
if (video.duration > 0) {
videoProgress = video.currentTime / video.duration;
const element = event.currentTarget as HTMLVideoElement;
if (element.duration > 0) {
videoProgress = element.currentTime / element.duration;
}
}
async function save() {
if (!story || saving) {
return;
}
saving = true;
try {
await downloadStory(story.peer_id, story.story_id);
} catch {
toasts.error("Не удалось скачать сторис");
} finally {
saving = false;
}
}
function share() {
if (!story) {
return;
}
shareUi.share(
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
);
}
function onkeydown(event: KeyboardEvent) {
if (!open) {
return;
@@ -77,6 +144,14 @@
step(-1);
} else if (event.key === "ArrowRight") {
step(1);
} else if (event.key === " " && !event.repeat) {
event.preventDefault();
held = !held;
if (held) {
video?.pause();
} else {
video?.play().catch(() => undefined);
}
}
}
@@ -97,17 +172,18 @@
untrack(() => {
url = null;
ready = false;
held = false;
});
}
});
</script>
<svelte:window {onkeydown} />
<svelte:window {onkeydown} onpointerup={releaseHold} />
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="story-overlay" />
<Dialog.Content class="story-content">
<Dialog.Content class="story-content {held ? 'held' : ''}">
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
<div class="bars">
{#each items as item, i (item.story_id)}
@@ -119,6 +195,7 @@
{#if ready && !isVideo}
<div
class="fill anim"
class:paused={held}
style="animation-duration: {PHOTO_SECONDS}s"
onanimationend={advance}
></div>
@@ -146,6 +223,29 @@
<Icon name={muted ? "speaker-muted-story" : "speaker-story"} />
</button>
{/if}
{#if canSave}
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="round"
aria-label="Скачать сторис"
onclick={save}
>
<Icon name={saving ? "timer" : "download"} />
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="download" onselect={save}>
Скачать
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
<Dialog.Close class="round" aria-label="Закрыть">
<Icon name="close" size="1.5rem" />
</Dialog.Close>
@@ -158,6 +258,7 @@
{:else if url && isVideo}
<!-- biome-ignore lint/a11y/useMediaCaption: archived story has no captions -->
<video
bind:this={video}
class="media"
src={url}
autoplay
@@ -200,14 +301,22 @@
type="button"
class="tap prev"
aria-label="Назад"
onclick={() => step(-1)}
onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(-1)}
></button>
<button
type="button"
class="tap next"
aria-label="Вперёд"
onclick={() => step(1)}
onpointerdown={startHold}
onpointercancel={releaseHold}
onclick={() => onTap(1)}
></button>
{#if held}
<span class="hold-hint">Пауза</span>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -268,6 +377,10 @@
&.anim {
animation: story-progress linear forwards;
}
&.paused {
animation-play-state: paused;
}
}
@keyframes story-progress {
@@ -376,6 +489,9 @@
cursor: pointer;
background: transparent;
touch-action: none;
user-select: none;
-webkit-touch-callout: none;
&.prev {
left: 0;
@@ -386,4 +502,44 @@
width: 65%;
}
}
.hold-hint {
position: absolute;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
color: var(--color-white);
letter-spacing: 0.04em;
background-color: rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px);
animation: hold-in 0.18s ease;
pointer-events: none;
z-index: 3;
}
@keyframes hold-in {
from {
opacity: 0;
transform: translate(-50%, 0.375rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
:global(.story-content.held) .bars,
:global(.story-content.held) .story-head,
:global(.story-content.held) .caption,
:global(.story-content.held) .view-count {
opacity: 0.25;
transition: opacity 0.2s;
}
</style>