Compare commits

...

11 Commits

Author SHA1 Message Date
byGalax 7f704e80f6 feat(P7.T4): per-attachment view-once toggle on AttachmentPreview 2026-05-17 01:25:08 +02:00
byGalax 940432d287 feat(P7.T3): composer toolbar — 5 inline buttons + popover for Bild/Poll/Aktivitäten 2026-05-17 01:18:32 +02:00
byGalax 28b6d64936 feat(P7.T2): ComposerActionsMenu popover (Bild/Datei + Umfrage + Aktivitäten group)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:14:34 +02:00
byGalax 92baa626d6 docs(P7): Phase 7 composer redesign plan (Hybrid: + menu + per-attachment view-once) 2026-05-17 01:11:04 +02:00
byGalax cd7ef8dccc fix(P6): AttachmentImage Lightbox blob URL — split unmount-only revoke, lock deps to handle.id 2026-05-17 01:09:09 +02:00
byGalax 58efc66ca7 fix(P4A): guard ImageAnnotator load callbacks with cancelled flag (React strict double-mount) 2026-05-17 01:03:37 +02:00
byGalax 1e139fb86e fix(P4A): ImageAnnotator effect-race — stabilize onCancel via ref so URL.revoke doesn't fire mid-decode 2026-05-17 01:01:27 +02:00
byGalax eeb713f03d fix(P6): convert positional bindings to named-params object for better-sqlite3 2026-05-17 00:59:39 +02:00
byGalax 837b5a326e fix(P6): hoist savedPositionRef above initialTopMostIndex (TDZ) + auto-open DevTools in dev 2026-05-17 00:56:34 +02:00
byGalax b1f37752d6 perf(P6C.T12): optimistic UI for mute / mentions-only / archive / pin / device-revoke
Audit of write-actions revealed that send (and edit via realtime UPDATE) are
already optimistic via local state insertion in `useConversationMessages`,
and friend nicknames are pure-local localStorage. Five user-write actions
were waiting on the ~100-200 ms server roundtrip + realtime echo:

* Toggle mute (`setConversationMutedUntil`)
* Toggle mentions-only (`setConversationMentionsOnly`)
* Toggle archive (`setConversationArchived`)
* Pin / unpin message (`pinMessage` / `unpinMessage`)
* Revoke device (`revokeDevice` RPC)

All five now flip local state synchronously and roll back on failure. The
existing realtime subscriptions reconcile canonically (no-op when the
optimistic patch already matches the server row), so this is purely a UX
latency improvement — no protocol or persistence changes.

Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped
this round: rollback semantics for the exclusive-vote path with multiple
sequential awaits are messy enough to warrant a dedicated pass.
2026-05-17 00:44:28 +02:00
byGalax 854c4b91a8 perf(P6C.T11): bundle audit + opt-in visualizer for future audits
Audited the renderer bundle with rollup-plugin-visualizer. Top offenders
(libsodium-sumo 292KB gz, livekit-client 177KB gz, @supabase 149KB gz)
all have justified usage and no viable swap. Mediapipe + track-processors
are already lazy-loaded into a separate chunk on first BackgroundBlur
activation. Bundle has zero duplicate packages, no moment/lodash/dayjs,
no syntax-highlight libs, no polyfills — already lean from T1+T6.

Added rollup-plugin-visualizer as a dev-dep, gated behind ANALYZE=true
so production builds pay no cost. Run with:
  ANALYZE=true pnpm --filter @chat-app/desktop build
to regenerate stats.html (treemap) + stats.json (raw) for future audits.
2026-05-17 00:35:41 +02:00
17 changed files with 985 additions and 148 deletions
+4
View File
@@ -33,6 +33,10 @@ web-build/
apps/desktop/out/
apps/desktop/release/
# Bundle visualizer reports
apps/desktop/stats.html
apps/desktop/stats.json
# Logs
*.log
npm-debug.log*
+27 -1
View File
@@ -10,6 +10,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
const ANALYZE = process.env.ANALYZE === 'true';
const rendererAliases = {
'@': path.resolve(__dirname, './src'),
@@ -44,7 +47,30 @@ export default defineConfig({
// bundle. Relative base produces `./assets/...` which works in both
// dev (served from /) and packaged builds.
base: './',
plugins: [react()],
// Visualizer plugins are gated behind ANALYZE=true so the production
// build never pays the analysis cost. Re-enable with:
// ANALYZE=true pnpm --filter @chat-app/desktop build
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
plugins: [
react(),
...(ANALYZE
? [
visualizer({
filename: 'stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
open: false,
}),
visualizer({
filename: 'stats.json',
template: 'raw-data',
gzipSize: true,
brotliSize: true,
}),
]
: []),
],
resolve: {
alias: rendererAliases,
},
+11
View File
@@ -147,6 +147,17 @@ async function createWindow(): Promise<BrowserWindow> {
attachWindowState(win, WINDOW_STATE_FILE);
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);
} else {
await win.loadFile(resolveRendererIndex());
+25 -4
View File
@@ -6,8 +6,10 @@
// (<1ms per op for the current workload).
//
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
// bindings as an array. SQLite natively accepts $N so existing queries
// keep working unmodified.
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
// (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 Database from 'better-sqlite3';
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
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 {
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
const rawName = stripPrefix(args.name);
@@ -59,7 +75,8 @@ export function register(): void {
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
const entry = requireHandle(args.handle);
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 {
rowsAffected: info.changes,
lastInsertId:
@@ -75,7 +92,11 @@ export function register(): void {
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
const entry = requireHandle(args.handle);
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;
},
);
+1
View File
@@ -52,6 +52,7 @@
"electron-vite": "^2.3.0",
"postcss": "^8.4.49",
"rimraf": "^6.0.0",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.15",
"vite": "^5.4.11"
},
@@ -3,7 +3,7 @@ import {
downloadAndDecryptAttachment,
downloadAndDecryptAttachmentThumb,
} from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
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
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
// 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(() => {
if (!lightboxOpen) return;
if (fullUrl) return;
let cancelled = false;
const created: string[] = [];
void (async () => {
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
@@ -187,14 +197,27 @@ export function AttachmentImage({ handle, mine = false }: Props) {
}
if (cancelled) return;
const u = URL.createObjectURL(blob);
created.push(u);
setFullUrl(u);
})();
return () => {
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;
@@ -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>
);
}
@@ -8,6 +8,7 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase';
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
@@ -54,6 +55,7 @@ interface MenuPos {
// under the trigger so it doesn't push off-screen on narrow windows.
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
const { t } = useTranslation(['app']);
const { patchConversation } = useConversationsContext();
const [open, setOpen] = useState(false);
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
@@ -122,44 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil, ment
const isMuted =
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
// Optimistic updates: flip the local state before the server RPC so the
// bell / archive icon / checkmark update on the same frame as the click.
// Realtime echo via ConversationsContext will reconcile (no-op since the
// optimistic patch already matches the server row). On error we restore
// the previous value so the menu doesn't lie about persisted state.
const handleArchive = useCallback(
async (next: boolean) => {
setOpen(false);
const previous = archived;
patchConversation(conversationId, { archived: next });
try {
await setConversationArchived(supabase, conversationId, next);
} catch (err: unknown) {
patchConversation(conversationId, { archived: previous });
console.error('archive toggle failed', err);
}
},
[conversationId],
[conversationId, archived, patchConversation],
);
const handleMute = useCallback(
async (minutes: number | null) => {
setOpen(false);
setSubmenuOpen(null);
const previous = mutedUntil;
const nextIso = muteDurationToIso(minutes);
patchConversation(conversationId, { mutedUntil: nextIso });
try {
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
await setConversationMutedUntil(supabase, conversationId, nextIso);
} catch (err: unknown) {
patchConversation(conversationId, { mutedUntil: previous });
console.error('mute toggle failed', err);
}
},
[conversationId],
[conversationId, mutedUntil, patchConversation],
);
const handleMentionsOnly = useCallback(
async (next: boolean) => {
setOpen(false);
const previous = mentionsOnly;
patchConversation(conversationId, { mentionsOnly: next });
try {
await setConversationMentionsOnly(supabase, {
conversationId,
mentionsOnly: next,
});
} catch (err: unknown) {
patchConversation(conversationId, { mentionsOnly: previous });
console.warn('mentions-only toggle failed', err);
}
},
[conversationId],
[conversationId, mentionsOnly, patchConversation],
);
return (
+25 -3
View File
@@ -41,20 +41,42 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
const draftRef = useRef<AnnotatorOp | null>(null);
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(() => {
// 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 img = new Image();
img.onload = () => {
if (cancelled) return;
imageRef.current = img;
setImageLoaded(true);
};
img.onerror = () => {
if (cancelled) return;
console.error('ImageAnnotator: failed to decode source image');
onCancel();
onCancelRef.current();
};
img.src = url;
return () => URL.revokeObjectURL(url);
}, [file, onCancel]);
return () => {
cancelled = true;
URL.revokeObjectURL(url);
};
}, [file]);
useEffect(() => {
if (!imageLoaded) return;
+10
View File
@@ -150,6 +150,16 @@ function EyeOffIconInner(props: IconProps) {
}
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) {
return (
<Base {...props}>
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
refresh: () => Promise<void>;
markRead: (conversationId: string) => void;
setActiveConversation: (conversationId: string | null) => void;
// Optimistic patch for the caller's per-membership preferences (mute /
// mentions-only / archive). Mutations to `conversation_members` echo back
// via the realtime channel and `refresh()` reconciles canonically, but the
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
// immediately, snapshot the previous state, and roll back on failure.
patchConversation: (
conversationId: string,
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
) => void;
}
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
[markRead],
);
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
(convId, patch) => {
setConversations((prev) => {
const idx = prev.findIndex((c) => c.id === convId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = { ...next[idx]!, ...patch };
return next;
});
},
[],
);
useEffect(() => {
if (!myId) {
setConversations([]);
@@ -308,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
refresh,
markRead,
setActiveConversation,
patchConversation,
}),
[
conversations,
@@ -318,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
refresh,
markRead,
setActiveConversation,
patchConversation,
],
);
+29 -2
View File
@@ -64,10 +64,37 @@ export function useOwnDevices(): {
const revoke = useCallback(
async (deviceId: string) => {
// Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge
// appears on the same frame as the click. Capture a snapshot so we
// can restore exactly on RPC failure (the realtime subscription's
// own UPDATE echo would otherwise reconcile back to "not revoked"
// anyway). Skip if the row isn't in our list — nothing to undo.
let snapshot: DeviceRecord[] | null = null;
const stampedAt = new Date().toISOString();
setState((prev) => {
if (!prev.devices.some((d) => d.id === deviceId)) return prev;
snapshot = prev.devices;
return {
...prev,
devices: prev.devices.map((d) =>
d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d,
),
};
});
try {
await revokeDevice(supabase, deviceId);
await refresh();
// Skip the eager refresh: the realtime UPDATE on `devices` triggers
// refresh() via the subscription and the optimistic row already
// shows the badge. Avoids a list flicker between optimistic and
// canonical state.
} catch (err) {
if (snapshot) {
setState((prev) => ({ ...prev, devices: snapshot! }));
}
throw err;
}
},
[refresh],
[],
);
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
@@ -75,7 +75,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
text: string,
images?: File[],
replyToId?: string | null,
opts?: { viewOnce?: boolean },
opts?: { viewOnceFlags?: boolean[] },
) => Promise<void>;
refresh: () => Promise<void>;
pending: OutboxItem[];
@@ -569,7 +569,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
text: string,
images: File[] = [],
replyToId: string | null = null,
opts: { viewOnce?: boolean } = {},
opts: { viewOnceFlags?: boolean[] } = {},
) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
@@ -626,9 +626,13 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
// (so the public attachment row can reference the blob-level nonce).
// P7.T4: view-once is now a per-attachment flag rather than a
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
// missing entries (or whole-array absence) default to false.
const handles: AttachmentHandle[] = [];
const blobNonceHexByHandleId = new Map<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) {
throw new Error('attachment exceeds max size (10 MB)');
}
@@ -649,12 +653,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(dims.height !== undefined ? { height: dims.height } : {}),
...(thumbBlob ? { thumbBlob } : {}),
});
// Stamp the view-once flag on each handle the caller requested it
// for. The flag rides inside the encrypted payload (so peers can
// render the locked card without leaking who-sent-what to the
// server) AND lands on the public message_attachments row via
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
if (opts.viewOnce) {
// Stamp the view-once flag on each handle the caller flagged. The
// flag rides inside the encrypted payload (so peers can render the
// locked card without leaking who-sent-what to the server) AND
// lands on the public message_attachments row via insertAttachmentRow
// below (where the mark-viewed RPC enforces it).
if (opts.viewOnceFlags?.[i]) {
res.handle.viewOnce = true;
}
handles.push(res.handle);
+55 -4
View File
@@ -1,12 +1,26 @@
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { supabase } from './supabase';
export interface UsePinnedMessagesResult {
pins: PinnedMessage[];
// Optimistic insert. Caller flips the UI immediately; server insert +
// realtime echo will reconcile (dedup'd by messageId). Returns the
// previous snapshot so the caller can roll back on error.
applyOptimisticPin: (messageId: string, pinnedBy: string) => PinnedMessage[];
applyOptimisticUnpin: (messageId: string) => PinnedMessage[];
// Hard restore for rollback after a failed server call.
restorePins: (snapshot: PinnedMessage[]) => void;
}
// Live list of pinned messages for one conversation. Subscribes to the
// `pinned_messages` realtime channel for the conv so the header pill +
// side-panel update without a refetch.
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
// side-panel update without a refetch. The `applyOptimistic*` helpers let
// callers flip local state synchronously on user action so the pin button
// doesn't appear unresponsive while the ~100-200ms server roundtrip + the
// realtime refetch round complete.
export function usePinnedMessages(conversationId: string | undefined): UsePinnedMessagesResult {
const [pins, setPins] = useState<PinnedMessage[]>([]);
useEffect(() => {
@@ -44,5 +58,42 @@ export function usePinnedMessages(conversationId: string | undefined): PinnedMes
};
}, [conversationId]);
return pins;
const applyOptimisticPin = useCallback<UsePinnedMessagesResult['applyOptimisticPin']>(
(messageId, pinnedBy) => {
if (!conversationId) return pins;
let snapshot: PinnedMessage[] = pins;
setPins((prev) => {
snapshot = prev;
if (prev.some((p) => p.messageId === messageId)) return prev;
const optimistic: PinnedMessage = {
conversationId,
messageId,
pinnedBy,
pinnedAt: new Date().toISOString(),
};
// Newest first matches the listPinnedMessages order.
return [optimistic, ...prev];
});
return snapshot;
},
[conversationId, pins],
);
const applyOptimisticUnpin = useCallback<UsePinnedMessagesResult['applyOptimisticUnpin']>(
(messageId) => {
let snapshot: PinnedMessage[] = pins;
setPins((prev) => {
snapshot = prev;
return prev.filter((p) => p.messageId !== messageId);
});
return snapshot;
},
[pins],
);
const restorePins = useCallback<UsePinnedMessagesResult['restorePins']>((snapshot) => {
setPins(snapshot);
}, []);
return { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins };
}
+139 -115
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState';
@@ -16,10 +17,10 @@ import {
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
EyeIcon,
EyeOffIcon,
PencilIcon,
PlusIcon,
PollIcon,
ReplyIcon,
SearchIcon,
SendIcon,
@@ -108,6 +109,15 @@ const EMPTY_REACTIONS: AggregatedReaction[] = [];
// reading position even if some rows above re-render at different heights.
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() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
@@ -194,7 +204,11 @@ export function ConversationPage() {
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
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 [infoPanelOpen, setInfoPanelOpen] = useState(false);
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
@@ -237,28 +251,36 @@ export function ConversationPage() {
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [gifPickerOpen, setGifPickerOpen] = useState(false);
// Sticky toggle: when on, the next image(s) sent are marked view-once.
// Auto-clears on a successful send so the composer doesn't accidentally
// burn the message-after-next.
const [viewOnceNext, setViewOnceNext] = useState(false);
const pins = usePinnedMessages(id);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
// Optimistic pin/unpin: flip the local list synchronously so the pin badge
// / panel updates on the same frame as the click. Realtime echo via
// usePinnedMessages will refetch and reconcile (no-op since the optimistic
// row matches the server). On error we restore the snapshot so the badge
// doesn't lie about persisted state.
const handleTogglePin = useCallback(
async (messageId: string) => {
if (!id || !myId) return;
const wasPinned = pinnedIds.has(messageId);
const snapshot = wasPinned
? applyOptimisticUnpin(messageId)
: applyOptimisticPin(messageId, myId);
try {
if (pinnedIds.has(messageId)) {
if (wasPinned) {
await unpinMessage(supabase, id, messageId);
} else {
await pinMessage(supabase, id, messageId, myId);
}
} catch (err) {
restorePins(snapshot);
console.warn('pin toggle failed', err);
}
},
[id, myId, pinnedIds],
[id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins],
);
const handleGifPick = useCallback(
@@ -424,6 +446,22 @@ export function ConversationPage() {
return out;
}, [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.
// 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
@@ -638,17 +676,8 @@ export function ConversationPage() {
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
// longer needed: Virtuoso owns scroll positioning now.
// 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.
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
null,
);
if (savedPositionRef.current === null && id) {
savedPositionRef.current = scrollPositions.get(id) ?? null;
}
// (savedPositionRef declared earlier — see TDZ note above the
// initialTopMostIndex useMemo.)
// Track whether the user is currently scrolled to the bottom. Virtuoso
// calls this whenever the bottom-state changes; we feed it into
@@ -722,13 +751,15 @@ export function ConversationPage() {
setSending(true);
setSendError(null);
try {
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
await send(
text,
attachments.map((a) => a.file),
replyTo?.id ?? null,
{ viewOnceFlags: attachments.map((a) => a.viewOnce) },
);
setText('');
setAttachments([]);
setReplyTo(null);
// Reset the sticky view-once flag so it only applies to the message
// the user explicitly armed it for — Snapchat / WhatsApp parity.
setViewOnceNext(false);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
@@ -842,13 +873,15 @@ export function ConversationPage() {
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
const next: File[] = [];
const next: PendingAttachment[] = [];
for (const f of compressed) {
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
}
next.push(f);
// New attachments default to viewOnce=false; user opts in per-thumb
// via the eye-toggle button on the preview (P7.T4).
next.push({ file: f, viewOnce: false });
}
setAttachments((prev) => [...prev, ...next].slice(0, 4));
}
@@ -1181,13 +1214,22 @@ export function ConversationPage() {
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, idx) => (
{attachments.map((a, idx) => (
<AttachmentPreview
key={idx}
file={file}
file={a.file}
viewOnce={a.viewOnce}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
{...(file.type.startsWith('image/')
? { onEdit: () => setAnnotatingIndex(idx) }
{...(a.file.type.startsWith('image/')
? {
onEdit: () => setAnnotatingIndex(idx),
onToggleViewOnce: () =>
setAttachments((prev) =>
prev.map((x, i) =>
i === idx ? { ...x, viewOnce: !x.viewOnce } : x,
),
),
}
: {})}
/>
))}
@@ -1226,55 +1268,32 @@ export function ConversationPage() {
className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)}
/>
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
<button
ref={actionsMenuAnchorRef}
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Datei anhängen"
title="Datei anhängen"
onClick={() => setActionsMenuOpen((v) => !v)}
aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
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]"
>
<PlusIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
<ComposerActionsMenu
anchorRef={actionsMenuAnchorRef}
open={actionsMenuOpen}
onClose={() => setActionsMenuOpen(false)}
onAttachFile={() => fileInputRef.current?.click()}
onCreatePoll={() => {
setPollError(null);
setPollDialogOpen(true);
}}
aria-label="Umfrage erstellen"
title="Umfrage erstellen"
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]"
>
<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>
onCreateWhiteboard={() => void handleCreateWhiteboard()}
onStartWatchTogether={() => setWatchDialogOpen(true)}
onStartGame={() => setGameDialogOpen(true)}
canStartGame={conversation?.members?.length === 2}
/>
<div className="relative">
<button
type="button"
@@ -1320,20 +1339,6 @@ export function ConversationPage() {
onPick={(gif) => void handleGifPick(gif)}
/>
</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
disabled={sending}
onComplete={async (file) => {
@@ -1470,10 +1475,17 @@ export function ConversationPage() {
{annotatingIndex !== null && attachments[annotatingIndex] && (
<Suspense fallback={null}>
<ImageAnnotator
file={attachments[annotatingIndex]!}
file={attachments[annotatingIndex]!.file}
onCancel={() => setAnnotatingIndex(null)}
onSave={(next) => {
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
// Preserve the per-attachment viewOnce flag across annotation —
// the user's burn-after-viewing intent shouldn't reset just
// because they redrew the image.
setAttachments((prev) =>
prev.map((a, i) =>
i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a,
),
);
setAnnotatingIndex(null);
}}
/>
@@ -1830,13 +1842,18 @@ function Banner({ children }: { children: React.ReactNode }) {
function AttachmentPreview({
file,
viewOnce,
onRemove,
onEdit,
onToggleViewOnce,
}: {
file: File;
viewOnce: boolean;
onRemove: () => void;
onEdit?: () => void;
onToggleViewOnce?: () => void;
}) {
const { t } = useTranslation(['app']);
const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
@@ -1869,6 +1886,42 @@ function AttachmentPreview({
<PencilIcon className="h-3 w-3" />
</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
type="button"
onClick={onRemove}
@@ -1881,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).
+184
View File
@@ -144,6 +144,9 @@ importers:
rimraf:
specifier: ^6.0.0
version: 6.1.3
rollup-plugin-visualizer:
specifier: ^7.0.1
version: 7.0.1(rollup@4.60.1)
tailwindcss:
specifier: ^3.4.15
version: 3.4.19
@@ -2615,6 +2618,10 @@ packages:
builder-util@25.1.7:
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -2760,6 +2767,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
clone-deep@4.0.1:
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
engines: {node: '>=6'}
@@ -2991,6 +3002,14 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.5.0:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
default-gateway@4.2.0:
resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
engines: {node: '>=6'}
@@ -3010,6 +3029,10 @@ packages:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@@ -3131,6 +3154,9 @@ packages:
engines: {node: '>= 12.20.55'}
hasBin: true
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -3722,6 +3748,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -4054,6 +4084,11 @@ packages:
engines: {node: '>=8'}
hasBin: true
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -4074,6 +4109,15 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
@@ -4172,6 +4216,10 @@ packages:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -4965,6 +5013,10 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
open@7.4.2:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
@@ -5190,6 +5242,10 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -5586,11 +5642,28 @@ packages:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'}
rollup-plugin-visualizer@7.0.1:
resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
engines: {node: '>=22'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -5826,6 +5899,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
@@ -5893,6 +5970,10 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string.prototype.trim@1.2.10:
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
engines: {node: '>= 0.4'}
@@ -6479,6 +6560,10 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -6524,6 +6609,10 @@ packages:
utf-8-validate:
optional: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xcode@3.0.1:
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
engines: {node: '>=10.0.0'}
@@ -6565,10 +6654,18 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
@@ -9566,6 +9663,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -9750,6 +9851,12 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
clone-deep@4.0.1:
dependencies:
is-plain-object: 2.0.4
@@ -9969,6 +10076,13 @@ snapshots:
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
default-browser@5.5.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
default-gateway@4.2.0:
dependencies:
execa: 1.0.0
@@ -9988,6 +10102,8 @@ snapshots:
define-lazy-prop@2.0.0: {}
define-lazy-prop@3.0.0: {}
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
@@ -10161,6 +10277,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -10945,6 +11063,8 @@ snapshots:
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -11314,6 +11434,8 @@ snapshots:
is-docker@2.2.1: {}
is-docker@3.0.0: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -11334,6 +11456,12 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-interactive@1.0.0: {}
is-lambda@1.0.1: {}
@@ -11415,6 +11543,10 @@ snapshots:
dependencies:
is-docker: 2.2.1
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isarray@1.0.0: {}
isarray@2.0.5: {}
@@ -12347,6 +12479,15 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
open@11.0.0:
dependencies:
default-browser: 5.5.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.1
open@7.4.2:
dependencies:
is-docker: 2.2.1
@@ -12548,6 +12689,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
@@ -13018,6 +13161,15 @@ snapshots:
sprintf-js: 1.1.3
optional: true
rollup-plugin-visualizer@7.0.1(rollup@4.60.1):
dependencies:
open: 11.0.0
picomatch: 4.0.4
source-map: 0.7.6
yargs: 18.0.0
optionalDependencies:
rollup: 4.60.1
rollup@4.60.1:
dependencies:
'@types/estree': 1.0.8
@@ -13049,6 +13201,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
run-applescript@7.1.0: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -13308,6 +13462,8 @@ snapshots:
source-map@0.6.1: {}
source-map@0.7.6: {}
split-on-first@1.1.0: {}
sprintf-js@1.0.3: {}
@@ -13364,6 +13520,12 @@ snapshots:
emoji-regex: 9.2.2
strip-ansi: 7.2.0
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
string.prototype.trim@1.2.10:
dependencies:
call-bind: 1.0.9
@@ -14007,6 +14169,12 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
wrappy@1.0.2: {}
write-file-atomic@2.4.3:
@@ -14028,6 +14196,11 @@ snapshots:
ws@8.20.0: {}
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xcode@3.0.1:
dependencies:
simple-plist: 1.3.1
@@ -14056,6 +14229,8 @@ snapshots:
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
@@ -14066,6 +14241,15 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yargs@18.0.0:
dependencies:
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
yauzl@2.10.0:
dependencies:
buffer-crc32: 0.2.13