feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh committed 2026-05-31 01:45:05 +02:00
1 parent f0afb7ec5b
commit 75425d1bee
110 files changed
+10199 -54

No files matched your search

@@ -0,0 +1,106 @@
<script lang="ts">
import { visible } from "$lib/actions/visible";
import { fetchMedia } from "$lib/api/endpoints";
import { type InlineMedia, loadMediaItem, visualKind } from "$lib/api/media";
import type { MediaRef } from "$lib/api/types";
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 {
chatId: number;
media: MediaRef;
onopen: () => void;
}
let { media, chatId, onopen }: Props = $props();
let loaded = $state(false);
let inline = $state<InlineMedia | null>(null);
let queuing = $state(false);
const ready = $derived(inline?.state === "ready" ? inline : null);
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
async function start() {
inline = await loadMediaItem(media);
loaded = true;
}
async function queue() {
if (queuing) {
return;
}
queuing = true;
try {
await fetchMedia(chatId, media.message_id);
toasts.success("Download queued");
} catch {
toasts.error("Failed to queue download");
} finally {
queuing = false;
}
}
</script>
<div class="AlbumTile" use:visible={start}>
{#if ready && isVideo}
<button class="tile" onclick={onopen} type="button">
<video src={ready.url} muted preload="metadata"></video>
<span class="play"><Icon name="large-play" size="2rem" /></span>
</button>
{:else if ready}
<button class="tile" onclick={onopen} type="button">
<img src={ready.url} alt="attachment">
</button>
{:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
</button>
{:else if loaded}
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
{:else}
<div class="tile placeholder"><Spinner /></div>
{/if}
</div>
<style lang="scss">
.AlbumTile {
aspect-ratio: 1;
min-width: 0;
}
.tile {
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background-color: var(--color-default-shadow);
img,
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
color: var(--color-primary);
background-color: var(--color-primary-tint);
}
.play {
position: absolute;
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
interface Props {
own: boolean;
title: string;
url: string;
}
let { url, title, own }: Props = $props();
let element = $state<HTMLAudioElement>();
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
function seek(event: PointerEvent) {
if (!(element && duration > 0)) {
return;
}
const rect = event.currentTarget as HTMLElement;
const ratio =
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
}
</script>
<div class="Audio" class:own>
<button class="toggle" onclick={toggle} type="button" aria-label="Play audio">
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
</button>
<div class="content">
<div class="title">{title}</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="seekline" onpointerdown={seek}>
<div class="track"></div>
<div class="fill" style:width={`${Math.round(progress * 100)}%`}></div>
</div>
<div class="meta">
{formatDuration(currentTime)}
/ {formatDuration(duration)}
</div>
</div>
<!-- biome-ignore lint/a11y/useMediaCaption: archived audio track has no captions -->
<audio
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
preload="metadata"
src={url}
></audio>
</div>
<style lang="scss">
.Audio {
--active: var(--color-primary);
--toggle-fg: var(--color-white);
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 15rem;
padding: 0.1875rem 0;
&.own {
--active: var(--color-white);
--toggle-fg: var(--color-background-own);
}
}
.toggle {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--toggle-fg);
background-color: var(--active);
}
.content {
flex: 1;
min-width: 0;
}
.title {
overflow: hidden;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.seekline {
cursor: pointer;
touch-action: none;
position: relative;
height: 1rem;
margin: 0.25rem 0;
}
.track,
.fill {
position: absolute;
top: 7px;
height: 2px;
border-radius: 2px;
}
.track {
width: 100%;
background-color: var(--color-interactive-inactive);
}
.fill {
background-color: var(--active);
}
.meta {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,203 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
interface Props {
transcript?: string | null;
url: string;
}
let { url, transcript = null }: Props = $props();
let showTranscript = $state(false);
const RADIUS = 94;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
let element = $state<HTMLVideoElement>();
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
const remaining = $derived(Math.max(0, duration - currentTime));
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
</script>
<div class="RoundVideoWrap">
<button
class="RoundVideo"
onclick={toggle}
type="button"
aria-label="Play video message"
>
<!-- svelte-ignore a11y_media_has_caption -->
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
<video
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onended={() => element && releasePlayback(element)}
onplay={() => element && claimPlayback(element)}
playsinline
preload="metadata"
src={url}
></video>
<svg class="ring" viewBox="0 0 200 200" aria-hidden="true">
<circle
class="ring-progress"
cx="100"
cy="100"
r={RADIUS}
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
/>
</svg>
{#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
{/if}
<span class="badge">
<Icon name="microphone" size="0.875rem" />
{formatDuration(paused && currentTime === 0 ? duration : remaining)}
</span>
</button>
{#if transcript}
<button
class="transcribe"
class:active={showTranscript}
onclick={() => (showTranscript = !showTranscript)}
type="button"
>
<Icon name="transcribe" size="1rem" />
Transcription
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
{/if}
{/if}
</div>
<style lang="scss">
.RoundVideoWrap {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.transcribe {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
border: 0;
border-radius: 0.75rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
background-color: var(--color-primary-tint);
&.active {
color: var(--color-primary);
}
}
.transcript {
max-width: 16rem;
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text);
white-space: pre-wrap;
}
.RoundVideo {
cursor: pointer;
position: relative;
display: block;
width: 13rem;
height: 13rem;
padding: 0;
border: 0;
background: transparent;
}
video {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
background-color: var(--color-default-shadow);
}
.ring {
pointer-events: none;
position: absolute;
inset: 0;
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.ring-progress {
fill: transparent;
stroke: var(--color-white);
stroke-width: 4;
stroke-linecap: round;
stroke-opacity: 0.9;
transition: stroke-dashoffset 0.2s linear;
}
.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));
}
.badge {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-white);
background-color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,227 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { formatDuration } from "$lib/format/duration";
import { claimPlayback, releasePlayback } from "$lib/media/playback";
import { computeWaveform, flatWaveform } from "$lib/media/waveform";
interface Props {
own: boolean;
transcript?: string | null;
url: string;
}
let { url, own, transcript = null }: Props = $props();
let showTranscript = $state(false);
let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0);
const elapsed = $derived(
paused && currentTime === 0 ? duration : currentTime
);
$effect(() => {
let active = true;
computeWaveform(url)
.then((result) => {
if (active) {
peaks = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
function toggle() {
if (!element) {
return;
}
if (paused) {
element.play().catch(() => undefined);
} else {
element.pause();
}
}
function seek(event: PointerEvent) {
if (!(element && duration > 0)) {
return;
}
const rect = event.currentTarget as HTMLElement;
const ratio =
(event.clientX - rect.getBoundingClientRect().left) / rect.offsetWidth;
element.currentTime = Math.min(1, Math.max(0, ratio)) * duration;
}
</script>
<div class="VoiceWrap" class:own>
<div class="Voice">
<button
class="toggle"
onclick={toggle}
type="button"
aria-label="Play voice"
>
<Icon name={paused ? "play" : "pause"} size="1.5rem" />
</button>
<div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="waveform" onpointerdown={seek}>
{#each peaks as peak, i (i)}
<span
class="bar"
class:filled={i / peaks.length < progress}
style:height={`${Math.round(peak * 100)}%`}
></span>
{/each}
</div>
<div class="time">{formatDuration(elapsed)}</div>
</div>
{#if transcript}
<button
class="transcribe"
class:active={showTranscript}
onclick={() => (showTranscript = !showTranscript)}
type="button"
aria-label="Show transcription"
>
<Icon name="transcribe" size="1.125rem" />
</button>
{/if}
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
<audio
bind:this={element}
bind:currentTime
bind:duration
bind:paused
onplay={() => element && claimPlayback(element)}
onended={() => element && releasePlayback(element)}
preload="metadata"
src={url}
></audio>
</div>
{#if transcript && showTranscript}
<div class="transcript">{transcript}</div>
{/if}
</div>
<style lang="scss">
.VoiceWrap {
--active: var(--color-primary);
--inactive: var(--color-interactive-inactive);
--toggle-fg: var(--color-white);
--voice-meta: var(--color-text-secondary);
--transcribe-bg: var(--color-primary-tint);
&.own {
--active: var(--color-white);
--inactive: rgba(255, 255, 255, 0.45);
--toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75);
--transcribe-bg: rgba(255, 255, 255, 0.2);
}
}
.Voice {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 13rem;
padding: 0.1875rem 0;
}
.transcribe {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border: 0;
border-radius: 50%;
color: var(--voice-meta);
background-color: var(--transcribe-bg);
&.active {
color: var(--toggle-fg);
background-color: var(--active);
}
}
.transcript {
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--color-borders);
font-size: 0.9375rem;
line-height: 1.3;
color: var(--color-text);
white-space: pre-wrap;
}
.toggle {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
color: var(--toggle-fg);
background-color: var(--active);
}
.body {
flex: 1;
min-width: 0;
}
.waveform {
cursor: pointer;
touch-action: none;
display: flex;
align-items: flex-end;
gap: 1px;
height: 23px;
}
.bar {
flex: 1;
min-height: 2px;
border-radius: 1px;
background-color: var(--inactive);
&.filled {
background-color: var(--active);
}
}
.time {
margin-top: 0.25rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--voice-meta);
}
</style>