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

1078 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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:
```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:2543`
- [ ] **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:3343` 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.