diff --git a/apps/desktop/src/components/AttachmentImage.tsx b/apps/desktop/src/components/AttachmentImage.tsx index ded19e0..9623764 100644 --- a/apps/desktop/src/components/AttachmentImage.tsx +++ b/apps/desktop/src/components/AttachmentImage.tsx @@ -5,9 +5,15 @@ import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache import { supabase } from '../lib/supabase'; import { AlertIcon, SpinnerIcon } from './icons'; import { Lightbox } from './Lightbox'; +import { ViewOnceImage } from './ViewOnceImage'; interface Props { handle: AttachmentHandle; + /** True iff the local user sent this message. View-once images use this + * to suppress the burn (senders never burn their own attachment) and to + * pick the right preview chrome. Defaults to false so existing callers + * (e.g. MediaFilesDrawer) keep working without modification. */ + mine?: boolean; } // Max inline-preview dimension. Full-resolution stays available for the @@ -44,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise { } } -export function AttachmentImage({ handle }: Props) { +export function AttachmentImage({ handle, mine = false }: Props) { const [fullUrl, setFullUrl] = useState(null); const [thumbUrl, setThumbUrl] = useState(null); const [error, setError] = useState(null); @@ -116,6 +122,25 @@ export function AttachmentImage({ handle }: Props) { ); } + // View-once attachments swap the standard image preview for the ViewOnceImage + // chrome (locked card → fullscreen lightbox + burn for recipient, normal + // image with badge for sender). We still kick off the decrypt above so the + // fullscreen lightbox has the decoded blob ready the moment the recipient + // taps. `handle.viewedAt` is undefined in the current renderer (it lives on + // the public attachment row, not in the encrypted payload) — the component + // treats undefined as "not yet burned" and uses the RPC response to flip + // the state locally after the recipient opens. + if (handle.viewOnce) { + return ( + + ); + } + return ( <> + {fullscreen && ( +
setFullscreen(false)} + > + +
+ )} + + ); +} diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 1e4c9ab..d788c41 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -70,7 +70,12 @@ function rowToMessage(row: Record): MessageWithCipher { } export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & { - send: (text: string, images?: File[], replyToId?: string | null) => Promise; + send: ( + text: string, + images?: File[], + replyToId?: string | null, + opts?: { viewOnce?: boolean }, + ) => Promise; refresh: () => Promise; pending: OutboxItem[]; retryPending: (id: string) => void; @@ -559,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar ); const send = useCallback( - async (text: string, images: File[] = [], replyToId: string | null = null) => { + async ( + text: string, + images: File[] = [], + replyToId: string | null = null, + opts: { viewOnce?: boolean } = {}, + ) => { const trimmed = text.trim(); if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return; const priv = privateKeyRef.current; @@ -631,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar ...(dims.width !== undefined ? { width: dims.width } : {}), ...(dims.height !== undefined ? { height: dims.height } : {}), }); + // Stamp the view-once flag on each handle the caller requested it + // for. The flag rides inside the encrypted payload (so peers can + // render the locked card without leaking who-sent-what to the + // server) AND lands on the public message_attachments row via + // insertAttachmentRow below (where the mark-viewed RPC enforces it). + if (opts.viewOnce) { + res.handle.viewOnce = true; + } handles.push(res.handle); blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce)); } diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 3666c3c..d3a00bb 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -15,6 +15,7 @@ import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, + EyeOffIcon, PlusIcon, PollIcon, ReplyIcon, @@ -183,6 +184,10 @@ export function ConversationPage() { const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null); const [emojiOpen, setEmojiOpen] = useState(false); const [gifPickerOpen, setGifPickerOpen] = useState(false); + // Sticky toggle: when on, the next image(s) sent are marked view-once. + // Auto-clears on a successful send so the composer doesn't accidentally + // burn the message-after-next. + const [viewOnceNext, setViewOnceNext] = useState(false); const pins = usePinnedMessages(id); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); @@ -536,10 +541,13 @@ export function ConversationPage() { setSending(true); setSendError(null); try { - await send(text, attachments, replyTo?.id ?? null); + await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext }); setText(''); setAttachments([]); setReplyTo(null); + // Reset the sticky view-once flag so it only applies to the message + // the user explicitly armed it for — Snapchat / WhatsApp parity. + setViewOnceNext(false); if (fileInputRef.current) fileInputRef.current.value = ''; setStickToBottom(true); notifyStopTyping(); @@ -1012,6 +1020,20 @@ export function ConversationPage() { onPick={(gif) => void handleGifPick(gif)} /> + {