feat(P7.T4): per-attachment view-once toggle on AttachmentPreview
This commit is contained in:
@@ -150,6 +150,16 @@ function EyeOffIconInner(props: IconProps) {
|
|||||||
}
|
}
|
||||||
export const EyeOffIcon = memo(EyeOffIconInner);
|
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||||
|
|
||||||
|
function EyeIconInner(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export const EyeIcon = memo(EyeIconInner);
|
||||||
|
|
||||||
function CaptionsIconInner(props: IconProps) {
|
function CaptionsIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
text: string,
|
text: string,
|
||||||
images?: File[],
|
images?: File[],
|
||||||
replyToId?: string | null,
|
replyToId?: string | null,
|
||||||
opts?: { viewOnce?: boolean },
|
opts?: { viewOnceFlags?: boolean[] },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
pending: OutboxItem[];
|
pending: OutboxItem[];
|
||||||
@@ -569,7 +569,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
text: string,
|
text: string,
|
||||||
images: File[] = [],
|
images: File[] = [],
|
||||||
replyToId: string | null = null,
|
replyToId: string | null = null,
|
||||||
opts: { viewOnce?: boolean } = {},
|
opts: { viewOnceFlags?: 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;
|
||||||
@@ -626,9 +626,13 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
|
|
||||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||||
// (so the public attachment row can reference the blob-level nonce).
|
// (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 handles: AttachmentHandle[] = [];
|
||||||
const blobNonceHexByHandleId = new Map<string, string>();
|
const blobNonceHexByHandleId = new Map<string, string>();
|
||||||
for (const file of images) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const file = images[i]!;
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
throw new Error('attachment exceeds max size (10 MB)');
|
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 } : {}),
|
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||||
...(thumbBlob ? { thumbBlob } : {}),
|
...(thumbBlob ? { thumbBlob } : {}),
|
||||||
});
|
});
|
||||||
// Stamp the view-once flag on each handle the caller requested it
|
// Stamp the view-once flag on each handle the caller flagged. The
|
||||||
// for. The flag rides inside the encrypted payload (so peers can
|
// flag rides inside the encrypted payload (so peers can render the
|
||||||
// render the locked card without leaking who-sent-what to the
|
// locked card without leaking who-sent-what to the server) AND
|
||||||
// server) AND lands on the public message_attachments row via
|
// lands on the public message_attachments row via insertAttachmentRow
|
||||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
// below (where the mark-viewed RPC enforces it).
|
||||||
if (opts.viewOnce) {
|
if (opts.viewOnceFlags?.[i]) {
|
||||||
res.handle.viewOnce = true;
|
res.handle.viewOnce = true;
|
||||||
}
|
}
|
||||||
handles.push(res.handle);
|
handles.push(res.handle);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeOffIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
@@ -107,6 +109,15 @@ const EMPTY_REACTIONS: AggregatedReaction[] = [];
|
|||||||
// reading position even if some rows above re-render at different heights.
|
// reading position even if some rows above re-render at different heights.
|
||||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||||
|
|
||||||
|
/** 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() {
|
export function ConversationPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -193,7 +204,11 @@ export function ConversationPage() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
// 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<PendingAttachment[]>([]);
|
||||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||||
@@ -238,10 +253,6 @@ export function ConversationPage() {
|
|||||||
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
||||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||||
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(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 { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = 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]);
|
||||||
@@ -740,13 +751,15 @@ export function ConversationPage() {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
try {
|
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('');
|
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();
|
||||||
@@ -860,13 +873,15 @@ export function ConversationPage() {
|
|||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
const next: File[] = [];
|
const next: PendingAttachment[] = [];
|
||||||
for (const f of compressed) {
|
for (const f of compressed) {
|
||||||
if (f.size > 10 * 1024 * 1024) {
|
if (f.size > 10 * 1024 * 1024) {
|
||||||
setSendError('Datei zu groß (max 10 MB)');
|
setSendError('Datei zu groß (max 10 MB)');
|
||||||
continue;
|
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));
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||||
}
|
}
|
||||||
@@ -1199,13 +1214,22 @@ export function ConversationPage() {
|
|||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="mb-2 flex flex-wrap gap-2">
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
{attachments.map((file, idx) => (
|
{attachments.map((a, idx) => (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
key={idx}
|
key={idx}
|
||||||
file={file}
|
file={a.file}
|
||||||
|
viewOnce={a.viewOnce}
|
||||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
{...(file.type.startsWith('image/')
|
{...(a.file.type.startsWith('image/')
|
||||||
? { onEdit: () => setAnnotatingIndex(idx) }
|
? {
|
||||||
|
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] && (
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
file={attachments[annotatingIndex]!}
|
file={attachments[annotatingIndex]!.file}
|
||||||
onCancel={() => setAnnotatingIndex(null)}
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
onSave={(next) => {
|
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);
|
setAnnotatingIndex(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1811,13 +1842,18 @@ function Banner({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function AttachmentPreview({
|
function AttachmentPreview({
|
||||||
file,
|
file,
|
||||||
|
viewOnce,
|
||||||
onRemove,
|
onRemove,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onToggleViewOnce,
|
||||||
}: {
|
}: {
|
||||||
file: File;
|
file: File;
|
||||||
|
viewOnce: boolean;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
onToggleViewOnce?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1850,6 +1886,42 @@ function AttachmentPreview({
|
|||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isImage && onToggleViewOnce && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleViewOnce}
|
||||||
|
aria-label={
|
||||||
|
viewOnce
|
||||||
|
? t('app:composer.view_once_off', { defaultValue: 'Einmal-Ansicht deaktivieren' })
|
||||||
|
: t('app:composer.view_once_on', { defaultValue: 'Einmal-Ansicht aktivieren' })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
viewOnce
|
||||||
|
? t('app:composer.view_once_on_hint', {
|
||||||
|
defaultValue: 'Empfänger sieht das Bild nur einmal',
|
||||||
|
})
|
||||||
|
: t('app:composer.view_once_off_hint', { defaultValue: 'Einmal-Ansicht ein/aus' })
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
'absolute bottom-1 right-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full transition ' +
|
||||||
|
(viewOnce
|
||||||
|
? 'bg-accent text-accent-fg opacity-100'
|
||||||
|
: 'bg-black/70 text-white opacity-0 hover:bg-accent/80 group-hover:opacity-100')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{viewOnce ? <EyeIcon className="h-3 w-3" /> : <EyeOffIcon className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* When viewOnce is on, overlay a persistent "1×" badge so the user
|
||||||
|
has visual confirmation independent of the small toggle button. */}
|
||||||
|
{isImage && viewOnce && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-1 right-7 rounded-md bg-accent/90 px-1 py-0.5 text-[9px] font-bold text-accent-fg"
|
||||||
|
>
|
||||||
|
1×
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
|
|||||||
Reference in New Issue
Block a user