feat(desktop): view-once image attachments (sender toggle + recipient lightbox + tombstone)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-16 18:18:39 +02:00
parent 915569db39
commit 95156a65eb
5 changed files with 157 additions and 5 deletions
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { markAttachmentViewed } from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
import { EyeOffIcon, LockIcon } from './icons';
interface Props {
attachmentId: string;
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
viewedAt: string | null;
/** True iff the local user is the sender — they don't burn the view. */
isSender: boolean;
/** Decrypted image source; only fetched/displayed inside the lightbox. */
src: string;
}
// Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
// fullscreen lightbox AND fires the mark-viewed RPC.
// 2. viewedAt is set → tombstone "Angesehen am …".
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
const [fullscreen, setFullscreen] = useState(false);
const burned = revealedAt !== null;
if (burned && !isSender) {
return (
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
<EyeOffIcon className="mr-2 h-4 w-4" />
Angesehen am {new Date(revealedAt).toLocaleString()}
</div>
);
}
if (isSender) {
return (
<div className="relative">
<img src={src} alt="" className="max-h-72 rounded-lg" />
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
</span>
{burned && (
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
Angesehen
</span>
)}
</div>
);
}
// Recipient, not yet viewed.
const handleOpen = async (): Promise<void> => {
try {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
}
setFullscreen(true);
};
return (
<>
<button
type="button"
onClick={() => void handleOpen()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
>
<LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Einmal ansehen antippen</span>
</button>
{fullscreen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
onClick={() => setFullscreen(false)}
>
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
</div>
)}
</>
);
}