37 KiB
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— addglobal: booleanfield toVoiceHotkeyBindingapps/desktop/src/context/CallContext.tsx— only register Electron global shortcut whenglobal === trueapps/desktop/src/pages/SettingsPage.tsx— VoiceHotkeyControls: add 🌐-Toggle UI; SecurityCenter: add Memory-Wipe Settingapps/desktop/src/context/AuthContext.tsx—signOutcalls newwipeLocalStateapps/desktop/electron/main.ts—before-quitIPC if memory-wipe-on-close enabledapps/desktop/electron/ipc-types.ts— new IPC channelAPP_WIPE_BEFORE_QUITapps/desktop/electron/preload.ts+preload-types.d.ts— expose new IPCapps/desktop/src/pages/ChatsPage.tsx— empty-state for empty chat listapps/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 nicknameapps/desktop/src/components/MessageBubble.tsx— use nickname when rendering peer nameapps/desktop/src/components/MentionAutocomplete.tsx— show nickname in suggestion listapps/desktop/src/components/ConversationHeader.tsx— use nickname for peerapps/desktop/src/components/CallParticipantTile.tsx— use nickname
New:
apps/desktop/src/lib/friendNicknames.ts— localStorage-backed nickname store withuseNickname(userId)hookapps/desktop/src/lib/memoryWipe.ts—wipeLocalState(userId)functionapps/desktop/src/lib/memoryWipeSettings.ts— persisted toggle for wipe-on-closeapps/desktop/src/components/EmptyState.tsx— reusable empty-state primitiveapps/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 1957–2049) -
Step 1: Gate
syncGlobalShortcutson theglobalflag
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 theVoiceHotkeyControlsfunction -
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:25–43 -
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:33–43 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.infocalls
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
signOutin 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.ts—before-quithandler -
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.