feat: 1-to-1 message render + web data-lake backend
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
<script lang="ts">
|
||||
import { visible } from "$lib/actions/visible";
|
||||
import { fetchMedia } from "$lib/api/endpoints";
|
||||
import {
|
||||
type InlineMedia,
|
||||
loadInlineMedia,
|
||||
visualKind,
|
||||
} from "$lib/api/media";
|
||||
import type { MessageView } from "$lib/api/types";
|
||||
import AudioFile from "$lib/components/media/AudioFile.svelte";
|
||||
import VideoNote from "$lib/components/media/VideoNote.svelte";
|
||||
import VoiceMessage from "$lib/components/media/VoiceMessage.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.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);
|
||||
|
||||
const ready = $derived(media?.state === "ready" ? media : null);
|
||||
const kind = $derived(ready?.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"
|
||||
);
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
media = await loadInlineMedia(message.chat_id, message.message_id);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
for (let i = 0; i < POLL_TRIES; i++) {
|
||||
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("Download queued");
|
||||
poll();
|
||||
} catch {
|
||||
toasts.error("Failed to queue download");
|
||||
} finally {
|
||||
queuing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div 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>Self-destruct media</span>
|
||||
</button>
|
||||
{:else if !loaded}
|
||||
<div class="media-skeleton"><Spinner /></div>
|
||||
{:else if ready && kind === "voice"}
|
||||
<VoiceMessage url={ready.url} transcript={ready.transcript} {own} />
|
||||
{: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} />
|
||||
{:else if ready && isImage}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="attachment">
|
||||
</button>
|
||||
{:else if ready && isStaticSticker}
|
||||
<button class="media-sticker" onclick={onopen} type="button">
|
||||
<img src={ready.url} alt="sticker">
|
||||
</button>
|
||||
{:else if ready && isVideoSticker}
|
||||
<video
|
||||
class="media-sticker-video"
|
||||
src={ready.url}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
{:else if ready && isTgsSticker}
|
||||
<div class="media-sticker tgs">
|
||||
<span class="tgs-emoji">{message.sticker?.emoji ?? "🎞"}</span>
|
||||
</div>
|
||||
{:else if ready && isAnimation}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<video src={ready.url} autoplay loop muted playsinline></video>
|
||||
<span class="gif-badge">GIF</span>
|
||||
</button>
|
||||
{:else if ready && isThumbVideo}
|
||||
<button class="media-thumb" onclick={onopen} type="button">
|
||||
<video src={ready.url} muted preload="metadata"></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" ? "Video" : "Photo"}</span>
|
||||
<small>{queuing ? "Queued" : "Tap to download"}</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>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="media-chip" onclick={onopen} type="button">
|
||||
<Icon name="photo" size="1.25rem" />
|
||||
<span>Media</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
.tgs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.tgs-emoji {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user