Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f704e80f6 | |||
| 940432d287 | |||
| 28b6d64936 | |||
| 92baa626d6 | |||
| cd7ef8dccc | |||
| 58efc66ca7 | |||
| 1e139fb86e | |||
| eeb713f03d | |||
| 837b5a326e |
@@ -147,6 +147,17 @@ async function createWindow(): Promise<BrowserWindow> {
|
|||||||
attachWindowState(win, WINDOW_STATE_FILE);
|
attachWindowState(win, WINDOW_STATE_FILE);
|
||||||
|
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
|
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
|
||||||
|
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
|
||||||
|
// separate inspector window for easy debugging.
|
||||||
|
win.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
// Forward renderer console messages to the main-process stdout so
|
||||||
|
// errors during local dev are visible in the terminal too (helps when
|
||||||
|
// the inspector isn't focused).
|
||||||
|
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||||
|
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
|
||||||
|
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
|
||||||
|
});
|
||||||
await win.loadURL(DEV_URL);
|
await win.loadURL(DEV_URL);
|
||||||
} else {
|
} else {
|
||||||
await win.loadFile(resolveRendererIndex());
|
await win.loadFile(resolveRendererIndex());
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
// (<1ms per op for the current workload).
|
// (<1ms per op for the current workload).
|
||||||
//
|
//
|
||||||
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||||
// bindings as an array. SQLite natively accepts $N so existing queries
|
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||||
// keep working unmodified.
|
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
|
||||||
|
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
|
||||||
|
// array-shape from callers and convert to the named-object on the way in.
|
||||||
|
|
||||||
import { app, ipcMain } from 'electron';
|
import { app, ipcMain } from 'electron';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a positional bindings array `[v1, v2]` to the named-params object
|
||||||
|
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
|
||||||
|
// `$1`/`$2` named placeholders. Returns the original array (spread later)
|
||||||
|
// when it's empty.
|
||||||
|
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
|
||||||
|
const arr = bindings ?? [];
|
||||||
|
if (arr.length === 0) return [];
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
obj[String(i + 1)] = arr[i];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
export function register(): void {
|
export function register(): void {
|
||||||
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||||
const rawName = stripPrefix(args.name);
|
const rawName = stripPrefix(args.name);
|
||||||
@@ -59,7 +75,8 @@ export function register(): void {
|
|||||||
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||||
const entry = requireHandle(args.handle);
|
const entry = requireHandle(args.handle);
|
||||||
const stmt = entry.db.prepare(args.query);
|
const stmt = entry.db.prepare(args.query);
|
||||||
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
|
const params = bindParams(args.bindings);
|
||||||
|
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
|
||||||
return {
|
return {
|
||||||
rowsAffected: info.changes,
|
rowsAffected: info.changes,
|
||||||
lastInsertId:
|
lastInsertId:
|
||||||
@@ -75,7 +92,11 @@ export function register(): void {
|
|||||||
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||||
const entry = requireHandle(args.handle);
|
const entry = requireHandle(args.handle);
|
||||||
const stmt = entry.db.prepare(args.query);
|
const stmt = entry.db.prepare(args.query);
|
||||||
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
|
const params = bindParams(args.bindings);
|
||||||
|
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>[];
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
downloadAndDecryptAttachment,
|
downloadAndDecryptAttachment,
|
||||||
downloadAndDecryptAttachmentThumb,
|
downloadAndDecryptAttachmentThumb,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
@@ -166,11 +166,21 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
// skipped the eager full-blob download above). Resolves into the same
|
// skipped the eager full-blob download above). Resolves into the same
|
||||||
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
||||||
// backing the bubble until the lightbox actually mounts.
|
// backing the bubble until the lightbox actually mounts.
|
||||||
|
//
|
||||||
|
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
|
||||||
|
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
|
||||||
|
// run → previous cleanup fires → URL revoked → Lightbox renders
|
||||||
|
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
|
||||||
|
// unmount-only effect below tracks the current URL via ref and revokes
|
||||||
|
// it once when the component truly leaves the tree.
|
||||||
|
//
|
||||||
|
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
|
||||||
|
// attachment id, so object-identity churn from parent re-renders must
|
||||||
|
// not re-trigger the fetch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lightboxOpen) return;
|
if (!lightboxOpen) return;
|
||||||
if (fullUrl) return;
|
if (fullUrl) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const created: string[] = [];
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const cached = await getCachedAttachment(handle.id);
|
const cached = await getCachedAttachment(handle.id);
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
@@ -187,14 +197,27 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
|||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const u = URL.createObjectURL(blob);
|
const u = URL.createObjectURL(blob);
|
||||||
created.push(u);
|
|
||||||
setFullUrl(u);
|
setFullUrl(u);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
for (const u of created) URL.revokeObjectURL(u);
|
|
||||||
};
|
};
|
||||||
}, [lightboxOpen, fullUrl, handle]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lightboxOpen, handle.id]);
|
||||||
|
|
||||||
|
// Track the currently-published fullUrl in a ref so the unmount-only
|
||||||
|
// cleanup below can revoke whatever URL is live at teardown time
|
||||||
|
// without subscribing to fullUrl changes (which would re-trigger and
|
||||||
|
// revoke prematurely — see the comment above the fetch effect).
|
||||||
|
const fullUrlRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
fullUrlRef.current = fullUrl;
|
||||||
|
}, [fullUrl]);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const blobUrl = thumbUrl ?? fullUrl;
|
const blobUrl = thumbUrl ?? fullUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,20 +41,42 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
|||||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||||
const [draftTick, setDraftTick] = useState(0);
|
const [draftTick, setDraftTick] = useState(0);
|
||||||
|
|
||||||
|
// Hold a stable ref to onCancel so the image-load effect doesn't depend
|
||||||
|
// on its identity. Without this, parents that pass an inline `() => …`
|
||||||
|
// re-render the modal on every keystroke / state change, the effect re-
|
||||||
|
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
|
||||||
|
// is still decoding → img.onerror fires ("file not found") → onCancel →
|
||||||
|
// modal flashes open + closes instantly.
|
||||||
|
const onCancelRef = useRef(onCancel);
|
||||||
|
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// React 18 strict mode in dev double-mounts effects to test idempotency.
|
||||||
|
// The first run creates a blob URL, sets img.src, returns a cleanup
|
||||||
|
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
|
||||||
|
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
|
||||||
|
// the revoked URL → img.onerror → modal closes instantly. The
|
||||||
|
// `cancelled` flag guards every callback so a torn-down run can't
|
||||||
|
// close the modal that the second mount just opened.
|
||||||
|
let cancelled = false;
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
imageRef.current = img;
|
imageRef.current = img;
|
||||||
setImageLoaded(true);
|
setImageLoaded(true);
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
|
if (cancelled) return;
|
||||||
console.error('ImageAnnotator: failed to decode source image');
|
console.error('ImageAnnotator: failed to decode source image');
|
||||||
onCancel();
|
onCancelRef.current();
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => {
|
||||||
}, [file, onCancel]);
|
cancelled = true;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageLoaded) return;
|
if (!imageLoaded) return;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
@@ -434,6 +446,22 @@ export function ConversationPage() {
|
|||||||
return out;
|
return out;
|
||||||
}, [messages, pending, displayCount]);
|
}, [messages, pending, displayCount]);
|
||||||
|
|
||||||
|
// Snapshot of the saved position for this conversation, captured once on
|
||||||
|
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
|
||||||
|
// Virtuoso instance below — Virtuoso applies that index synchronously
|
||||||
|
// before its first paint, so re-entering a chat shows the saved row in
|
||||||
|
// one frame rather than a "starts at top, jumps" flicker.
|
||||||
|
//
|
||||||
|
// Declared HERE (above `initialTopMostIndex`) rather than further down
|
||||||
|
// because the useMemo that consumes it would otherwise hit a TDZ on
|
||||||
|
// first render — `const` refs aren't hoisted.
|
||||||
|
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (savedPositionRef.current === null && id) {
|
||||||
|
savedPositionRef.current = scrollPositions.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Initial scroll position for the freshly-mounted Virtuoso instance.
|
// Initial scroll position for the freshly-mounted Virtuoso instance.
|
||||||
// Default = bottom (newest message). If we have a saved position from a
|
// Default = bottom (newest message). If we have a saved position from a
|
||||||
// previous visit to this chat AND the user wasn't sticking to the
|
// previous visit to this chat AND the user wasn't sticking to the
|
||||||
@@ -648,17 +676,8 @@ export function ConversationPage() {
|
|||||||
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
|
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
|
||||||
// longer needed: Virtuoso owns scroll positioning now.
|
// longer needed: Virtuoso owns scroll positioning now.
|
||||||
|
|
||||||
// Snapshot of the saved position for this conversation, captured once on
|
// (savedPositionRef declared earlier — see TDZ note above the
|
||||||
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
|
// initialTopMostIndex useMemo.)
|
||||||
// Virtuoso instance below — Virtuoso applies that index synchronously
|
|
||||||
// before its first paint, so re-entering a chat shows the saved row in
|
|
||||||
// one frame rather than a "starts at top, jumps" flicker.
|
|
||||||
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
if (savedPositionRef.current === null && id) {
|
|
||||||
savedPositionRef.current = scrollPositions.get(id) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track whether the user is currently scrolled to the bottom. Virtuoso
|
// Track whether the user is currently scrolled to the bottom. Virtuoso
|
||||||
// calls this whenever the bottom-state changes; we feed it into
|
// calls this whenever the bottom-state changes; we feed it into
|
||||||
@@ -732,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();
|
||||||
@@ -852,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));
|
||||||
}
|
}
|
||||||
@@ -1191,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1236,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"
|
||||||
@@ -1330,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) => {
|
||||||
@@ -1480,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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1840,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(() => {
|
||||||
@@ -1879,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}
|
||||||
@@ -1891,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Phase 7 — Composer Redesign (Hybrid)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Reduce composer toolbar from 9 cluttered icons to 5 hierarchically-organized buttons. Move "creative activities" (Whiteboard, Watch-Together, Mini-Games) into a `+` popover. Move View-Once from global composer toggle to per-attachment flag in the upload preview.
|
||||||
|
|
||||||
|
**Rollback anchor:** tag `pre-phase7-composer` (set in T1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Rollback anchor
|
||||||
|
|
||||||
|
- [ ] Run:
|
||||||
|
```bash
|
||||||
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||||
|
git tag -a pre-phase7-composer -m "Rollback anchor before Phase 7 composer redesign"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `<ComposerActionsMenu>` popover component
|
||||||
|
|
||||||
|
**Files:** Create `apps/desktop/src/components/ComposerActionsMenu.tsx`
|
||||||
|
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface Props {
|
||||||
|
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAttachFile: () => void;
|
||||||
|
onCreatePoll: () => void;
|
||||||
|
onCreateWhiteboard: () => void;
|
||||||
|
onStartWatchTogether: () => void;
|
||||||
|
onStartGame: () => void;
|
||||||
|
canStartGame?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Layout (floating panel anchored above `anchorRef`):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 📎 Bild / Datei │
|
||||||
|
│ 📊 Umfrage │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ AKTIVITÄTEN │
|
||||||
|
│ ✏ Whiteboard │
|
||||||
|
│ 📺 Watch Together │
|
||||||
|
│ 🎮 Spiel starten │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use existing icons from `apps/desktop/src/components/icons.tsx` (grep for `PaperclipIcon`/`PlusIcon`, `PollIcon`, `MonitorShareIcon`, `PlayBoxIcon`, `GameIcon`).
|
||||||
|
- Click outside or `Esc` → `onClose`.
|
||||||
|
- Disabled items: `opacity-50 cursor-not-allowed` + `title` hint (e.g. "Spiele nur in 1:1-Chats").
|
||||||
|
- Each row ≥ 44px tall, `role="menu"`/`role="menuitem"`, arrow-up/down keyboard nav.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Refactor ConversationPage composer
|
||||||
|
|
||||||
|
**Files:** Modify `apps/desktop/src/pages/ConversationPage.tsx`
|
||||||
|
|
||||||
|
**Target layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ [+] [😊] [GIF] [🎤] Nachricht schreiben… [→] │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. **Remove** inline buttons for: file-attach, poll, whiteboard, watch-together, game-picker.
|
||||||
|
2. **Add** a `+` button at position 1 with a `useRef` anchor.
|
||||||
|
3. **State:** `const [menuOpen, setMenuOpen] = useState(false);` + render `<ComposerActionsMenu>` with the existing handlers wired (`handleCreateWhiteboard`, `handleStartWatchTogether`, `handleStartGame`, `() => setPollDialogOpen(true)`, `() => fileInputRef.current?.click()`).
|
||||||
|
4. **Remove** the standalone View-Once toggle button (moves to T4 per-attachment).
|
||||||
|
5. **Keep inline:** Emoji picker, GIF picker, voice mic, send arrow.
|
||||||
|
6. **Auto-close menu** after any item action.
|
||||||
|
7. Pass `canStartGame={conversation?.members?.length === 2}` so the dropdown reflects the DM-only constraint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: View-Once per-attachment in `AttachmentPreview`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify `apps/desktop/src/pages/ConversationPage.tsx` (`AttachmentPreview` component + the `attachments[]` state shape).
|
||||||
|
- Modify `apps/desktop/src/hooks/useConversationMessages.ts` (`send()` signature + per-attachment handling).
|
||||||
|
- Possibly extend the per-attachment encrypt/upload helper if it still treats `viewOnce` as a per-message flag.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
Add a third hover-button on each image preview next to `✏` and `✕`: a `👁` icon that toggles `viewOnce` per attachment.
|
||||||
|
- Active: icon switches (e.g. crossed-eye) + small `1×` badge in lower-right corner of the thumb.
|
||||||
|
- Image-only (`file.type.startsWith('image/')`). Hidden on non-image previews.
|
||||||
|
|
||||||
|
**State refactor:**
|
||||||
|
|
||||||
|
Change `attachments: File[]` → `attachments: Array<{ file: File; viewOnce: boolean }>`. Every consumer site updated:
|
||||||
|
- `setAttachments((prev) => [...prev, ...newOnes.map((f) => ({ file: f, viewOnce: false }))])`
|
||||||
|
- `attachments.map((a, idx) => <AttachmentPreview file={a.file} ... onToggleViewOnce={() => setAttachments(prev => prev.map((x, i) => i === idx ? { ...x, viewOnce: !x.viewOnce } : x))} />)`
|
||||||
|
- `setAttachments((prev) => prev.filter((_, i) => i !== idx))` — unchanged shape
|
||||||
|
|
||||||
|
**Send path:**
|
||||||
|
|
||||||
|
The `send()` currently accepts a `viewOnce` option that applies globally. Refactor so the per-attachment flag flows through:
|
||||||
|
- Either change `send(payload, attachments, replyTo, { viewOnce })` → `send(payload, attachmentsWithFlags, replyTo)` where each entry carries its own `viewOnce`
|
||||||
|
- OR pass a parallel `viewOnceFlags: boolean[]` array aligned with attachments
|
||||||
|
|
||||||
|
The encrypt/upload helper already supports per-attachment `view_once` (P2.T14 column `message_attachments.view_once`). The renderer just needs to pass the right flag per row.
|
||||||
|
|
||||||
|
**Grep first** to find the existing wiring: `Grep -rn "view_once\|viewOnce" apps/desktop/src/ packages/shared/src/chat/` — adapt to what's actually there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Cleanup + Final gate
|
||||||
|
|
||||||
|
- [ ] `pnpm --filter @chat-app/desktop typecheck` — green
|
||||||
|
- [ ] `pnpm --filter @chat-app/shared test -- --run` — green (71 tests)
|
||||||
|
- [ ] `pnpm --filter @chat-app/desktop test -- --run` — green
|
||||||
|
- [ ] `git status` — clean
|
||||||
|
- [ ] Tag `phase7-done`
|
||||||
|
- [ ] Report smoke-test points:
|
||||||
|
1. Composer shows 5 inline buttons (was 9)
|
||||||
|
2. Click `+` → popover opens; click anywhere outside or `Esc` closes it
|
||||||
|
3. Attach image → preview shows ✏/👁/✕ on hover
|
||||||
|
4. Toggle 👁 on attachment-1 only → recipient sees attachment-1 as view-once, attachment-2 normally
|
||||||
|
5. Everything else unchanged (emoji, GIF, voice, send, edit-message, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No emoji-as-icon (uses existing SVG icons).
|
||||||
|
- No slash-commands (deferred to potential Phase 7B).
|
||||||
|
- No reordering of inline buttons beyond the spec.
|
||||||
|
- No per-attachment poll-attach (polls remain message-level).
|
||||||
Reference in New Issue
Block a user