Files
ChatApp/docs/superpowers/plans/2026-05-16-phase1-quality-fixes.md
T

37 KiB
Raw Blame History

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.tsxsignOut calls new wipeLocalState
  • apps/desktop/electron/main.tsbefore-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.tswipeLocalState(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:

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:

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:

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
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 19572049)

  • 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:

    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
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

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:

            <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
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5

Expected: zero errors.

  • Step 4: Commit
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:2543

  • Step 1: Verify the IPC channel actually fires

Add temporary console.info('[tray] push n=' + n) in trayBadge.ts:14:

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:

  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:3343 to use an embedded PNG as a fallback (same approach as buildOverlay):

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
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

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
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5
  • Step 3: Commit
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

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:

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
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -5
  • Step 4: Commit
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:

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
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:

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
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

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
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

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:

import { NicknameDialog } from '../components/NicknameDialog';

Inside the component, add local state for the dialog:

const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null);

On the existing friend list row JSX, add an onContextMenu handler:

<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:

<NicknameDialog
  open={nicknameDialog !== null}
  userId={nicknameDialog?.userId ?? ''}
  displayName={nicknameDialog?.displayName ?? ''}
  onClose={() => setNicknameDialog(null)}
/>
  • Step 3: Typecheck + commit
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

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:

// 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:

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
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

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:

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
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.tsbefore-quit handler

  • Modify: apps/desktop/src/components/SecurityCenter.tsx — Toggle UI

  • Step 1: Settings helper

apps/desktop/src/lib/memoryWipeSettings.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:

  /** 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:

  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:

  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):

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:

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":

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
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
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.