fix(api,frontend): render files by name without fetching, previewable share links
This commit is contained in:
@@ -6,7 +6,12 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query, Request
|
||||
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.storage import ContentAddressedStorage
|
||||
|
||||
@@ -35,13 +40,13 @@ def _exhausted(row: asyncpg.Record) -> bool:
|
||||
return limit is not None and row["download_count"] >= limit
|
||||
|
||||
|
||||
@router.api_route("/f/{token}", methods=["GET", "HEAD"], response_model=None)
|
||||
async def serve_shared_file(
|
||||
async def _serve(
|
||||
request: Request,
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
storage: FromDishka[ContentAddressedStorage],
|
||||
pool: asyncpg.Pool,
|
||||
storage: ContentAddressedStorage,
|
||||
token: str,
|
||||
dl: Annotated[bool, Query()] = False,
|
||||
*,
|
||||
dl: bool,
|
||||
) -> FileResponse | PlainTextResponse:
|
||||
row = await shares.share_by_token(pool, token)
|
||||
if row is None:
|
||||
@@ -64,8 +69,8 @@ async def serve_shared_file(
|
||||
return PlainTextResponse(_GONE, status_code=410, headers=_NO_STORE)
|
||||
|
||||
await shares.record_hit(pool, row["id"], method, ip, agent, counted=counts)
|
||||
mime = row["mime"] or "application/octet-stream"
|
||||
attachment = dl or not is_inline_mime(row["mime"])
|
||||
mime = resolve_mime(row["kind"], row["mime"], row["file_name"])
|
||||
attachment = dl or not is_inline_mime(mime)
|
||||
return FileResponse(
|
||||
storage.url(row["storage_key"]),
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
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 (
|
||||
get_media,
|
||||
get_media_version,
|
||||
@@ -63,7 +63,7 @@ async def serve_media_version(
|
||||
)
|
||||
return FileResponse(
|
||||
storage.url(version.storage_key),
|
||||
media_type=version.mime or "application/octet-stream",
|
||||
media_type=resolve_mime(version.kind, version.mime),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@@ -101,6 +101,6 @@ async def serve_media(
|
||||
headers = _attachment(headers, media.file_name or f"media_{media_id}")
|
||||
return FileResponse(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,12 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
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.media import get_media, get_media_version
|
||||
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")
|
||||
if not media.downloaded or media.storage_key is None:
|
||||
raise HTTPException(status_code=409, detail=_NOT_STORED)
|
||||
file_name = media.file_name or f"media_{media.id}"
|
||||
return Subject(
|
||||
storage_key=media.storage_key,
|
||||
file_name=media.file_name or f"media_{media.id}",
|
||||
mime=media.mime,
|
||||
file_name=file_name,
|
||||
mime=resolve_mime(media.kind, media.mime, file_name),
|
||||
file_size=media.file_size,
|
||||
title=await _chat_title(pool, media.account_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)
|
||||
if version is None:
|
||||
raise HTTPException(status_code=404, detail="media version not found")
|
||||
version_name = media_file_name(version.kind, version.mime, version_id)
|
||||
return Subject(
|
||||
storage_key=version.storage_key,
|
||||
file_name=media_file_name(version.kind, version.mime, version_id),
|
||||
mime=version.mime,
|
||||
file_name=version_name,
|
||||
mime=resolve_mime(version.kind, version.mime, version_name),
|
||||
file_size=version.file_size,
|
||||
title=None,
|
||||
chat_id=None,
|
||||
|
||||
@@ -29,6 +29,42 @@ _KIND_EXTENSIONS = {
|
||||
"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_MIMES = {"application/pdf", "application/json"}
|
||||
|
||||
@@ -57,6 +93,45 @@ _PREVIEW_AGENTS = (
|
||||
"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]+')
|
||||
_SPACES = re.compile(r"\s+")
|
||||
_NAME_LIMIT = 120
|
||||
@@ -86,6 +161,16 @@ def sanitize_name(name: str) -> str:
|
||||
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(
|
||||
kind: str | None, mime: str | None, message_id: int, original: str | None = None
|
||||
) -> 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)}"
|
||||
|
||||
|
||||
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:
|
||||
if not mime:
|
||||
return False
|
||||
|
||||
@@ -401,6 +401,7 @@ class FileShareView(BaseModel):
|
||||
token: str
|
||||
kind: str
|
||||
file_name: str
|
||||
url_name: str
|
||||
mime: str | None
|
||||
file_size: int | None
|
||||
title: str | None
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.files import url_slug
|
||||
from utils.read.models import FileShareHitView, FileShareView, Page
|
||||
|
||||
_COLS = (
|
||||
@@ -11,10 +12,11 @@ _COLS = (
|
||||
"download_count, last_download_at, revoked_at, created_at"
|
||||
)
|
||||
|
||||
_TOKEN_BYTES = 12
|
||||
_TOKEN_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789" # noqa: S105
|
||||
_TOKEN_LENGTH = 10
|
||||
|
||||
_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
|
||||
FROM file_shares WHERE token = $1
|
||||
"""
|
||||
@@ -45,11 +47,12 @@ RETURNING download_count
|
||||
|
||||
|
||||
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:
|
||||
return FileShareView(**dict(row))
|
||||
fields = dict(row)
|
||||
return FileShareView(**fields, url_name=url_slug(fields["file_name"]))
|
||||
|
||||
|
||||
async def list_shares(
|
||||
|
||||
@@ -21,9 +21,11 @@ export type InlineMedia =
|
||||
export interface ViewerItem {
|
||||
downloaded: boolean;
|
||||
fileName: string | null;
|
||||
fileSize: number | null;
|
||||
kind: string;
|
||||
mediaId: number | null;
|
||||
messageId: number;
|
||||
mime: string | null;
|
||||
}
|
||||
|
||||
export function viewerItemsFrom(
|
||||
@@ -32,7 +34,15 @@ export function viewerItemsFrom(
|
||||
): ViewerItem[] {
|
||||
if (media.length === 0) {
|
||||
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) => ({
|
||||
@@ -41,9 +51,31 @@ export function viewerItemsFrom(
|
||||
kind: item.kind,
|
||||
downloaded: item.downloaded,
|
||||
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";
|
||||
|
||||
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
|
||||
|
||||
@@ -97,7 +97,7 @@ export function shareUrl(share: FileShare): string {
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: window.location.origin.replace(TRAILING_SLASH, "");
|
||||
return `${origin}/f/${share.token}`;
|
||||
return `${origin}/f/${share.token}/${share.url_name}`;
|
||||
}
|
||||
|
||||
export function shareState(
|
||||
|
||||
@@ -528,6 +528,7 @@ export interface FileShare {
|
||||
story_id: number | null;
|
||||
title: string | null;
|
||||
token: string;
|
||||
url_name: string;
|
||||
}
|
||||
|
||||
export interface FileShareHit {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
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 { isPreviewable, 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 { formatBytes, 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";
|
||||
@@ -33,6 +33,8 @@
|
||||
let messageId = $state<number | null>(null);
|
||||
let currentMediaId = $state<number | 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 loading = $state(false);
|
||||
let saving = $state(false);
|
||||
@@ -48,12 +50,12 @@
|
||||
);
|
||||
const hasNav = $derived(items.length > 1);
|
||||
const canSave = $derived(
|
||||
currentMediaId !== null && result?.state === "ready"
|
||||
currentMediaId !== null && (fileOnly || result?.state === "ready")
|
||||
);
|
||||
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
|
||||
|
||||
function revoke() {
|
||||
if (result?.state === "ready") {
|
||||
if (result?.state === "ready" && result.url) {
|
||||
URL.revokeObjectURL(result.url);
|
||||
}
|
||||
}
|
||||
@@ -66,25 +68,39 @@
|
||||
messageId = item.messageId;
|
||||
currentMediaId = null;
|
||||
fileName = item.fileName;
|
||||
fileSize = item.fileSize;
|
||||
fileOnly = false;
|
||||
const current = ++token;
|
||||
try {
|
||||
let mediaId = item.mediaId;
|
||||
let downloaded = item.downloaded;
|
||||
let name = item.fileName;
|
||||
let size = item.fileSize;
|
||||
let mimeType = item.mime;
|
||||
if (mediaId === null) {
|
||||
const meta = await getMessageMedia(chatId, item.messageId);
|
||||
mediaId = meta.id;
|
||||
downloaded = meta.downloaded;
|
||||
kind = meta.kind;
|
||||
name = meta.file_name;
|
||||
size = meta.file_size;
|
||||
mimeType = meta.mime;
|
||||
}
|
||||
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) {
|
||||
result = next;
|
||||
currentMediaId = mediaId;
|
||||
fileName = name;
|
||||
fileSize = size;
|
||||
fileOnly = plainFile;
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
@@ -233,10 +249,17 @@
|
||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||
<audio src={result.url} controls></audio>
|
||||
{:else if result?.state === "ready"}
|
||||
<button class="media-download" type="button" onclick={save}>
|
||||
<Icon name="download" />
|
||||
Скачать файл
|
||||
</button>
|
||||
<div class="media-file">
|
||||
<span class="file-glyph"><Icon name="document" size="2rem" /></span>
|
||||
<p class="file-name">{title}</p>
|
||||
{#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"}
|
||||
<div class="media-message">
|
||||
<p>Файл ещё не скачан в архив.</p>
|
||||
@@ -354,6 +377,42 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { fetchMedia } from "$lib/api/endpoints";
|
||||
import {
|
||||
type InlineMedia,
|
||||
isPreviewable,
|
||||
loadInlineMedia,
|
||||
visualKind,
|
||||
} from "$lib/api/media";
|
||||
@@ -16,7 +17,7 @@
|
||||
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 { formatBytes, 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";
|
||||
@@ -38,8 +39,13 @@
|
||||
let queuing = $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 kind = $derived(ready?.kind ?? "");
|
||||
const kind = $derived(ready?.kind ?? ref?.kind ?? "");
|
||||
const mime = $derived(ready?.mime ?? "");
|
||||
const isImage = $derived(kind === "photo");
|
||||
const isStaticSticker = $derived(
|
||||
@@ -60,7 +66,10 @@
|
||||
const label = $derived(
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -69,6 +78,10 @@
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (asFile) {
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
media = await loadInlineMedia(message.chat_id, message.message_id);
|
||||
loaded = true;
|
||||
}
|
||||
@@ -116,10 +129,7 @@
|
||||
|
||||
function share() {
|
||||
if (storedId !== null) {
|
||||
shareUi.share(
|
||||
{ kind: "media", mediaId: storedId },
|
||||
mediaKindLabel(kind) ?? "Файл"
|
||||
);
|
||||
shareUi.share({ kind: "media", mediaId: storedId }, fileName);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -132,6 +142,18 @@
|
||||
<Icon name="timer" size="1.25rem" />
|
||||
<span>Самоуничтожающееся медиа</span>
|
||||
</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}
|
||||
<div class="media-skeleton"><Spinner /></div>
|
||||
{:else if ready && kind === "voice"}
|
||||
@@ -145,7 +167,7 @@
|
||||
{:else if ready && kind === "video_note"}
|
||||
<VideoNote url={ready.url} transcript={ready.transcript} />
|
||||
{: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}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<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 {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
kind: item.kind,
|
||||
downloaded: item.downloaded,
|
||||
fileName: item.file_name,
|
||||
fileSize: item.file_size,
|
||||
mime: item.mime,
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
@@ -212,10 +212,9 @@
|
||||
{/if}
|
||||
|
||||
<p class="hint raw">
|
||||
Ссылка отдаёт файл как есть —
|
||||
<code>curl -O {shareUrl(share)}</code>
|
||||
скачает его без авторизации. Просмотры превью в Telegram не
|
||||
списывают скачивания.
|
||||
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
|
||||
браузере, <code>curl -O {shareUrl(share)}</code> скачает под
|
||||
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
|
||||
</p>
|
||||
|
||||
<fieldset class="choices">
|
||||
|
||||
Reference in New Issue
Block a user