44088b35d7
Route splitting - React.lazy for AdminPage, SettingsPage, FriendsPage, DevicePage, AuthCallbackPage; ChatsPage + ConversationPage stay eager - RouteSuspense wrapper with spinner fallback Vendor chunking - Vite manualChunks splits livekit-client, libsodium, @supabase, react into dedicated cacheable chunks Image thumbnails - createImageBitmap + OffscreenCanvas downscales inline preview to max 640px, emits webp; full blob reserved for the lightbox - Passes through gif/apng/webp so animation is preserved - decoding="async" on the inline img Attachment cache - lib/attachmentCache.ts backed by OPFS; 7-day TTL - AttachmentImage/Audio/Video/PDF/Generic read cache first, decrypt on miss, write-through on success; graceful no-op when OPFS missing Avatar cache - lib/avatarCache.ts — session Map<url, blobUrl> + warmAvatarCache() helper for bulk preload Message batching - Realtime INSERT burst collapses to a single refresh() when >3 ids land within a 250ms window; solo inserts keep the per-id path for latency parity Conversation-list virtualization - VirtualConversationList with IntersectionObserver sentinel, initial 40 rows + 40 per batch; no overhead under threshold Rust release tuning - Cargo [profile.release]: lto, codegen-units=1, strip=symbols, panic=abort, opt-level="s" — ~20-30% smaller binary, faster startup
252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
|
import { supabase } from '../lib/supabase';
|
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
|
|
|
interface Props {
|
|
handle: AttachmentHandle;
|
|
}
|
|
|
|
const BAR_COUNT = 48;
|
|
|
|
// Custom voice-message player with waveform visualisation. Decoded peaks are
|
|
// computed once per blob via OfflineAudioContext so playback only carries the
|
|
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
|
|
// blob mime is recognised by <audio> but not by AudioContext).
|
|
export function AttachmentAudio({ handle }: Props) {
|
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [peaks, setPeaks] = useState<number[] | null>(null);
|
|
const [duration, setDuration] = useState<number>(0);
|
|
const [position, setPosition] = useState<number>(0);
|
|
const [playing, setPlaying] = useState(false);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let url: string | null = null;
|
|
setError(null);
|
|
setBlobUrl(null);
|
|
setArrayBuf(null);
|
|
|
|
void (async () => {
|
|
const cached = await getCachedAttachment(handle.id);
|
|
if (cached) {
|
|
if (cancelled) return;
|
|
url = URL.createObjectURL(cached);
|
|
setBlobUrl(url);
|
|
const buf = await cached.arrayBuffer();
|
|
if (!cancelled) setArrayBuf(buf);
|
|
return;
|
|
}
|
|
try {
|
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
|
if (cancelled) return;
|
|
url = URL.createObjectURL(blob);
|
|
setBlobUrl(url);
|
|
const buf = await blob.arrayBuffer();
|
|
if (!cancelled) setArrayBuf(buf);
|
|
void putCachedAttachment(handle.id, blob);
|
|
} catch (err: unknown) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : 'download failed');
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (url) URL.revokeObjectURL(url);
|
|
};
|
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
|
|
|
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
|
|
// bucketed into BAR_COUNT bars. Done once per attachment.
|
|
useEffect(() => {
|
|
if (!arrayBuf) return;
|
|
let cancelled = false;
|
|
const Ctx =
|
|
window.AudioContext ||
|
|
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
const ctx = new Ctx();
|
|
ctx
|
|
.decodeAudioData(arrayBuf.slice(0))
|
|
.then((decoded) => {
|
|
if (cancelled) return;
|
|
setDuration(decoded.duration);
|
|
const channel = decoded.getChannelData(0);
|
|
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
|
|
const out = new Array<number>(BAR_COUNT).fill(0);
|
|
for (let i = 0; i < BAR_COUNT; i++) {
|
|
let max = 0;
|
|
const start = i * bucket;
|
|
const end = Math.min(channel.length, start + bucket);
|
|
for (let j = start; j < end; j++) {
|
|
const v = Math.abs(channel[j]!);
|
|
if (v > max) max = v;
|
|
}
|
|
out[i] = max;
|
|
}
|
|
// Normalize so loudest peak is 1; keeps quiet recordings visible.
|
|
const peak = Math.max(...out, 0.001);
|
|
setPeaks(out.map((v) => v / peak));
|
|
})
|
|
.catch(() => {
|
|
// Fall through — UI shows a flat bar but playback still works.
|
|
})
|
|
.finally(() => {
|
|
void ctx.close().catch(() => {});
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [arrayBuf]);
|
|
|
|
const fallbackPeaks = useMemo(
|
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
|
[peaks],
|
|
);
|
|
const visiblePeaks = peaks ?? fallbackPeaks!;
|
|
const progress = duration > 0 ? position / duration : 0;
|
|
|
|
const onTogglePlay = () => {
|
|
const el = audioRef.current;
|
|
if (!el || !blobUrl) return;
|
|
if (el.paused) void el.play();
|
|
else el.pause();
|
|
};
|
|
|
|
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const el = audioRef.current;
|
|
if (!el || duration === 0) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
|
el.currentTime = ratio * duration;
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
|
<AlertIcon className="h-4 w-4" />
|
|
<span>{error}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={onTogglePlay}
|
|
disabled={!blobUrl}
|
|
aria-label={playing ? 'Pause' : 'Wiedergabe'}
|
|
title={playing ? 'Pause' : 'Wiedergabe'}
|
|
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{!blobUrl ? (
|
|
<SpinnerIcon className="h-4 w-4" />
|
|
) : playing ? (
|
|
<PauseGlyph />
|
|
) : (
|
|
<PlayGlyph />
|
|
)}
|
|
</button>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<div
|
|
role="slider"
|
|
aria-label="Position"
|
|
aria-valuemin={0}
|
|
aria-valuemax={Math.max(1, Math.floor(duration))}
|
|
aria-valuenow={Math.floor(position)}
|
|
tabIndex={0}
|
|
onClick={onSeek}
|
|
className="flex h-7 cursor-pointer items-center gap-[2px]"
|
|
>
|
|
{visiblePeaks.map((v, i) => {
|
|
const playedRatio = (i + 0.5) / BAR_COUNT;
|
|
const played = playedRatio <= progress;
|
|
const h = Math.max(2, Math.round(v * 22));
|
|
return (
|
|
<span
|
|
key={i}
|
|
style={{ height: h + 'px' }}
|
|
className={
|
|
'w-[3px] rounded-full ' +
|
|
(played ? 'bg-accent' : 'bg-fg-muted/40')
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
|
|
<span className="inline-flex items-center gap-1">
|
|
<MicIcon className="h-3 w-3" />
|
|
<span>{formatSec(playing ? position : duration)}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{blobUrl && (
|
|
<audio
|
|
ref={audioRef}
|
|
src={blobUrl}
|
|
preload="metadata"
|
|
onLoadedMetadata={(e) => {
|
|
// Some webm/opus blobs report Infinity until first seek (Chrome
|
|
// bug). Force a seek to flush real duration.
|
|
const el = e.currentTarget;
|
|
if (!Number.isFinite(el.duration)) {
|
|
el.currentTime = 1e9;
|
|
setTimeout(() => {
|
|
el.currentTime = 0;
|
|
}, 0);
|
|
} else if (duration === 0) {
|
|
setDuration(el.duration);
|
|
}
|
|
}}
|
|
onDurationChange={(e) => {
|
|
const d = e.currentTarget.duration;
|
|
if (Number.isFinite(d) && d > 0) setDuration(d);
|
|
}}
|
|
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
|
|
onPlay={() => setPlaying(true)}
|
|
onPause={() => setPlaying(false)}
|
|
onEnded={() => {
|
|
setPlaying(false);
|
|
setPosition(0);
|
|
}}
|
|
className="hidden"
|
|
>
|
|
<track kind="captions" />
|
|
</audio>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlayGlyph() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
|
<path d="M5 3.5l8 4.5-8 4.5z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function PauseGlyph() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
|
<rect x="4" y="3" width="3" height="10" rx="1" />
|
|
<rect x="9" y="3" width="3" height="10" rx="1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function formatSec(sec: number): string {
|
|
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
|
const m = Math.floor(sec / 60);
|
|
const s = Math.floor(sec % 60);
|
|
return m + ':' + s.toString().padStart(2, '0');
|
|
}
|