1078 lines
37 KiB
Markdown
1078 lines
37 KiB
Markdown
# 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.
|