Files
beavergram/frontend/src/lib/components/MessageMedia.svelte
T

388 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { downloadMedia } from "$lib/api/download";
import { fetchMedia } from "$lib/api/endpoints";
import {
type InlineMedia,
isPreviewable,
loadInlineMedia,
visualKind,
} from "$lib/api/media";
import type { MessageView } from "$lib/api/types";
import AudioFile from "$lib/components/media/AudioFile.svelte";
import FileChip from "$lib/components/media/FileChip.svelte";
import TgsSticker from "$lib/components/media/TgsSticker.svelte";
import VideoNote from "$lib/components/media/VideoNote.svelte";
import VoiceMessage from "$lib/components/media/VoiceMessage.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 { 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";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
message: MessageView;
onopen: () => void;
own: boolean;
}
let { message, onopen, own }: Props = $props();
const POLL_TRIES = 5;
const POLL_DELAY = 3000;
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
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 ?? ref?.kind ?? "");
const mime = $derived(ready?.mime ?? "");
const isImage = $derived(kind === "photo");
const isStaticSticker = $derived(
kind === "sticker" && mime.startsWith("image/")
);
const isVideoSticker = $derived(
kind === "sticker" && mime.startsWith("video/")
);
const isTgsSticker = $derived(
kind === "sticker" && mime === "application/x-tgsticker"
);
const isAnimation = $derived(kind === "animation" || kind === "gif");
const isThumbVideo = $derived(kind === "video");
const vk = $derived(
media && media.state !== "missing" ? visualKind(media.kind) : "other"
);
const label = $derived(
media && media.state !== "missing" ? media.kind : "media"
);
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) => {
setTimeout(resolve, ms);
});
}
async function start() {
if (asFile) {
loaded = true;
return;
}
media = await loadInlineMedia(message.chat_id, message.message_id);
loaded = true;
}
async function poll() {
for (let i = 0; i < POLL_TRIES; i += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
await delay(POLL_DELAY);
const next = await loadInlineMedia(message.chat_id, message.message_id);
if (next.state === "ready") {
media = next;
return;
}
}
}
async function queue() {
if (queuing) {
return;
}
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь");
poll();
} catch {
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 }, fileName);
}
}
</script>
<ContextMenu>
{#snippet children({ props })}
<div {...props} class="message-media" use:visible={start}>
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span>
</button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
{:else if !loaded}
<div class="media-skeleton"><Spinner /></div>
{:else if ready && kind === "voice"}
<VoiceMessage
chatId={message.chat_id}
messageId={message.message_id}
{own}
transcript={ready.transcript}
url={ready.url}
/>
{:else if ready && kind === "video_note"}
<VideoNote transcript={ready.transcript} url={ready.url} />
{:else if ready && kind === "audio"}
<AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}>
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}>
</button>
{:else if ready && isVideoSticker}
<video
autoplay
class="media-sticker-video"
loop
muted
playsinline
src={ready.url}
></video>
{:else if ready && isTgsSticker}
<TgsSticker url={ready.url} />
{:else if ready && isAnimation}
<button class="media-thumb" onclick={onopen} type="button">
<video autoplay loop muted playsinline src={ready.url}></video>
<span class="gif-badge">GIF</span>
</button>
{:else if ready && isThumbVideo}
<button class="media-thumb" onclick={onopen} type="button">
<video
muted
playsinline
preload="metadata"
src={ready.url}
use:poster
></video>
<span class="play"><Icon name="large-play" size="2.5rem" /></span>
</button>
{:else if ready}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="document" size="1.25rem" />
<span>{label}</span>
</button>
{: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" ? "Видео" : "Фото"}</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 ? "В очереди" : `Скачать ${label}`}</span>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Медиа</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</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>
<style lang="scss">
.message-media {
margin-bottom: 0.25rem;
}
.media-thumb {
cursor: pointer;
position: relative;
display: block;
overflow: hidden;
max-width: 100%;
padding: 0;
border: 0;
border-radius: var(--border-radius-default-small);
background-color: var(--color-default-shadow);
img,
video {
display: block;
width: 100%;
max-width: 20rem;
max-height: 20rem;
object-fit: cover;
}
}
.media-sticker {
cursor: pointer;
display: block;
padding: 0;
border: 0;
background: transparent;
img {
display: block;
width: 12rem;
height: 12rem;
object-fit: contain;
}
}
.media-sticker-video {
display: block;
width: 12rem;
height: 12rem;
object-fit: contain;
}
.gif-badge {
position: absolute;
top: 0.375rem;
left: 0.375rem;
padding: 0.0625rem 0.3125rem;
border-radius: 0.5rem;
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
background-color: rgba(0, 0, 0, 0.45);
}
.play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
.media-skeleton {
display: flex;
align-items: center;
justify-content: center;
width: 12rem;
height: 9rem;
border-radius: var(--border-radius-default-small);
background-color: var(--color-default-shadow);
}
.media-placeholder {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
width: 12rem;
height: 9rem;
border: 0;
border-radius: var(--border-radius-default-small);
color: var(--color-primary);
text-align: center;
background-color: var(--color-primary-tint);
small {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
}
.media-chip {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--border-radius-default-small);
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background-color: var(--color-primary-tint);
&.self-destruct {
color: var(--color-orange);
background-color: var(--color-light-coral);
}
}
</style>