import { useEffect, useRef, 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. Press-and- // hold reveals the image fullscreen; release closes it 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. // // While revealed, the renderer window enables content-protection // (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd // snipping tools, screen recorders) sees a black/empty surface. Re-enabled // on release / unmount. export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) { const [revealedAt, setRevealedAt] = useState(viewedAt); const [revealing, setRevealing] = useState(false); const burnedRef = useRef(false); const holdingRef = useRef(false); const burned = revealedAt !== null; // Tear down screen-capture protection if the component unmounts mid-reveal. useEffect(() => { return () => { if (revealing || holdingRef.current) { void window.electronAPI?.setContentProtection?.(false).catch(() => {}); } }; }, [revealing]); if (burned && !isSender) { return (
Angesehen am {new Date(revealedAt).toLocaleString()}
); } if (isSender) { return (
Einmal ansehen {burned && ( Angesehen )}
); } const startReveal = async (): Promise => { if (burnedRef.current) return; burnedRef.current = true; holdingRef.current = true; try { await window.electronAPI?.setContentProtection?.(true); } catch (err) { console.warn('setContentProtection enable failed', err); } // The user may have released during the await. If so, skip showing the // dialog and run the close-path directly so we don't leave the renderer // in protected mode with no visible UI. if (!holdingRef.current) { // User released during the IPC await — endReveal already fired and is // responsible for teardown (setContentProtection(false) + mark-viewed). // Skipping teardown here avoids a duplicate markAttachmentViewed RPC. return; } setRevealing(true); }; const endReveal = async (): Promise => { if (!holdingRef.current && !revealing) return; holdingRef.current = false; if (revealing) setRevealing(false); await teardownReveal(); }; const teardownReveal = async (): Promise => { try { await window.electronAPI?.setContentProtection?.(false); } catch (err) { console.warn('setContentProtection disable failed', err); } try { const res = await markAttachmentViewed(supabase, attachmentId); if (res.viewedAt) setRevealedAt(res.viewedAt); } catch (err) { console.warn('mark-viewed failed', err); burnedRef.current = false; } }; return ( <> {revealing && (
Loslassen zum Schließen — Aufnahme blockiert
)} ); }