diff --git a/apps/desktop/src/components/icons.tsx b/apps/desktop/src/components/icons.tsx index 2ffae37..ff456f0 100644 --- a/apps/desktop/src/components/icons.tsx +++ b/apps/desktop/src/components/icons.tsx @@ -150,6 +150,16 @@ function EyeOffIconInner(props: IconProps) { } export const EyeOffIcon = memo(EyeOffIconInner); +function EyeIconInner(props: IconProps) { + return ( + + + + + ); +} +export const EyeIcon = memo(EyeIconInner); + function CaptionsIconInner(props: IconProps) { return ( diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index e3728d0..716f3b0 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -75,7 +75,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar text: string, images?: File[], replyToId?: string | null, - opts?: { viewOnce?: boolean }, + opts?: { viewOnceFlags?: boolean[] }, ) => Promise; refresh: () => Promise; pending: OutboxItem[]; @@ -569,7 +569,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar text: string, images: File[] = [], replyToId: string | null = null, - opts: { viewOnce?: boolean } = {}, + opts: { viewOnceFlags?: boolean[] } = {}, ) => { const trimmed = text.trim(); if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return; @@ -626,9 +626,13 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar // 1. Upload + encrypt each image. Collect handles + raw blob nonces // (so the public attachment row can reference the blob-level nonce). + // P7.T4: view-once is now a per-attachment flag rather than a + // composer-wide toggle. `opts.viewOnceFlags` is a parallel array; + // missing entries (or whole-array absence) default to false. const handles: AttachmentHandle[] = []; const blobNonceHexByHandleId = new Map(); - for (const file of images) { + for (let i = 0; i < images.length; i++) { + const file = images[i]!; if (file.size > MAX_ATTACHMENT_BYTES) { throw new Error('attachment exceeds max size (10 MB)'); } @@ -649,12 +653,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar ...(dims.height !== undefined ? { height: dims.height } : {}), ...(thumbBlob ? { thumbBlob } : {}), }); - // 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) { + // Stamp the view-once flag on each handle the caller flagged. 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.viewOnceFlags?.[i]) { res.handle.viewOnce = true; } handles.push(res.handle); diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 54a6e5e..d486b5d 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -17,6 +17,8 @@ import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, + EyeIcon, + EyeOffIcon, PencilIcon, PlusIcon, ReplyIcon, @@ -107,6 +109,15 @@ const EMPTY_REACTIONS: AggregatedReaction[] = []; // reading position even if some rows above re-render at different heights. const scrollPositions = new Map(); +/** Pending composer attachment: the raw File plus the per-attachment + * view-once flag the user can toggle from the thumb hover button (P7.T4). + * Lives only in composer state — the flag is forwarded into + * `message_attachments.view_once` per row when the message is sent. */ +interface PendingAttachment { + file: File; + viewOnce: boolean; +} + export function ConversationPage() { const { t } = useTranslation(['app', 'errors']); const { id } = useParams<{ id: string }>(); @@ -193,7 +204,11 @@ export function ConversationPage() { const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [stickToBottom, setStickToBottom] = useState(true); - const [attachments, setAttachments] = useState([]); + // Pending composer attachments — each carries its own view-once flag so + // the user can mark individual images "burn after viewing" via the hover + // toggle on the thumb (P7.T4). Non-image attachments keep viewOnce=false + // but the field stays on the object so the shape is uniform. + const [attachments, setAttachments] = useState([]); const [annotatingIndex, setAnnotatingIndex] = useState(null); const [infoPanelOpen, setInfoPanelOpen] = useState(false); const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false); @@ -238,10 +253,6 @@ export function ConversationPage() { const [gifPickerOpen, setGifPickerOpen] = useState(false); const [actionsMenuOpen, setActionsMenuOpen] = useState(false); const actionsMenuAnchorRef = useRef(null); - // 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, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); @@ -740,13 +751,15 @@ export function ConversationPage() { setSending(true); setSendError(null); try { - await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext }); + await send( + text, + attachments.map((a) => a.file), + replyTo?.id ?? null, + { viewOnceFlags: attachments.map((a) => a.viewOnce) }, + ); 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(); @@ -860,13 +873,15 @@ export function ConversationPage() { async function ingestFiles(files: File[]) { const compressed = await compressImages(files); - const next: File[] = []; + const next: PendingAttachment[] = []; for (const f of compressed) { if (f.size > 10 * 1024 * 1024) { setSendError('Datei zu groß (max 10 MB)'); continue; } - next.push(f); + // New attachments default to viewOnce=false; user opts in per-thumb + // via the eye-toggle button on the preview (P7.T4). + next.push({ file: f, viewOnce: false }); } setAttachments((prev) => [...prev, ...next].slice(0, 4)); } @@ -1199,13 +1214,22 @@ export function ConversationPage() { {attachments.length > 0 && (
- {attachments.map((file, idx) => ( + {attachments.map((a, idx) => ( setAttachments((prev) => prev.filter((_, i) => i !== idx))} - {...(file.type.startsWith('image/') - ? { onEdit: () => setAnnotatingIndex(idx) } + {...(a.file.type.startsWith('image/') + ? { + onEdit: () => setAnnotatingIndex(idx), + onToggleViewOnce: () => + setAttachments((prev) => + prev.map((x, i) => + i === idx ? { ...x, viewOnce: !x.viewOnce } : x, + ), + ), + } : {})} /> ))} @@ -1451,10 +1475,17 @@ export function ConversationPage() { {annotatingIndex !== null && attachments[annotatingIndex] && ( setAnnotatingIndex(null)} onSave={(next) => { - setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f))); + // Preserve the per-attachment viewOnce flag across annotation — + // the user's burn-after-viewing intent shouldn't reset just + // because they redrew the image. + setAttachments((prev) => + prev.map((a, i) => + i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a, + ), + ); setAnnotatingIndex(null); }} /> @@ -1811,13 +1842,18 @@ function Banner({ children }: { children: React.ReactNode }) { function AttachmentPreview({ file, + viewOnce, onRemove, onEdit, + onToggleViewOnce, }: { file: File; + viewOnce: boolean; onRemove: () => void; onEdit?: () => void; + onToggleViewOnce?: () => void; }) { + const { t } = useTranslation(['app']); const isImage = file.type.startsWith('image/'); const [url, setUrl] = useState(null); useEffect(() => { @@ -1850,6 +1886,42 @@ function AttachmentPreview({ )} + {isImage && onToggleViewOnce && ( + + )} + {/* When viewOnce is on, overlay a persistent "1×" badge so the user + has visual confirmation independent of the small toggle button. */} + {isImage && viewOnce && ( + + )}