149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
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<string | null>(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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
const startReveal = async (): Promise<void> => {
|
|
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<void> => {
|
|
if (!holdingRef.current && !revealing) return;
|
|
holdingRef.current = false;
|
|
if (revealing) setRevealing(false);
|
|
await teardownReveal();
|
|
};
|
|
|
|
const teardownReveal = async (): Promise<void> => {
|
|
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 (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onPointerDown={() => void startReveal()}
|
|
onPointerUp={() => void endReveal()}
|
|
onPointerLeave={() => void endReveal()}
|
|
onPointerCancel={() => void endReveal()}
|
|
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 select-none"
|
|
>
|
|
<LockIcon className="h-6 w-6 text-accent" />
|
|
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
|
</button>
|
|
{revealing && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Einmal-ansehen Bild"
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
|
|
>
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className="max-h-full max-w-full select-none rounded-lg"
|
|
draggable={false}
|
|
/>
|
|
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
|
|
Loslassen zum Schließen — Aufnahme blockiert
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|