# 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<VoiceHotkeyBinding>;
  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<HotkeyKind, string | null> = {
        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
            <button
              type="button"
              onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
              disabled={!binding.enabled}
              aria-pressed={binding.global}
              title={
                binding.global
                  ? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
                  : 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
              }
              className={
                'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
                (binding.global
                  ? 'border-accent/40 bg-accent/15 text-accent'
                  : 'border-line bg-surface-3 text-fg-muted hover:text-fg')
              }
            >
              <span aria-hidden>🌐</span>
              <span>{binding.global ? 'Global' : 'Im Fenster'}</span>
            </button>
```

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<void> {
  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<void> => {
      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 — <one-line description of the root cause>"
```

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 (
    <div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
      <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
        {icon}
      </div>
      <h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
      <p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
      {action && (
        <button
          type="button"
          onClick={action.onClick}
          className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
        >
          {action.label}
        </button>
      )}
    </div>
  );
}
```

- [ ] **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 ? (
  <EmptyState
    icon={<ChatBubbleIcon className="h-8 w-8" />}
    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 ? (
  <EmptyState
    icon={<AddUserIcon className="h-8 w-8" />}
    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 ? (
  <EmptyState
    icon={<SendIcon className="h-8 w-8" />}
    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<string, string> | null = null;
const listeners = new Set<() => void>();

function load(): Record<string, string> {
  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<string, unknown>)) {
        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<HTMLInputElement | null>(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 (
    <div
      role="dialog"
      aria-modal="true"
      aria-label="Spitzname setzen"
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
      onClick={onClose}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
      >
        <h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
        <p className="mt-1 text-xs text-fg-muted">
          Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
        </p>
        <input
          ref={inputRef}
          type="text"
          value={value}
          onChange={(e) => 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"
        />
        <div className="mt-4 flex justify-end gap-2">
          <button
            type="button"
            onClick={onClose}
            className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
          >
            Abbrechen
          </button>
          <button
            type="button"
            onClick={submit}
            className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
          >
            Speichern
          </button>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **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
<div
  // existing props…
  onContextMenu={(e) => {
    e.preventDefault();
    setNicknameDialog({ userId: friend.userId, displayName: friend.displayName ?? friend.username });
  }}
>
  {/* existing row content */}
</div>
```

At the bottom of the component (before the return-closing tag), add the dialog:

```tsx
<NicknameDialog
  open={nicknameDialog !== null}
  userId={nicknameDialog?.userId ?? ''}
  displayName={nicknameDialog?.displayName ?? ''}
  onClose={() => 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.
//   <span>{peer.displayName}</span>
// change to:
const renderedName = useNickname(peer.userId, peer.displayName ?? peer.username ?? '?');
// …
<span>{renderedName}</span>
```

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 <li>{name}</li>;
}
```

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<void> {
  // 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<void>((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>): (() => void) => {
    const handler = async (_evt: IpcRendererEvent): Promise<void> => {
      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>) => () => 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<void>((resolve) => {
    ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
  });
  Promise.race([done, new Promise<void>((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<boolean>(() => isWipeOnCloseEnabled());

// in the JSX, between the recovery-code section and the danger section:
<section>
  <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
    Cache beim Schließen leeren
  </h3>
  <p className="mb-2 text-xs text-fg-muted">
    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.
  </p>
  <label className="inline-flex cursor-pointer items-center gap-2 text-sm">
    <input
      type="checkbox"
      checked={wipeOnClose}
      onChange={(e) => {
        setWipeOnClose(e.target.checked);
        setWipeOnCloseState(e.target.checked);
      }}
    />
    <span>Aktivieren</span>
  </label>
</section>
```

- [ ] **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.
