# Phase 1 — Quality & Fixes Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Land five quality fixes — make hotkeys window-scoped by default, verify the tray badge actually works, give empty pages a call-to-action, let users rename friends locally, and wipe local crypto/cache on sign-out (and optionally on app-close). **Architecture:** Five independent feature slices, each touching a small set of files. No DB changes. No release. All commits on `main`. Phase ends with `pnpm --filter @chat-app/shared typecheck` + `pnpm --filter @chat-app/desktop typecheck` + `pnpm --filter @chat-app/shared test` all green. **Tech Stack:** TypeScript, React 18, Vite, Vitest, Electron 33, Tailwind, react-i18next. **Spec:** `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` (Phase 1 section) --- ## File Overview **Modified (existing):** - `apps/desktop/src/lib/voiceHotkeys.ts` — add `global: boolean` field to `VoiceHotkeyBinding` - `apps/desktop/src/context/CallContext.tsx` — only register Electron global shortcut when `global === true` - `apps/desktop/src/pages/SettingsPage.tsx` — VoiceHotkeyControls: add 🌐-Toggle UI; SecurityCenter: add Memory-Wipe Setting - `apps/desktop/src/context/AuthContext.tsx` — `signOut` calls new `wipeLocalState` - `apps/desktop/electron/main.ts` — `before-quit` IPC if memory-wipe-on-close enabled - `apps/desktop/electron/ipc-types.ts` — new IPC channel `APP_WIPE_BEFORE_QUIT` - `apps/desktop/electron/preload.ts` + `preload-types.d.ts` — expose new IPC - `apps/desktop/src/pages/ChatsPage.tsx` — empty-state for empty chat list - `apps/desktop/src/pages/FriendsPage.tsx` — empty-state for no friends + right-click "Spitzname setzen" - `apps/desktop/src/pages/ConversationPage.tsx` — empty-state for empty conv + use nickname - `apps/desktop/src/components/MessageBubble.tsx` — use nickname when rendering peer name - `apps/desktop/src/components/MentionAutocomplete.tsx` — show nickname in suggestion list - `apps/desktop/src/components/ConversationHeader.tsx` — use nickname for peer - `apps/desktop/src/components/CallParticipantTile.tsx` — use nickname **New:** - `apps/desktop/src/lib/friendNicknames.ts` — localStorage-backed nickname store with `useNickname(userId)` hook - `apps/desktop/src/lib/memoryWipe.ts` — `wipeLocalState(userId)` function - `apps/desktop/src/lib/memoryWipeSettings.ts` — persisted toggle for wipe-on-close - `apps/desktop/src/components/EmptyState.tsx` — reusable empty-state primitive - `apps/desktop/src/components/NicknameDialog.tsx` — set/clear nickname modal --- ## Task 1: Hotkey — add `global` flag to binding shape **Files:** - Modify: `apps/desktop/src/lib/voiceHotkeys.ts` - [ ] **Step 1: Add field to interface + defaults** In `voiceHotkeys.ts`, change the `VoiceHotkeyBinding` interface and `DEFAULTS`: ```ts export interface VoiceHotkeyBinding { /** KeyboardEvent.code of the base key. */ key: string; keyLabel: string; ctrl: boolean; shift: boolean; alt: boolean; enabled: boolean; /** * When true, the hotkey is registered as an OS-level shortcut and fires * even when the app isn't focused. When false (default) the binding only * fires from the window's keydown listener — so e.g. setting "M" as mute * doesn't break typing "m" everywhere else on the system. */ global: boolean; } ``` Update `DEFAULTS` so every binding includes `global: false`: ```ts const DEFAULTS: VoiceHotkeys = { mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false }, deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false }, hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false }, screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false }, video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: false }, }; ``` Update `validateBinding` to carry `global` through with a `false` fallback: ```ts function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotkeyBinding { if (!raw || typeof raw !== 'object') return fallback; const b = raw as Partial; return { key: typeof b.key === 'string' && b.key ? b.key : fallback.key, keyLabel: typeof b.keyLabel === 'string' && b.keyLabel ? b.keyLabel : fallback.keyLabel, ctrl: typeof b.ctrl === 'boolean' ? b.ctrl : fallback.ctrl, shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift, alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt, enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled, global: typeof b.global === 'boolean' ? b.global : fallback.global, }; } ``` - [ ] **Step 2: Typecheck** Run: `pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10` Expected: errors will pop in `CallContext.tsx` because `global` isn't read yet — that's the NEXT task. - [ ] **Step 3: Commit** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app git add apps/desktop/src/lib/voiceHotkeys.ts git commit -m "feat(desktop): add 'global' flag to voice hotkey binding (default false)" ``` --- ## Task 2: Hotkey — CallContext only registers global shortcut when opted in **Files:** - Modify: `apps/desktop/src/context/CallContext.tsx` (~line 1957–2049) - [ ] **Step 1: Gate `syncGlobalShortcuts` on the `global` flag** Find the `syncGlobalShortcuts` block. Replace the `desired` map so only bindings with `enabled && global` are passed to Electron: ```ts const syncGlobalShortcuts = () => { if (!isTauriRuntime()) return; // Only register an OS-level shortcut if the user explicitly opted in. // Window-scoped firing happens via the `onKey` listener above and works // for every enabled binding regardless of the `global` flag. const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global; const desired: Record = { mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null, deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null, hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null, screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null, video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null, }; for (const kind of KINDS) { const want = desired[kind]; const have = registered[kind]; if (want === have) continue; if (have) { void unregisterGlobalShortcut(have); registered = { ...registered, [kind]: null }; } if (want) { const thisKind = kind; void registerGlobalShortcutPress(want, () => fire(thisKind)); registered = { ...registered, [kind]: want }; } } }; ``` The window-level `onKey` listener at the top of the effect already handles in-focus firing for every enabled binding — that needs no change. Only the global-shortcut path is now gated. - [ ] **Step 2: Typecheck** Run: `pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5` Expected: zero errors. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/context/CallContext.tsx git commit -m "fix(desktop): voice hotkeys are window-scoped unless 'Global' is toggled The old code registered every enabled hotkey through Electron's globalShortcut API, which captures system-wide. Setting 'M' as mute meant 'm' couldn't be typed in any other app. Now the OS-level registration only happens when binding.global === true; otherwise the existing window-keydown listener handles it." ``` --- ## Task 3: Hotkey — Settings UI for the Global toggle **Files:** - Modify: `apps/desktop/src/pages/SettingsPage.tsx` — find the `VoiceHotkeyControls` function - [ ] **Step 1: Locate the existing `VoiceHotkeyControls`** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app grep -n "function VoiceHotkeyControls" apps/desktop/src/pages/SettingsPage.tsx ``` The function renders one row per kind (mute/deafen/hangup/screenShare/video) with: a label, an enable-toggle, and the key-capture button. - [ ] **Step 2: Add a "🌐 Global" toggle next to each capture button** Inside the row, after the existing capture button, add: ```tsx ``` Use whatever local variable currently holds the binding (likely `binding` or `s[kind]` — adapt accordingly). `kind` and `updateVoiceHotkey` are already in scope per the existing code. - [ ] **Step 3: Typecheck + build the UI once** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 ``` Expected: zero errors. - [ ] **Step 4: Commit** ```bash git add apps/desktop/src/pages/SettingsPage.tsx git commit -m "feat(desktop): Settings — 🌐 Global toggle per voice hotkey" ``` --- ## Task 4: Tray-Badge audit + fix **Files:** - Investigate: `apps/desktop/electron/modules/tray.ts`, `apps/desktop/src/lib/trayBadge.ts`, `apps/desktop/src/context/ConversationsContext.tsx:293` - Likely fix area: icon path resolution in `tray.ts:25–43` - [ ] **Step 1: Verify the IPC channel actually fires** Add temporary `console.info('[tray] push n=' + n)` in `trayBadge.ts:14`: ```ts export async function updateTrayUnread(count: number): Promise { if (!isTauriRuntime()) return; const n = Math.max(0, Math.floor(count)); console.info('[tray] push n=' + n); const badgeDataUrl = n > 0 ? renderBadgePng(n) : null; try { await window.electronAPI.setTrayUnread(n, badgeDataUrl); } catch (err: unknown) { console.warn('updateTrayUnread failed', err); } } ``` Add `console.info('[tray-main] received', n);` in `tray.ts` inside the IPC handler: ```ts ipcMain.handle( CHANNELS.TRAY_UNREAD, async (_evt, count: number, badgeDataUrl?: string | null): Promise => { const n = Math.max(0, Math.floor(Number(count) || 0)); console.info('[tray-main] received', n); // ... rest unchanged ``` Run `pnpm --filter @chat-app/desktop run dev`, sign in, have a peer send a message. Look at DevTools console (renderer log) AND at the terminal where dev runs (main log). - [ ] **Step 2: Diagnose from the logs** Three possible outcomes: | Symptom | Root cause | Fix in next step | |---------|------------|------------------| | Renderer logs `[tray] push n=1` but main never logs | IPC channel mismatch or preload bridge missing | Verify `CHANNELS.TRAY_UNREAD` is the same on both sides; check preload exposes `setTrayUnread` | | Both sides log but no badge appears | Icon path missing / overlay-icon API silently fails | Replace `loadTrayIcon` to use a definitely-existing 16×16 PNG | | Renderer never logs `[tray] push` | ConversationsContext isn't computing unread count | Inspect line 293 of ConversationsContext, the `totalUnread` value | - [ ] **Step 3: Apply the fix matching the diagnosed cause** If icon-path is the cause (most likely): replace `loadTrayIcon` in `tray.ts:33–43` to use an embedded PNG as a fallback (same approach as `buildOverlay`): ```ts function loadTrayIcon(): NativeImage { for (const p of [resolveIconPath(), resolveIconPathDev()]) { try { const img = nativeImage.createFromPath(p); if (!img.isEmpty()) return img; } catch { /* try next */ } } // Embedded 32×32 PNG fallback — Netralax 'N' on a brand background. // Same base64 trick as buildOverlay so the module is self-contained. const FALLBACK_B64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAANElEQVR42u3OQQ0AIAwEsFhBJlqx' + 'sDcGdjC0NRkBHrMrXLT2cqOhAQECCBAggAABAgQI8K9aAasACzGAtdgAAAAASUVORK5CYII='; return nativeImage.createFromBuffer(Buffer.from(FALLBACK_B64, 'base64')); } ``` If IPC mismatch: check `apps/desktop/electron/ipc-types.ts` and `preload.ts` — fix whichever side is out of sync. If unread count is 0: investigate `ConversationsContext.tsx:293` to find why `totalUnread` doesn't reflect new messages. - [ ] **Step 4: Remove the debug `console.info` calls** Both `[tray] push` and `[tray-main] received` lines. - [ ] **Step 5: Verify in dev** Restart dev, have a peer send → tray badge appears within ~1s of receiving. - [ ] **Step 6: Commit** ```bash git add apps/desktop/electron/modules/tray.ts apps/desktop/src/lib/trayBadge.ts git commit -m "fix(desktop): tray badge actually renders — " ``` If no functional change was needed (diagnosis only): commit empty with `--allow-empty` and a `chore(desktop): tray badge verified working in dev` message. --- ## Task 5: EmptyState primitive **Files:** - Create: `apps/desktop/src/components/EmptyState.tsx` - [ ] **Step 1: Create the component** ```tsx import type { ReactNode } from 'react'; interface Props { icon: ReactNode; title: string; description: string; action?: { label: string; onClick: () => void }; } // Reusable empty-state placeholder: large icon + heading + description + // optional primary CTA. Used everywhere there's a meaningfully-empty list // (no chats, no friends, no search results, fresh conversation). export function EmptyState({ icon, title, description, action }: Props) { return (
{icon}

{title}

{description}

{action && ( )}
); } ``` - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 ``` - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/components/EmptyState.tsx git commit -m "feat(desktop): EmptyState primitive component" ``` --- ## Task 6: Empty-state — Chat list **Files:** - Modify: `apps/desktop/src/pages/ChatsPage.tsx` - [ ] **Step 1: Find where the chat list is rendered** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app grep -n "conversations\\.map\\|conversations\\.length\\|return.*null" apps/desktop/src/pages/ChatsPage.tsx | head -10 ``` - [ ] **Step 2: Render EmptyState when the list is empty** In the file's render output, where `conversations.map(...)` would produce zero items, add a fall-through: ```tsx import { useNavigate } from 'react-router-dom'; import { ChatBubbleIcon, AddUserIcon } from '../components/icons'; import { EmptyState } from '../components/EmptyState'; // inside the component, near other hooks: const navigate = useNavigate(); // in the render, where the chat list is: {conversations.length === 0 ? ( } title={t('app:chats.empty_title', { defaultValue: 'Noch keine Chats' })} description={t('app:chats.empty_desc', { defaultValue: 'Lade einen Freund ein und schreibe die erste Nachricht.', })} action={{ label: t('app:chats.empty_cta', { defaultValue: 'Freunde verwalten' }), onClick: () => navigate('/friends'), }} /> ) : ( conversations.map(/* existing render */) )} ``` Adapt the variable names (`conversations`, `t`) to the actual ones in the file. - [ ] **Step 3: Typecheck** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 ``` - [ ] **Step 4: Commit** ```bash git add apps/desktop/src/pages/ChatsPage.tsx git commit -m "feat(desktop): empty-state for chat list" ``` --- ## Task 7: Empty-state — Friends list **Files:** - Modify: `apps/desktop/src/pages/FriendsPage.tsx` - [ ] **Step 1: Same pattern as Task 6** Find the friends-list render path. When `friends.length === 0` (or the equivalent), render: ```tsx import { EmptyState } from '../components/EmptyState'; import { AddUserIcon } from '../components/icons'; {friends.length === 0 ? ( } title={t('app:friends.empty_title', { defaultValue: 'Noch keine Freunde' })} description={t('app:friends.empty_desc', { defaultValue: 'Suche einen Friend per Username oder schicke eine Einladung.', })} action={{ label: t('app:friends.empty_cta', { defaultValue: 'Friend suchen' }), onClick: () => { // Open the existing add-friend dialog/section. Adapt to whatever // trigger the page already has (e.g., setShowAddDialog(true)). }, }} /> ) : ( // existing list render )} ``` Wire the `onClick` to whatever already opens the "Add friend" affordance in this page. - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add apps/desktop/src/pages/FriendsPage.tsx git commit -m "feat(desktop): empty-state for friends list" ``` --- ## Task 8: Empty-state — Empty conversation **Files:** - Modify: `apps/desktop/src/pages/ConversationPage.tsx` - [ ] **Step 1: Add empty-state to the message-area render** When the conversation has zero messages AND nothing's loading, show a soft intro: ```tsx import { EmptyState } from '../components/EmptyState'; import { SendIcon } from '../components/icons'; // In the messages-area render, when state.messages.length === 0 && !state.loading: {!state.loading && state.messages.length === 0 ? ( } title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })} description={t('app:chats.conv_empty_desc', { defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.', })} /> ) : ( // existing messages render )} ``` No action button — the composer at the bottom is already the CTA. - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(desktop): empty-state for empty conversation" ``` --- ## Task 9: Friend-Nicknames — storage helper + hook **Files:** - Create: `apps/desktop/src/lib/friendNicknames.ts` - [ ] **Step 1: Implement the store** ```ts import { useSyncExternalStore } from 'react'; // Local-only friend nickname overrides. Stored in localStorage keyed by the // peer's user-id. Empty/missing value = use the real display name. // Local-only by design — friends never see your nickname for them. const STORAGE_KEY = 'chatapp.friendNicknames.v1'; let cache: Record | null = null; const listeners = new Set<() => void>(); function load(): Record { if (cache) return cache; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { cache = {}; return cache; } const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === 'object') { cache = {}; for (const [k, v] of Object.entries(parsed as Record)) { if (typeof v === 'string' && v.trim().length > 0) cache[k] = v; } return cache; } } catch { /* corrupted; fall through */ } cache = {}; return cache; } function persist(): void { try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); } catch { /* quota / private mode */ } for (const l of listeners) l(); } export function getNickname(userId: string): string | null { return load()[userId] ?? null; } export function setNickname(userId: string, nickname: string | null): void { const store = load(); const trimmed = nickname?.trim() ?? ''; if (trimmed.length === 0) { if (!(userId in store)) return; delete store[userId]; } else { if (store[userId] === trimmed) return; store[userId] = trimmed; } persist(); } // Reactive hook: returns the current nickname for a user, or `fallback` // when no nickname is set. Re-renders when ANY nickname changes (cheap, // the set is small). export function useNickname(userId: string | null | undefined, fallback: string): string { const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; }; const getSnapshot = () => (userId ? getNickname(userId) : null); const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); return nickname ?? fallback; } ``` - [ ] **Step 2: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add apps/desktop/src/lib/friendNicknames.ts git commit -m "feat(desktop): friendNicknames local store + useNickname hook" ``` --- ## Task 10: Friend-Nicknames — set-dialog + right-click trigger **Files:** - Create: `apps/desktop/src/components/NicknameDialog.tsx` - Modify: `apps/desktop/src/pages/FriendsPage.tsx` — wire right-click to open the dialog - [ ] **Step 1: Create the dialog** ```tsx import { useEffect, useRef, useState } from 'react'; import { getNickname, setNickname } from '../lib/friendNicknames'; interface Props { open: boolean; userId: string; displayName: string; onClose: () => void; } export function NicknameDialog({ open, userId, displayName, onClose }: Props) { const [value, setValue] = useState(''); const inputRef = useRef(null); useEffect(() => { if (!open) return; setValue(getNickname(userId) ?? ''); setTimeout(() => inputRef.current?.focus(), 0); }, [open, userId]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; const submit = (): void => { setNickname(userId, value); onClose(); }; return (
e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl" >

Spitzname für {displayName}

Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.

setValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} maxLength={32} placeholder={displayName} className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" />
); } ``` - [ ] **Step 2: Wire it from `FriendsPage.tsx`** Add at the top of `FriendsPage.tsx`: ```tsx import { NicknameDialog } from '../components/NicknameDialog'; ``` Inside the component, add local state for the dialog: ```tsx const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null); ``` On the existing friend list row JSX, add an `onContextMenu` handler: ```tsx
{ e.preventDefault(); setNicknameDialog({ userId: friend.userId, displayName: friend.displayName ?? friend.username }); }} > {/* existing row content */}
``` At the bottom of the component (before the return-closing tag), add the dialog: ```tsx setNicknameDialog(null)} /> ``` - [ ] **Step 3: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add apps/desktop/src/components/NicknameDialog.tsx apps/desktop/src/pages/FriendsPage.tsx git commit -m "feat(desktop): set-nickname dialog + right-click trigger in friends list" ``` --- ## Task 11: Friend-Nicknames — apply at every display site **Files:** - Modify: `apps/desktop/src/components/ConversationHeader.tsx` - Modify: `apps/desktop/src/components/MessageBubble.tsx` - Modify: `apps/desktop/src/components/MentionAutocomplete.tsx` - Modify: `apps/desktop/src/components/CallParticipantTile.tsx` - [ ] **Step 1: Sweep for display-name renders** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app grep -rn "displayName\|display_name" apps/desktop/src/components/ConversationHeader.tsx apps/desktop/src/components/MessageBubble.tsx apps/desktop/src/components/MentionAutocomplete.tsx apps/desktop/src/components/CallParticipantTile.tsx | head -30 ``` - [ ] **Step 2: In each file, swap the raw display name for the hook** Pattern, for each occurrence where the peer's display name is rendered: ```tsx // add import at the top of the file import { useNickname } from '../lib/friendNicknames'; // where you currently do, e.g. // {peer.displayName} // change to: const renderedName = useNickname(peer.userId, peer.displayName ?? peer.username ?? '?'); // … {renderedName} ``` For lists (e.g. `MentionAutocomplete`'s suggestion items, `CallParticipantTile` for each tile), introduce a tiny inner component so the hook can be called per row: ```tsx function MemberRow({ member, ...rest }: { member: Member; /* ...other props... */ }) { const name = useNickname(member.userId, member.displayName ?? '?'); return
  • {name}
  • ; } ``` Don't try to call the hook in a `.map()` — extract a row component. - [ ] **Step 3: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add -u git commit -m "feat(desktop): apply friend nicknames in header / bubble / mentions / call tile" ``` --- ## Task 12: Memory-Wipe — sign-out wipe **Files:** - Create: `apps/desktop/src/lib/memoryWipe.ts` - Modify: `apps/desktop/src/context/AuthContext.tsx` - [ ] **Step 1: Implement the wipe function** ```ts import { clearConvKeyCache } from '@chat-app/shared/chat'; import { devLocalSecretStore } from './secretStore'; // Aggressively scrub local crypto + chat caches on sign-out (and on // app-close if the user opted in). Preserves things that aren't sensitive // and would be annoying to lose (theme, locale, install-id). // // We can't enumerate IndexedDB names without async + the indexedDB API, // so we list the ones we know about explicitly. Adding a new local store // later? Append to LOCAL_DBS. const LOCAL_DBS = ['soundboard', 'message-cache', 'chatapp-attachments']; const PRESERVE_LOCAL_STORAGE = new Set([ 'chatapp.theme', 'chatapp.locale', 'chatapp.installId', 'i18nextLng', ]); export async function wipeLocalState(userId: string | null): Promise { // 1. Per-conversation key cache (in-memory). try { clearConvKeyCache(); } catch { /* never throws but be defensive */ } // 2. Stronghold / secret-store: drop the user-priv blob for this user. if (userId) { try { await devLocalSecretStore.removeSecret('chatapp.userpriv.' + userId); } catch (err) { console.warn('[wipe] userpriv remove failed', err); } } // 3. localStorage — preserve only the explicit whitelist. try { const keysToDrop: string[] = []; for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); if (k && !PRESERVE_LOCAL_STORAGE.has(k)) keysToDrop.push(k); } for (const k of keysToDrop) window.localStorage.removeItem(k); } catch (err) { console.warn('[wipe] localStorage clear failed', err); } // 4. sessionStorage — always full. try { window.sessionStorage.clear(); } catch { /* ignored */ } // 5. IndexedDB — delete known databases. Resolves even when blocked so // we don't hang sign-out forever. await Promise.allSettled(LOCAL_DBS.map((name) => new Promise((resolve) => { try { const req = window.indexedDB.deleteDatabase(name); req.onsuccess = () => resolve(); req.onerror = () => resolve(); req.onblocked = () => resolve(); } catch { resolve(); } }))); } ``` - [ ] **Step 2: Call it from `signOut` in AuthContext** In `apps/desktop/src/context/AuthContext.tsx`, change the `signOut` callback: ```tsx import { wipeLocalState } from '../lib/memoryWipe'; // …existing signOut definition becomes: const signOut = useCallback(async () => { const uid = session?.user.id ?? null; await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => { console.warn('offline update before sign-out failed', err); }); await supabaseSignOut(supabase); await wipeLocalState(uid); }, [session]); ``` The wipe runs AFTER supabase clears the session so the wipe can't accidentally drop something Supabase needed mid-shutdown. - [ ] **Step 3: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add apps/desktop/src/lib/memoryWipe.ts apps/desktop/src/context/AuthContext.tsx git commit -m "feat(desktop): wipe local crypto + caches on sign-out" ``` --- ## Task 13: Memory-Wipe — wipe-on-close setting **Files:** - Create: `apps/desktop/src/lib/memoryWipeSettings.ts` - Modify: `apps/desktop/electron/ipc-types.ts` — new channel - Modify: `apps/desktop/electron/preload.ts` + `preload-types.d.ts` - Modify: `apps/desktop/electron/main.ts` — `before-quit` handler - Modify: `apps/desktop/src/components/SecurityCenter.tsx` — Toggle UI - [ ] **Step 1: Settings helper** `apps/desktop/src/lib/memoryWipeSettings.ts`: ```ts const KEY = 'chatapp.wipeOnClose.v1'; export function isWipeOnCloseEnabled(): boolean { try { return window.localStorage.getItem(KEY) === '1'; } catch { return false; } } export function setWipeOnClose(enabled: boolean): void { try { window.localStorage.setItem(KEY, enabled ? '1' : '0'); } catch { /* ignored */ } } ``` - [ ] **Step 2: New IPC channel — main asks renderer to wipe before quit** In `apps/desktop/electron/ipc-types.ts`, add inside the `CHANNELS` object: ```ts /** Main → renderer: about to quit. Renderer wipes, then resolves. */ APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit', ``` In `apps/desktop/electron/preload.ts`, expose a listener: ```ts onWipeBeforeQuit: (cb: () => Promise): (() => void) => { const handler = async (_evt: IpcRendererEvent): Promise => { try { await cb(); } catch (err) { console.warn('[wipe] renderer cb failed', err); } ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done'); }; ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler); return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler); }, ``` In `apps/desktop/electron/preload-types.d.ts`, add the function signature: ```ts onWipeBeforeQuit: (cb: () => Promise) => () => void; ``` - [ ] **Step 3: Main process triggers it on `before-quit`** In `apps/desktop/electron/main.ts`, add near other window-event handlers (look for `before-quit` to see if there's already one): ```ts import { CHANNELS } from './ipc-types'; // Wipe-on-close: when the user enables it in Settings, renderer is given a // chance to clear all sensitive caches before the app process exits. If the // renderer doesn't ack within 2 seconds we force-quit anyway — better to // lose the wipe than to hang the app shutdown. let wipeRequested = false; app.on('before-quit', (event) => { if (wipeRequested) return; // already in progress if (!mainWindow || mainWindow.isDestroyed()) return; wipeRequested = true; event.preventDefault(); mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT); const done = new Promise((resolve) => { ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve()); }); Promise.race([done, new Promise((r) => setTimeout(r, 2000))]).finally(() => { app.quit(); }); }); ``` The renderer chooses whether the wipe is no-op or real (based on the setting); main always asks. - [ ] **Step 4: Renderer registers the wipe handler at boot** In `apps/desktop/src/main.tsx` (or wherever the top-level mount happens), add an effect or a module-level subscribe near where `window.electronAPI` is first available. Easier path: do it inside `AuthContext` right after `signOut` is defined: ```tsx import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings'; useEffect(() => { if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return; const unsub = window.electronAPI.onWipeBeforeQuit(async () => { if (!isWipeOnCloseEnabled()) return; await wipeLocalState(session?.user.id ?? null); }); return unsub; }, [session?.user.id]); ``` - [ ] **Step 5: Toggle in SecurityCenter** In `apps/desktop/src/components/SecurityCenter.tsx`, add a new section before "Identität zurücksetzen": ```tsx import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings'; // inside the component: const [wipeOnClose, setWipeOnCloseState] = useState(() => isWipeOnCloseEnabled()); // in the JSX, between the recovery-code section and the danger section:

    Cache beim Schließen leeren

    Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.

    ``` - [ ] **Step 6: Typecheck + commit** ```bash pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5 git add -u git commit -m "feat(desktop): optional wipe-on-close (Settings → Sicherheit)" ``` --- ## Final Phase 1 gate - [ ] **Run the full check matrix** ```bash cd D:/Programmieren/ChatApp-Electron/chat-app pnpm --filter @chat-app/shared typecheck && \ pnpm --filter @chat-app/desktop typecheck && \ pnpm --filter @chat-app/shared test ``` Expected: all green. - [ ] **DO NOT release** No `pnpm release` calls in this phase. Version stays on `0.18.8`. The next plan (Phase 2 — Messaging) is written and started only after the user signs off on Phase 1. --- ## Self-Review **1. Spec coverage:** - Hotkey-Bug Fix: Tasks 1-3 (binding shape, gating, UI toggle) ✓ - Tray-Badge Audit: Task 4 (diagnose + fix) ✓ - Empty-States: Tasks 5-8 (primitive + 3 sites) ✓ - Friend-Nicknames: Tasks 9-11 (store, dialog, application) ✓ - Memory-Wipe: Tasks 12-13 (sign-out + optional close) ✓ - No release: Final gate explicit ✓ **2. Placeholder scan:** All code blocks are complete. Task 4's diagnostic step has three explicit branching fixes, not a generic "fix it". Task 11 mentions "adapt to whatever trigger" only where the per-page widget naming truly varies — the action it should take is fully specified. **3. Type consistency:** `VoiceHotkeyBinding.global` introduced Task 1, consumed Task 2 (CallContext) + Task 3 (UI). `wipeLocalState(userId)` defined Task 12, consumed Task 13 (renderer handler). `isWipeOnCloseEnabled / setWipeOnClose` Task 13 only. `useNickname(userId, fallback)` Task 9, consumed Task 11. `EmptyState` Task 5, consumed Tasks 6-8. All consistent.