diff --git a/docs/superpowers/plans/2026-05-16-phase1-quality-fixes.md b/docs/superpowers/plans/2026-05-16-phase1-quality-fixes.md new file mode 100644 index 0000000..df70f54 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase1-quality-fixes.md @@ -0,0 +1,1077 @@ +# 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.