Compare commits

..

3 Commits

4 changed files with 381 additions and 110 deletions
@@ -0,0 +1,250 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { MonitorShareIcon, PollIcon } from './icons';
interface Props {
anchorRef: React.RefObject<HTMLButtonElement | null>;
open: boolean;
onClose: () => void;
onAttachFile: () => void;
onCreatePoll: () => void;
onCreateWhiteboard: () => void;
onStartWatchTogether: () => void;
onStartGame: () => void;
canStartGame?: boolean;
}
export function ComposerActionsMenu({
anchorRef,
open,
onClose,
onAttachFile,
onCreatePoll,
onCreateWhiteboard,
onStartWatchTogether,
onStartGame,
canStartGame = true,
}: Props) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement | null>(null);
const firstItemRef = useRef<HTMLButtonElement | null>(null);
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
useEffect(() => {
if (!open) return;
firstItemRef.current?.focus();
const onDocClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (!target) return;
if (menuRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose, anchorRef]);
if (!open) return null;
// Each item: closes the menu, then runs the action.
const items: Array<{
key: string;
label: string;
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
action: () => void;
disabled?: boolean;
disabledTitle?: string;
section: 'top' | 'activities';
}> = [
{
key: 'attach',
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
Icon: PaperclipIcon,
action: onAttachFile,
section: 'top',
},
{
key: 'poll',
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
Icon: PollIcon,
action: onCreatePoll,
section: 'top',
},
{
key: 'whiteboard',
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
Icon: MonitorShareIcon,
action: onCreateWhiteboard,
section: 'activities',
},
{
key: 'watch',
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
Icon: PlayBoxIcon,
action: onStartWatchTogether,
section: 'activities',
},
{
key: 'game',
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
Icon: GameIcon,
action: onStartGame,
disabled: !canStartGame,
disabledTitle: t('app:composer.menu.game_dm_only', {
defaultValue: 'Nur in 1:1-Chats',
}),
section: 'activities',
},
];
const handleItemClick = (item: (typeof items)[number]) => {
if (item.disabled) return;
onClose();
item.action();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
'button[role="menuitem"]:not([disabled])',
);
if (!focusable || focusable.length === 0) return;
const list = Array.from(focusable);
const idx = list.findIndex((el) => el === document.activeElement);
if (e.key === 'ArrowDown') {
e.preventDefault();
list[(idx + 1) % list.length]?.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
list[(idx - 1 + list.length) % list.length]?.focus();
}
};
const topItems = items.filter((i) => i.section === 'top');
const activityItems = items.filter((i) => i.section === 'activities');
return (
<div
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
// Positioned absolutely above the anchor; the wrapping parent (the
// composer) must be `position: relative` for this to anchor correctly.
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
>
{topItems.map((item, idx) => {
const Icon = item.Icon;
const isFirst = idx === 0;
return (
<button
key={item.key}
ref={isFirst ? firstItemRef : undefined}
type="button"
role="menuitem"
onClick={() => handleItemClick(item)}
disabled={item.disabled}
title={item.disabled ? item.disabledTitle : undefined}
className={
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
(item.disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
}
>
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="flex-1 truncate">{item.label}</span>
</button>
);
})}
<div
role="separator"
className="border-t border-line/60"
aria-hidden="true"
/>
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
</div>
{activityItems.map((item) => {
const Icon = item.Icon;
return (
<button
key={item.key}
type="button"
role="menuitem"
onClick={() => handleItemClick(item)}
disabled={item.disabled}
title={item.disabled ? item.disabledTitle : undefined}
className={
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
(item.disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
}
>
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="flex-1 truncate">{item.label}</span>
</button>
);
})}
</div>
);
}
// --- Inline icons not in the central icons module ----------------------
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
);
}
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect x="3" y="4" width="18" height="14" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect x="3" y="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
);
}
+10
View File
@@ -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);
+108 -101
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker'; import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState'; import { EmptyState } from '../components/EmptyState';
@@ -16,10 +17,10 @@ import {
ArrowRightIcon, ArrowRightIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
EyeIcon,
EyeOffIcon, EyeOffIcon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
PollIcon,
ReplyIcon, ReplyIcon,
SearchIcon, SearchIcon,
SendIcon, SendIcon,
@@ -108,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 }>();
@@ -194,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);
@@ -237,10 +251,8 @@ 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. const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
// Auto-clears on a successful send so the composer doesn't accidentally const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
// 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]);
@@ -739,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();
@@ -859,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));
} }
@@ -1198,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,
),
),
}
: {})} : {})}
/> />
))} ))}
@@ -1243,55 +1268,32 @@ export function ConversationPage() {
className="hidden" className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)} onChange={(e) => handleFilesChosen(e.target.files)}
/> />
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
<button <button
ref={actionsMenuAnchorRef}
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => setActionsMenuOpen((v) => !v)}
aria-label="Datei anhängen" aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
title="Datei anhängen" title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
aria-expanded={actionsMenuOpen}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]" className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<button <ComposerActionsMenu
type="button" anchorRef={actionsMenuAnchorRef}
onClick={() => { open={actionsMenuOpen}
onClose={() => setActionsMenuOpen(false)}
onAttachFile={() => fileInputRef.current?.click()}
onCreatePoll={() => {
setPollError(null); setPollError(null);
setPollDialogOpen(true); setPollDialogOpen(true);
}} }}
aria-label="Umfrage erstellen" onCreateWhiteboard={() => void handleCreateWhiteboard()}
title="Umfrage erstellen" onStartWatchTogether={() => setWatchDialogOpen(true)}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]" onStartGame={() => setGameDialogOpen(true)}
> canStartGame={conversation?.members?.length === 2}
<PollIcon className="h-4 w-4" /> />
</button>
<button
type="button"
onClick={() => void handleCreateWhiteboard()}
disabled={creatingWhiteboard}
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
>
<WhiteboardIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setWatchDialogOpen(true)}
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
>
<PlayBoxIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setGameDialogOpen(true)}
title={t('app:composer.game', { defaultValue: 'Spielen' })}
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<GameIcon className="h-4 w-4" />
</button>
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -1337,20 +1339,6 @@ 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) => {
@@ -1487,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);
}} }}
/> />
@@ -1847,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(() => {
@@ -1886,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}
@@ -1898,32 +1934,3 @@ function AttachmentPreview({
); );
} }
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="4" width="18" height="14" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
);
}