414 lines
11 KiB
Svelte
414 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { ContextMenu as Menu } from "bits-ui";
|
|
import type { Snippet } from "svelte";
|
|
import { visible } from "#lib/actions/visible.js";
|
|
import { downloadMedia } from "#lib/api/download.js";
|
|
import { fetchMedia } from "#lib/api/endpoints.js";
|
|
import {
|
|
type InlineMedia,
|
|
isPreviewable,
|
|
loadInlineMedia,
|
|
visualKind,
|
|
} from "#lib/api/media.js";
|
|
import type { MessageView } from "#lib/api/types.js";
|
|
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.js";
|
|
import { t } from "#lib/i18n/locale.svelte.js";
|
|
import { poster } from "#lib/media/poster.js";
|
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
|
|
interface Props {
|
|
items?: Snippet;
|
|
message: MessageView;
|
|
onopen: () => void;
|
|
onversions?: () => void;
|
|
own: boolean;
|
|
}
|
|
|
|
let { items, message, onopen, onversions, 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 queued = $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) ?? t("chat.media.file")
|
|
);
|
|
const waiting = $derived(queuing || queued);
|
|
|
|
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 (waiting) {
|
|
return;
|
|
}
|
|
queuing = true;
|
|
try {
|
|
await fetchMedia(message.chat_id, message.message_id);
|
|
queued = true;
|
|
poll();
|
|
} catch {
|
|
toasts.error(t("chat.media.queueFailed"));
|
|
} finally {
|
|
queuing = false;
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (storedId === null || saving) {
|
|
return;
|
|
}
|
|
saving = true;
|
|
try {
|
|
await downloadMedia(storedId);
|
|
} catch {
|
|
toasts.error(t("chat.media.downloadFailed"));
|
|
} 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>{t("chat.media.selfDestruct")}</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={t("chat.media.photo")} src={ready.url}>
|
|
</button>
|
|
{:else if ready && isStaticSticker}
|
|
<button class="media-sticker" onclick={onopen} type="button">
|
|
<img alt={fileName} 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={waiting ? "timer" : "download"} size="1.5rem" />
|
|
<span
|
|
>{vk === "video"
|
|
? t("chat.media.video")
|
|
: t("chat.media.photo")}</span
|
|
>
|
|
<small
|
|
>{waiting
|
|
? t("chat.media.queued")
|
|
: t("chat.media.tapToDownload")}</small
|
|
>
|
|
</button>
|
|
{:else if media?.state === "not-downloaded"}
|
|
<button class="media-chip" onclick={queue} type="button">
|
|
<Icon name={waiting ? "timer" : "download"} size="1.25rem" />
|
|
<span
|
|
>{waiting
|
|
? t("chat.media.queued")
|
|
: t("chat.media.download", { kind: label })}</span
|
|
>
|
|
</button>
|
|
{:else}
|
|
<button class="media-chip" onclick={onopen} type="button">
|
|
<Icon name="photo" size="1.25rem" />
|
|
<span>{t("chat.media")}</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
{#snippet menu()}
|
|
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
|
>{t("chat.media.fullscreen")}</ContextMenuItem
|
|
>
|
|
{#if onversions}
|
|
<ContextMenuItem icon="recent" onselect={onversions}
|
|
>{t("chat.media.versions")}</ContextMenuItem
|
|
>
|
|
{/if}
|
|
{#if storedId === null}
|
|
<ContextMenuItem icon="cloud-download" onselect={queue}
|
|
>{t("chat.media.queue")}</ContextMenuItem
|
|
>
|
|
{:else}
|
|
<ContextMenuItem icon="download" onselect={save}
|
|
>{t("chat.media.save")}</ContextMenuItem
|
|
>
|
|
<ContextMenuItem icon="allow-share" onselect={share}
|
|
>{t("chat.media.share")}</ContextMenuItem
|
|
>
|
|
{/if}
|
|
{#if items}
|
|
<Menu.Separator class="bg-menu-separator" />
|
|
{@render items()}
|
|
{/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>
|