fix(api,frontend): render files by name without fetching, previewable share links

This commit is contained in:
hh
2026-08-13 03:49:29 +02:00
parent ef739838a5
commit e833de245a
13 changed files with 348 additions and 45 deletions
+36 -8
View File
@@ -6,7 +6,12 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, Query, Request
from fastapi.responses import FileResponse, PlainTextResponse from fastapi.responses import FileResponse, PlainTextResponse
from utils.files import content_disposition, counts_as_download, is_inline_mime from utils.files import (
content_disposition,
counts_as_download,
is_inline_mime,
resolve_mime,
)
from utils.read import shares from utils.read import shares
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -35,13 +40,13 @@ def _exhausted(row: asyncpg.Record) -> bool:
return limit is not None and row["download_count"] >= limit return limit is not None and row["download_count"] >= limit
@router.api_route("/f/{token}", methods=["GET", "HEAD"], response_model=None) async def _serve(
async def serve_shared_file(
request: Request, request: Request,
pool: FromDishka[asyncpg.Pool], pool: asyncpg.Pool,
storage: FromDishka[ContentAddressedStorage], storage: ContentAddressedStorage,
token: str, token: str,
dl: Annotated[bool, Query()] = False, *,
dl: bool,
) -> FileResponse | PlainTextResponse: ) -> FileResponse | PlainTextResponse:
row = await shares.share_by_token(pool, token) row = await shares.share_by_token(pool, token)
if row is None: if row is None:
@@ -64,8 +69,8 @@ async def serve_shared_file(
return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE) return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE)
await shares.record_hit(pool, row["id"], method, ip, agent, counted=counts) await shares.record_hit(pool, row["id"], method, ip, agent, counted=counts)
mime = row["mime"] or "application/octet-stream" mime = resolve_mime(row["kind"], row["mime"], row["file_name"])
attachment = dl or not is_inline_mime(row["mime"]) attachment = dl or not is_inline_mime(mime)
return FileResponse( return FileResponse(
storage.url(row["storage_key"]), storage.url(row["storage_key"]),
media_type=mime, media_type=mime,
@@ -76,3 +81,26 @@ async def serve_shared_file(
), ),
}, },
) )
@router.api_route("/f/{token}", methods=["GET", "HEAD"], response_model=None)
async def serve_shared_file(
request: Request,
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
token: str,
dl: Annotated[bool, Query()] = False,
) -> FileResponse | PlainTextResponse:
return await _serve(request, pool, storage, token, dl=dl)
@router.api_route("/f/{token}/{name}", methods=["GET", "HEAD"], response_model=None)
async def serve_shared_file_named(
request: Request,
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
token: str,
name: str, # noqa: ARG001
dl: Annotated[bool, Query()] = False,
) -> FileResponse | PlainTextResponse:
return await _serve(request, pool, storage, token, dl=dl)
+3 -3
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
from utils.files import content_disposition, media_file_name from utils.files import content_disposition, media_file_name, resolve_mime
from utils.read.media import ( from utils.read.media import (
get_media, get_media,
get_media_version, get_media_version,
@@ -63,7 +63,7 @@ async def serve_media_version(
) )
return FileResponse( return FileResponse(
storage.url(version.storage_key), storage.url(version.storage_key),
media_type=version.mime or "application/octet-stream", media_type=resolve_mime(version.kind, version.mime),
headers=headers, headers=headers,
) )
@@ -101,6 +101,6 @@ async def serve_media(
headers = _attachment(headers, media.file_name or f"media_{media_id}") headers = _attachment(headers, media.file_name or f"media_{media_id}")
return FileResponse( return FileResponse(
storage.url(media.storage_key), storage.url(media.storage_key),
media_type=media.mime or "application/octet-stream", media_type=resolve_mime(media.kind, media.mime, media.file_name),
headers=headers, headers=headers,
) )
+12 -5
View File
@@ -5,7 +5,12 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.files import expiry_from_seconds, media_file_name, story_file_name from utils.files import (
expiry_from_seconds,
media_file_name,
resolve_mime,
story_file_name,
)
from utils.read import chats, peers, shares from utils.read import chats, peers, shares
from utils.read.media import get_media, get_media_version from utils.read.media import get_media, get_media_version
from utils.read.models import DEFAULT_LIMIT, FileShareHitView, FileShareView, Page from utils.read.models import DEFAULT_LIMIT, FileShareHitView, FileShareView, Page
@@ -56,10 +61,11 @@ async def _media_subject(pool: asyncpg.Pool, media_id: int) -> Subject:
raise HTTPException(status_code=404, detail="media not found") raise HTTPException(status_code=404, detail="media not found")
if not media.downloaded or media.storage_key is None: if not media.downloaded or media.storage_key is None:
raise HTTPException(status_code=409, detail=_NOT_STORED) raise HTTPException(status_code=409, detail=_NOT_STORED)
file_name = media.file_name or f"media_{media.id}"
return Subject( return Subject(
storage_key=media.storage_key, storage_key=media.storage_key,
file_name=media.file_name or f"media_{media.id}", file_name=file_name,
mime=media.mime, mime=resolve_mime(media.kind, media.mime, file_name),
file_size=media.file_size, file_size=media.file_size,
title=await _chat_title(pool, media.account_id, media.chat_id), title=await _chat_title(pool, media.account_id, media.chat_id),
chat_id=media.chat_id, chat_id=media.chat_id,
@@ -71,10 +77,11 @@ async def _version_subject(pool: asyncpg.Pool, version_id: int) -> Subject:
version = await get_media_version(pool, version_id) version = await get_media_version(pool, version_id)
if version is None: if version is None:
raise HTTPException(status_code=404, detail="media version not found") raise HTTPException(status_code=404, detail="media version not found")
version_name = media_file_name(version.kind, version.mime, version_id)
return Subject( return Subject(
storage_key=version.storage_key, storage_key=version.storage_key,
file_name=media_file_name(version.kind, version.mime, version_id), file_name=version_name,
mime=version.mime, mime=resolve_mime(version.kind, version.mime, version_name),
file_size=version.file_size, file_size=version.file_size,
title=None, title=None,
chat_id=None, chat_id=None,
+97
View File
@@ -29,6 +29,42 @@ _KIND_EXTENSIONS = {
"voice": ".ogg", "voice": ".ogg",
} }
_EXTENSION_MIMES = {
".flac": "audio/flac",
".gif": "image/gif",
".heic": "image/heic",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".m4a": "audio/mp4",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
}
_KIND_MIMES = {
"animation": "video/mp4",
"gif": "video/mp4",
"photo": "image/jpeg",
"video": "video/mp4",
"video_note": "video/mp4",
"voice": "audio/ogg",
}
_GENERIC_MIMES = {
"application/octet-stream",
"application/binary",
"binary/octet-stream",
"",
}
_INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/") _INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
_INLINE_MIMES = {"application/pdf", "application/json"} _INLINE_MIMES = {"application/pdf", "application/json"}
@@ -57,6 +93,45 @@ _PREVIEW_AGENTS = (
"python-requests", "python-requests",
) )
_TRANSLIT = {
"а": "a",
"б": "b",
"в": "v",
"г": "g",
"д": "d",
"е": "e",
"ё": "e",
"ж": "zh",
"з": "z",
"и": "i",
"й": "y",
"к": "k",
"л": "l",
"м": "m",
"н": "n",
"о": "o",
"п": "p",
"р": "r",
"с": "s",
"т": "t",
"у": "u",
"ф": "f",
"х": "h",
"ц": "c",
"ч": "ch",
"ш": "sh",
"щ": "sch",
"ъ": "",
"ы": "y",
"ь": "",
"э": "e",
"ю": "yu",
"я": "ya",
}
_URL_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
_URL_REPEATS = re.compile(r"_{2,}")
_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') _UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
_SPACES = re.compile(r"\s+") _SPACES = re.compile(r"\s+")
_NAME_LIMIT = 120 _NAME_LIMIT = 120
@@ -86,6 +161,16 @@ def sanitize_name(name: str) -> str:
return cleaned or "file" return cleaned or "file"
def url_slug(file_name: str) -> str:
lowered = "".join(
_TRANSLIT.get(ch, _TRANSLIT.get(ch.lower(), ch)) for ch in file_name
)
slug = _URL_REPEATS.sub("_", _URL_UNSAFE.sub("_", lowered)).strip("_.")
if not slug:
return "file"
return slug[:_NAME_LIMIT]
def media_file_name( def media_file_name(
kind: str | None, mime: str | None, message_id: int, original: str | None = None kind: str | None, mime: str | None, message_id: int, original: str | None = None
) -> str: ) -> str:
@@ -99,6 +184,18 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}" return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None
) -> str:
if mime and mime.lower() not in _GENERIC_MIMES:
return mime
if file_name and "." in file_name:
by_extension = _EXTENSION_MIMES.get(f".{file_name.rsplit('.', 1)[1].lower()}")
if by_extension:
return by_extension
return _KIND_MIMES.get(kind or "", mime or "application/octet-stream")
def is_inline_mime(mime: str | None) -> bool: def is_inline_mime(mime: str | None) -> bool:
if not mime: if not mime:
return False return False
+1
View File
@@ -401,6 +401,7 @@ class FileShareView(BaseModel):
token: str token: str
kind: str kind: str
file_name: str file_name: str
url_name: str
mime: str | None mime: str | None
file_size: int | None file_size: int | None
title: str | None title: str | None
+7 -4
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import asyncpg import asyncpg
from utils.files import url_slug
from utils.read.models import FileShareHitView, FileShareView, Page from utils.read.models import FileShareHitView, FileShareView, Page
_COLS = ( _COLS = (
@@ -11,10 +12,11 @@ _COLS = (
"download_count, last_download_at, revoked_at, created_at" "download_count, last_download_at, revoked_at, created_at"
) )
_TOKEN_BYTES = 12 _TOKEN_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789" # noqa: S105
_TOKEN_LENGTH = 10
_SERVE = """ _SERVE = """
SELECT id, storage_key, file_name, mime, expires_at, max_downloads, SELECT id, kind, storage_key, file_name, mime, expires_at, max_downloads,
download_count, revoked_at download_count, revoked_at
FROM file_shares WHERE token = $1 FROM file_shares WHERE token = $1
""" """
@@ -45,11 +47,12 @@ RETURNING download_count
def new_token() -> str: def new_token() -> str:
return secrets.token_urlsafe(_TOKEN_BYTES) return "".join(secrets.choice(_TOKEN_ALPHABET) for _ in range(_TOKEN_LENGTH))
def _view(row: asyncpg.Record) -> FileShareView: def _view(row: asyncpg.Record) -> FileShareView:
return FileShareView(**dict(row)) fields = dict(row)
return FileShareView(**fields, url_name=url_slug(fields["file_name"]))
async def list_shares( async def list_shares(
+33 -1
View File
@@ -21,9 +21,11 @@ export type InlineMedia =
export interface ViewerItem { export interface ViewerItem {
downloaded: boolean; downloaded: boolean;
fileName: string | null; fileName: string | null;
fileSize: number | null;
kind: string; kind: string;
mediaId: number | null; mediaId: number | null;
messageId: number; messageId: number;
mime: string | null;
} }
export function viewerItemsFrom( export function viewerItemsFrom(
@@ -32,7 +34,15 @@ export function viewerItemsFrom(
): ViewerItem[] { ): ViewerItem[] {
if (media.length === 0) { if (media.length === 0) {
return [ return [
{ messageId, mediaId: null, kind: "", downloaded: false, fileName: null }, {
messageId,
mediaId: null,
kind: "",
downloaded: false,
fileName: null,
fileSize: null,
mime: null,
},
]; ];
} }
return media.map((item) => ({ return media.map((item) => ({
@@ -41,9 +51,31 @@ export function viewerItemsFrom(
kind: item.kind, kind: item.kind,
downloaded: item.downloaded, downloaded: item.downloaded,
fileName: item.file_name, fileName: item.file_name,
fileSize: item.file_size,
mime: item.mime,
})); }));
} }
const PREVIEW_KINDS = new Set([
"photo",
"video",
"video_note",
"animation",
"gif",
"sticker",
"voice",
"audio",
]);
const PREVIEW_MIME_PREFIXES = ["image/", "video/", "audio/"];
export function isPreviewable(kind: string, mime: string | null): boolean {
if (PREVIEW_KINDS.has(kind)) {
return true;
}
return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix));
}
export type VisualKind = "image" | "video" | "other"; export type VisualKind = "image" | "video" | "other";
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]); const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
+1 -1
View File
@@ -97,7 +97,7 @@ export function shareUrl(share: FileShare): string {
typeof window === "undefined" typeof window === "undefined"
? "" ? ""
: window.location.origin.replace(TRAILING_SLASH, ""); : window.location.origin.replace(TRAILING_SLASH, "");
return `${origin}/f/${share.token}`; return `${origin}/f/${share.token}/${share.url_name}`;
} }
export function shareState( export function shareState(
+1
View File
@@ -528,6 +528,7 @@ export interface FileShare {
story_id: number | null; story_id: number | null;
title: string | null; title: string | null;
token: string; token: string;
url_name: string;
} }
export interface FileShareHit { export interface FileShareHit {
+70 -11
View File
@@ -4,13 +4,13 @@
import { type MediaResult, requestMedia } from "$lib/api/client"; import { type MediaResult, requestMedia } from "$lib/api/client";
import { downloadMedia } from "$lib/api/download"; import { downloadMedia } from "$lib/api/download";
import { fetchMedia, getMessageMedia } from "$lib/api/endpoints"; import { fetchMedia, getMessageMedia } from "$lib/api/endpoints";
import type { ViewerItem } from "$lib/api/media"; import { isPreviewable, type ViewerItem } from "$lib/api/media";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.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 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 { mediaKindLabel } from "$lib/format/media"; import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster"; import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte"; import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -33,6 +33,8 @@
let messageId = $state<number | null>(null); let messageId = $state<number | null>(null);
let currentMediaId = $state<number | null>(null); let currentMediaId = $state<number | null>(null);
let fileName = $state<string | null>(null); let fileName = $state<string | null>(null);
let fileSize = $state<number | null>(null);
let fileOnly = $state(false);
let result = $state<MediaResult | null>(null); let result = $state<MediaResult | null>(null);
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
@@ -48,12 +50,12 @@
); );
const hasNav = $derived(items.length > 1); const hasNav = $derived(items.length > 1);
const canSave = $derived( const canSave = $derived(
currentMediaId !== null && result?.state === "ready" currentMediaId !== null && (fileOnly || result?.state === "ready")
); );
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
function revoke() { function revoke() {
if (result?.state === "ready") { if (result?.state === "ready" && result.url) {
URL.revokeObjectURL(result.url); URL.revokeObjectURL(result.url);
} }
} }
@@ -66,25 +68,39 @@
messageId = item.messageId; messageId = item.messageId;
currentMediaId = null; currentMediaId = null;
fileName = item.fileName; fileName = item.fileName;
fileSize = item.fileSize;
fileOnly = false;
const current = ++token; const current = ++token;
try { try {
let mediaId = item.mediaId; let mediaId = item.mediaId;
let downloaded = item.downloaded; let downloaded = item.downloaded;
let name = item.fileName; let name = item.fileName;
let size = item.fileSize;
let mimeType = item.mime;
if (mediaId === null) { if (mediaId === null) {
const meta = await getMessageMedia(chatId, item.messageId); const meta = await getMessageMedia(chatId, item.messageId);
mediaId = meta.id; mediaId = meta.id;
downloaded = meta.downloaded; downloaded = meta.downloaded;
kind = meta.kind; kind = meta.kind;
name = meta.file_name; name = meta.file_name;
size = meta.file_size;
mimeType = meta.mime;
}
const plainFile = downloaded && !isPreviewable(kind, mimeType);
let next: MediaResult;
if (plainFile) {
next = { state: "ready", url: "", mime: mimeType };
} else if (downloaded) {
next = await requestMedia(mediaId);
} else {
next = { state: "not-downloaded" } as MediaResult;
} }
const next = downloaded
? await requestMedia(mediaId)
: ({ state: "not-downloaded" } as MediaResult);
if (current === token) { if (current === token) {
result = next; result = next;
currentMediaId = mediaId; currentMediaId = mediaId;
fileName = name; fileName = name;
fileSize = size;
fileOnly = plainFile;
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -233,10 +249,17 @@
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions --> <!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
<audio src={result.url} controls></audio> <audio src={result.url} controls></audio>
{:else if result?.state === "ready"} {:else if result?.state === "ready"}
<button class="media-download" type="button" onclick={save}> <div class="media-file">
<Icon name="download" /> <span class="file-glyph"><Icon name="document" size="2rem" /></span>
Скачать файл <p class="file-name">{title}</p>
</button> {#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p>
{/if}
<button class="media-download" type="button" onclick={save}>
<Icon name="download" />
Скачать файл
</button>
</div>
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
<p>Файл ещё не скачан в архив.</p> <p>Файл ещё не скачан в архив.</p>
@@ -354,6 +377,42 @@
text-align: center; text-align: center;
} }
.media-file {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
max-width: min(24rem, 90vw);
color: var(--color-white);
text-align: center;
}
.file-glyph {
display: flex;
align-items: center;
justify-content: center;
width: 4.5rem;
height: 4.5rem;
margin-bottom: 0.5rem;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.12);
}
.file-name {
overflow-wrap: anywhere;
margin: 0;
font-size: 1rem;
}
.file-size {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.6);
}
.media-download { .media-download {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -4,6 +4,7 @@
import { fetchMedia } from "$lib/api/endpoints"; import { fetchMedia } from "$lib/api/endpoints";
import { import {
type InlineMedia, type InlineMedia,
isPreviewable,
loadInlineMedia, loadInlineMedia,
visualKind, visualKind,
} from "$lib/api/media"; } from "$lib/api/media";
@@ -16,7 +17,7 @@
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "$lib/components/ui/ContextMenuItem.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";
import { mediaKindLabel } from "$lib/format/media"; import { formatBytes, mediaKindLabel } from "$lib/format/media";
import { poster } from "$lib/media/poster"; import { poster } from "$lib/media/poster";
import { shareUi } from "$lib/stores/shares.svelte"; import { shareUi } from "$lib/stores/shares.svelte";
import { toasts } from "$lib/stores/toasts.svelte"; import { toasts } from "$lib/stores/toasts.svelte";
@@ -38,8 +39,13 @@
let queuing = $state(false); let queuing = $state(false);
let saving = $state(false); let saving = $state(false);
const ref = $derived(message.media[0] ?? null);
const asFile = $derived(
ref !== null && ref.id !== null && !isPreviewable(ref.kind, ref.mime)
);
const ready = $derived(media?.state === "ready" ? media : null); const ready = $derived(media?.state === "ready" ? media : null);
const kind = $derived(ready?.kind ?? ""); const kind = $derived(ready?.kind ?? ref?.kind ?? "");
const mime = $derived(ready?.mime ?? ""); const mime = $derived(ready?.mime ?? "");
const isImage = $derived(kind === "photo"); const isImage = $derived(kind === "photo");
const isStaticSticker = $derived( const isStaticSticker = $derived(
@@ -60,7 +66,10 @@
const label = $derived( const label = $derived(
media && media.state !== "missing" ? media.kind : "media" media && media.state !== "missing" ? media.kind : "media"
); );
const storedId = $derived(ready?.mediaId ?? null); const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
);
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл");
function delay(ms: number): Promise<void> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -69,6 +78,10 @@
} }
async function start() { async function start() {
if (asFile) {
loaded = true;
return;
}
media = await loadInlineMedia(message.chat_id, message.message_id); media = await loadInlineMedia(message.chat_id, message.message_id);
loaded = true; loaded = true;
} }
@@ -116,10 +129,7 @@
function share() { function share() {
if (storedId !== null) { if (storedId !== null) {
shareUi.share( shareUi.share({ kind: "media", mediaId: storedId }, fileName);
{ kind: "media", mediaId: storedId },
mediaKindLabel(kind) ?? "Файл"
);
} }
} }
</script> </script>
@@ -132,6 +142,18 @@
<Icon name="timer" size="1.25rem" /> <Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span> <span>Самоуничтожающееся медиа</span>
</button> </button>
{:else if asFile}
<button class="media-file" onclick={save} type="button">
<span class="file-glyph">
<Icon name={saving ? "timer" : "document"} size="1.25rem" />
</span>
<span class="file-text">
<span class="file-name">{fileName}</span>
<span class="file-sub">
{formatBytes(ref?.file_size ?? null) || mediaKindLabel(kind)}
</span>
</span>
</button>
{:else if !loaded} {:else if !loaded}
<div class="media-skeleton"><Spinner /></div> <div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"} {:else if ready && kind === "voice"}
@@ -145,7 +167,7 @@
{:else if ready && kind === "video_note"} {:else if ready && kind === "video_note"}
<VideoNote url={ready.url} transcript={ready.transcript} /> <VideoNote url={ready.url} transcript={ready.transcript} />
{:else if ready && kind === "audio"} {:else if ready && kind === "audio"}
<AudioFile url={ready.url} title={ready.mime ?? "Audio"} {own} /> <AudioFile url={ready.url} title={fileName} {own} />
{:else if ready && isImage} {:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<img src={ready.url} alt="attachment"> <img src={ready.url} alt="attachment">
@@ -348,6 +370,58 @@
} }
} }
.media-file {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
max-width: 18rem;
padding: 0.5rem 0.75rem 0.5rem 0.5rem;
border: 0;
border-radius: var(--border-radius-default-small);
text-align: start;
background-color: var(--color-primary-tint);
}
.file-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-primary);
}
.file-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.file-name {
overflow: hidden;
font-size: 0.9375rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.file-sub {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.media-chip { .media-chip {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -50,6 +50,8 @@
kind: item.kind, kind: item.kind,
downloaded: item.downloaded, downloaded: item.downloaded,
fileName: item.file_name, fileName: item.file_name,
fileSize: item.file_size,
mime: item.mime,
})) }))
); );
@@ -212,10 +212,9 @@
{/if} {/if}
<p class="hint raw"> <p class="hint raw">
Ссылка отдаёт файл как есть Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
<code>curl -O {shareUrl(share)}</code> браузере, <code>curl -O {shareUrl(share)}</code> скачает под
скачает его без авторизации. Просмотры превью в Telegram не настоящим именем. Превью-краулеры Telegram скачивания не списывают.
списывают скачивания.
</p> </p>
<fieldset class="choices"> <fieldset class="choices">