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:
@@ -5,9 +5,15 @@ import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
import { Lightbox } from './Lightbox';
|
import { Lightbox } from './Lightbox';
|
||||||
|
import { ViewOnceImage } from './ViewOnceImage';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
handle: AttachmentHandle;
|
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
|
// Max inline-preview dimension. Full-resolution stays available for the
|
||||||
@@ -44,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise<Blob | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentImage({ handle }: Props) {
|
export function AttachmentImage({ handle, mine = false }: Props) {
|
||||||
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<ViewOnceImage
|
||||||
|
attachmentId={handle.id}
|
||||||
|
viewedAt={handle.viewedAt ?? null}
|
||||||
|
isSender={mine}
|
||||||
|
src={blobUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ export function MessageBubble({
|
|||||||
return <AttachmentAudio key={a.id} handle={a} />;
|
return <AttachmentAudio key={a.id} handle={a} />;
|
||||||
}
|
}
|
||||||
if (a.mimeType.startsWith('image/')) {
|
if (a.mimeType.startsWith('image/')) {
|
||||||
return <AttachmentImage key={a.id} handle={a} />;
|
return <AttachmentImage key={a.id} handle={a} mine={mine} />;
|
||||||
}
|
}
|
||||||
if (a.mimeType.startsWith('video/')) {
|
if (a.mimeType.startsWith('video/')) {
|
||||||
return <AttachmentVideo key={a.id} handle={a} />;
|
return <AttachmentVideo key={a.id} handle={a} />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,7 +70,12 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
send: (
|
||||||
|
text: string,
|
||||||
|
images?: File[],
|
||||||
|
replyToId?: string | null,
|
||||||
|
opts?: { viewOnce?: boolean },
|
||||||
|
) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
pending: OutboxItem[];
|
pending: OutboxItem[];
|
||||||
retryPending: (id: string) => void;
|
retryPending: (id: string) => void;
|
||||||
@@ -559,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const send = useCallback(
|
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();
|
const trimmed = text.trim();
|
||||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
@@ -631,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
...(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);
|
handles.push(res.handle);
|
||||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
EyeOffIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PollIcon,
|
PollIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
@@ -183,6 +184,10 @@ export function ConversationPage() {
|
|||||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
const [gifPickerOpen, setGifPickerOpen] = 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 pins = usePinnedMessages(id);
|
||||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||||
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
||||||
@@ -536,10 +541,13 @@ export function ConversationPage() {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
try {
|
try {
|
||||||
await send(text, attachments, replyTo?.id ?? null);
|
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
|
||||||
setText('');
|
setText('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setReplyTo(null);
|
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 = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
@@ -1012,6 +1020,20 @@ export function ConversationPage() {
|
|||||||
onPick={(gif) => void handleGifPick(gif)}
|
onPick={(gif) => void handleGifPick(gif)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewOnceNext((v) => !v)}
|
||||||
|
aria-pressed={viewOnceNext}
|
||||||
|
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
|
||||||
|
className={
|
||||||
|
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||||
|
(viewOnceNext
|
||||||
|
? 'bg-accent/20 text-accent'
|
||||||
|
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<VoiceRecorder
|
<VoiceRecorder
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onComplete={async (file) => {
|
onComplete={async (file) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user