16 KiB
Fifteen-Features Initiative — Design
Date: 2026-05-16 Scope: Desktop only (Electron). Mobile is out of scope. Status: Approved by user (sections 1–6).
Problem
The desktop app has reached a stable point (encryption rolled out, Settings refactored). The user identified fifteen feature gaps to close as one coordinated initiative — quality fixes, messaging power, security UI, creative tools, and shared activities. Releasing each piecemeal would mean nine bumps and nine test cycles; instead this initiative ships as one 0.19.0 after a single end-to-end smoke pass.
Goals
- Close all fifteen features without a release until the user signs off.
- Fix the global-shortcut capture bug that currently breaks every keystroke after a hotkey is set.
- No surprise releases — every commit is
git commitonly, nopnpm release. Version stays on0.18.8throughout, bumps to0.19.0once. - Each phase ends with a clean typecheck + green shared tests.
Non-Goals
- Mobile / React Native port of any of these features.
- Twitch in Watch-Together (YouTube only for the MVP).
- Chess or other mini-games beyond Tic-Tac-Toe + Vier-Gewinnt.
- Operational Transform / CRDT for the Whiteboard (snapshot model only).
- Live video preview or speaking indicators in the existing CallPreviewPanel.
- Hot-patch releases between phases.
Phasing
| Phase | Theme | Features | Rough Effort |
|---|---|---|---|
| 1 | Quality & Fixes | Hotkey-Bug Fix, Tray-Badge Audit, Empty-States, Friend-Nicknames, Memory-Wipe | 3–4 days |
| 2 | Messaging | Pinned Messages, Mentions-Notifications, GIF-Picker, View-Once Media | ~1 week |
| 3 | Security & Devices | Session/Device List + Revoke | 2–3 days |
| 4 | Creative Tools | Image-Annotation, Soundboard Cloud-Sync, Whiteboard (Snapshot-Sync) | ~1.5 weeks |
| 5 | Activities | Watch-Together (YouTube), Mini-Games (Tic-Tac-Toe + 4-in-a-Row) | ~1.5 weeks |
Phases run sequentially. Within a phase, features are independent and can be implemented in any order.
Architecture (per feature)
Phase 1 — Quality & Fixes
Hotkey-Bug Fix. The current code (apps/desktop/src/lib/globalShortcut.ts) registers every hotkey through Electron's globalShortcut API, which captures system-wide. Setting "M" as Mute means "M" can't be typed anywhere on the OS while the app is running. Fix: split into two registration modes.
- Window-scoped (default): DOM
keydownlistener onwindow, only fires while the app has focus. Used for mute, deafen, hangup, screen-share, video toggle. - Global (opt-in): Keep
globalShortcut.register()for PTT and any hotkey the user explicitly toggles "Global" on.
UI change: every hotkey row in Settings → Voice → Hotkeys gets a 🌐 Global-toggle next to the key-capture button. Default off.
Tray-Badge Audit. Tray code already exists (electron/modules/tray.ts, lib/trayBadge.ts, called from ConversationsContext). Verify the count is actually pushed and the badge renders on Windows. Likely a wiring or icon-loading bug; spec-time fix scope unknown until investigated.
Empty-States. Four locations: empty chat list, empty friends list, empty conversation (just-created with no messages), empty search results. Each gets an illustration (SVG, light/dark variant), a heading, a description, and a primary action button (e.g., "Friend einladen", "Erste Nachricht schreiben"). Reuses existing icon palette.
Friend-Nicknames. Local-only override stored in localStorage keyed by userId. Surfaces wherever a profile name is shown: chat header, message bubble sender, friends list, mention autocomplete, call participant tile. UI: right-click friend in friends list → "Spitzname setzen" → input modal. Empty = use real display name.
Memory-Wipe.
- Always on sign-out: clear Stronghold (
chatapp.userpriv.*), IndexedDB (soundboard, message-cache), SQLite cache, conv-key in-memory cache,localStorageexcept theme + locale + installId. - Configurable on app-close (Settings → Security → "Cache beim Schließen leeren"): hooks
before-quitin main process → IPC to renderer → wipe → quit. Next start requires PIN.
Phase 2 — Messaging
Pinned Messages.
- DB:
pinned_messages (conversation_id uuid, message_id uuid, pinned_by uuid, pinned_at timestamptz, PK(conversation_id, message_id)). CHECK via trigger: max 5 pins per conversation. - RLS: any accepted conv-member can
SELECT/INSERT/DELETE. - UI: right-click message → "Anpinnen" / "Anheftung aufheben". Conv header shows compact "📌 N angepinnt"-pill; click opens a right-side panel with the pinned list; clicking an item scrolls to + flashes the original message.
- Realtime: subscribe
pinned_messages:<conv_id>channel for live updates.
Mentions-Notifications.
sendEncryptedMessageparses plaintext for@<username>tokens before encrypting. Username → user_id lookup againstconversation_membersjoined withprofiles. Bulk-insert into newmessage_mentions (message_id, mentioned_user_id, PK both).- RLS: mentioned user (and message author) can SELECT.
- Realtime: each client subscribes
message_mentions WHERE mentioned_user_id = me. On insert → fire web/desktop notification with[@] <sender>: <decrypted snippet>and the configured mention sound. Bypasses per-conv mute. - Per-conv setting "Nur bei @Mentions benachrichtigen" (stored alongside the existing mute setting).
GIF-Picker.
- Provider: Tenor v2 (
https://tenor.googleapis.com/v2/search, no per-user key — public key passable). Trending + Search + Recent (localStorage of last 24 URLs). - New component
<GifPicker>Popover anchored to the composer's GIF button (next to attachment plus). - On click: resolved GIF URL is fetched once, uploaded as a normal attachment (mime
image/gif) so it lives in the same per-conv attachment storage and is end-to-end-encrypted like any other image. Trade-off: a sent GIF doesn't stay free — it counts as an attachment (~few MB).
View-Once Media.
- Per-attachment flag
view_once boolean(new column onmessage_attachments). - Sender UI: attachment-picker toggle "👁 Einmal ansehen".
- Recipient UI: bubble shows blurred lock-overlay; tap → fullscreen view; close → bubble flips to "👁 Angesehen, von
- Auto-purge: Supabase cron deletes view-once attachments untouched after 7 days.
- Sender sees the same "Angesehen"-bubble update (so they know it was opened).
Phase 3 — Security & Devices
Session/Device List + Revoke.
- Uses the existing
devicestable (telemetry only since the 0.18 encryption refactor). - New SettingsPage tab "Geräte" between Sicherheit and Konto. Lists
name · platform · last_seenper device, "Dieses Gerät"-badge for the local install. - New column
devices.revoked_at timestamptz NULL. Revoke RPCrevoke_device(p_device_id uuid): requiresdevice.user_id = auth.uid(), setsrevoked_at = now(). - Realtime: every client subscribes
devices:user_id=me. On its ownrevoked_atchange → forcedsignOut()+ Memory-Wipe + screen "Du wurdest remote abgemeldet". - The own device's revoke button is disabled in the list (label: "Nutze Sign-out").
Phase 4 — Creative Tools
Image-Annotation vor Send.
- Attachment-picker for images shows a new "✏ Bearbeiten" button before send.
- Opens
<ImageAnnotator>modal: canvas overlay on the image. Tools: pen, arrow, rectangle, circle, text, highlighter. 6 colors, 3 stroke widths. Undo/Redo stack, Reset, Save. - On Save:
canvas.toBlob({type: 'image/png'})flattens the annotation into the original, replaces the upload-queue entry. - Custom implementation (~400 LOC). No external dep (tldraw etc. would be overkill).
Soundboard Cloud-Sync.
- New table
user_soundboards (id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at). - Storage: bucket
soundboards/<user_id>/<sound_id>.bin. Audio payload is encrypted client-side with the user's existing per-user X25519 key (reusescrypto/box). Server only sees ciphertext. - Local IndexedDB stays the working store; sync is bidirectional.
- Sync engine: on app start + on every local edit (debounced 500ms), diff IndexedDB ↔ remote → upload new/changed, download remote-newer. Realtime subscription on own
user_soundboardsrows triggers pulls when another device of the user edits. - Conflict resolution: last-write-wins by
updated_at. UI shows "☁ Sync OK / ↑ Hochladen…/ ⚠ Konflikt" badge per sound.
Whiteboard (Snapshot-Sync).
- New tables
conversation_whiteboards (id, conversation_id, owner_user_id, created_at)andwhiteboard_strokes (id, whiteboard_id, author_user_id, stroke_json jsonb, created_at). - UI: composer menu → "✏ Whiteboard" → starts a new whiteboard inline in the chat as a bubble (preview + author + "Öffnen"-button). Click → fullscreen modal with canvas.
- Tools: pen, eraser, 6 colors, 3 widths. "Clear all" with confirm dialog.
- Each pen stroke (on pointerup) inserts a row into
whiteboard_strokes. Realtime subscription onwhiteboard_idrenders incoming strokes from other authors. Strokes are JSON:{tool, color, width, points: [[x, y, t], …]}. - Whiteboard is permanent — bubble in the chat stays, history is preserved, anyone in the conv can reopen it later.
Phase 5 — Activities
Watch-Together (YouTube).
- New table
conversation_watch_sessions (id, conversation_id, owner_user_id, video_id text, started_at, ended_at NULL, current_state jsonb).current_state = { playing: bool, position_seconds: number, updated_at_ms: number }. - Composer → "📺 Watch Together" → modal with URL input → extract video-id (regex on YouTube URL formats) → INSERT row → inline bubble "Anna hat Watch-Together gestartet · [Beitreten]".
- Click Beitreten → fullscreen modal with YouTube IFrame Player API. Controls (play/pause/seek) by owner are broadcast via the session row's
current_stateupdate. Other clients reconcile their local player when drift > 2 seconds. - IFrame API loaded lazily via
<script src="https://www.youtube.com/iframe_api">. No build-time dep. - Auto-end after 12h inactivity (cron). Owner-leave sets
ended_at. Non-owner leave keeps session open for re-join.
Mini-Games (Tic-Tac-Toe + Vier-Gewinnt).
- Shared infrastructure for both:
- New table
conversation_games (id, conversation_id, game_type text, state jsonb, players jsonb, current_turn_user_id, winner_user_id NULL, created_at, finished_at NULL). - Move RPC
game_make_move(p_game_id uuid, p_move jsonb). Server-authoritative state machine validates and applies the move (no client trust). Returns updated state. - Realtime subscription on the game row renders state.
- New table
- Tic-Tac-Toe: 3×3 board, cell index 0–8, server validates cell-is-empty + correct turn + win line detection (8 winning lines).
- Vier-Gewinnt: 7×6 board, column 0–6 with gravity, server validates column-not-full + correct turn + 4-in-row detection (horizontal/vertical/2 diagonals).
- UI: composer → "🎮 Spiel starten" → submenu (Tic-Tac-Toe / Vier-Gewinnt) → inline invite bubble. Recipient sees "Annehmen"-button. Both then see a ~250×250 board in their bubble. Click a valid cell/column = RPC move. Turn indicator + winner reveal with confetti animation (
canvas-confetti). - Scope: 1v1 only (2-player games). Group games out of scope.
Data Flow Highlights
- No race conditions on mini-game moves: server RPC is atomic via
BEGIN; SELECT ... FOR UPDATE; validate; UPDATE; COMMIT;. Last-write loses. - No race on watch-together state: owner-only writes. Other clients always reconcile from
current_state.updated_at_ms. - No race on whiteboard: strokes are append-only; concurrent strokes from multiple authors interleave naturally.
- Mentions on edit: when a message is edited, the
message_mentionsrows for that message are recomputed (delete-then-insert in the same RPC).
Error Handling
| Case | Behavior |
|---|---|
| Hotkey conflict (same accelerator double-bound) | Capture-modal shows "Bereits belegt von ". Save disabled until user picks something else. |
| Tray-badge fails to render on Linux | Silent fallback to text-only tooltip; only Windows has overlay-icon API. |
| Friend has no nickname | Falls back to display_name. |
| Memory-wipe IPC times out at quit | Force-exit anyway after 2s timeout; user accepts ephemeral data may remain in OS swap. |
| Pinned-message limit reached (5) | "Anpinnen" action shows toast "Max 5 angepinnt — heften Sie eine andere ab". |
| Mention notification fires for a muted device | Always shows (mention overrides mute). |
| Tenor API down | GIF-Picker shows "GIFs gerade nicht verfügbar". |
| View-once attachment opened concurrently by two devices of recipient | First view RPC wins; second sees "Bereits angesehen". |
| Watch-together: video deleted from YouTube | Player surfaces YouTube's own error UI inline. |
| Mini-game: opponent disconnects mid-game | Game stays open. On reconnect (refresh) the latest state is fetched. After 24h auto-finishes as draw. |
| Whiteboard stroke insert fails | Stroke stays local; UI shows a "↻ Retry"-badge. |
Testing
Per phase: typecheck (pnpm --filter @chat-app/shared typecheck + pnpm --filter @chat-app/desktop typecheck) and shared tests (pnpm --filter @chat-app/shared test) must be green.
Final smoke list (user runs after Phase 5 completes):
- Hotkey "M" as Mute, default (window-scoped). Type "m" in Composer → letter appears, mute doesn't fire.
- Hotkey "M" toggled to Global → "m" outside the app triggers mute.
- Friend sends DM → tray-badge shows 1; click opens app focused.
- Empty chat list → "Friend einladen"-button visible + functional.
- Right-click friend → set nickname "Bestie" → chat header shows "Bestie".
- Sign-out → Storage Inspector shows no Stronghold key, no user-priv, no conv-key cache.
- Memory-Wipe toggle on → close app, reopen → PIN required.
- Pin a message → conv header shows "📌 1 angepinnt" → click → side panel lists it.
- Friend writes "@mich" → notification with [@] prefix + mention sound, even on muted conv.
- GIF-Picker → search "cat" → click → GIF sent in chat.
- View-once image sent → recipient opens once → bubble shows "Angesehen", image is gone.
- Settings → Geräte → revoke another device → that device is force-signed-out.
- Send image → "Bearbeiten" → draw arrow → send → arrow is in the final image.
- Add soundboard sound on device A → device B (same user, different install) → sound appears.
- Open whiteboard → friend draws → strokes appear within 1s.
- Start Watch-Together with YouTube URL → friend joins → owner pauses → friend's player pauses.
- Start Tic-Tac-Toe → friend accepts → alternate moves → win animation.
- Start Vier-Gewinnt → play → 4-in-row detection correct.
Release Policy
- No
pnpm releasecalls during Phases 1–5. Onlygit commitandgit tag(if any) — none of which trigger upload. - Version stays on
0.18.8throughout. The migration files and feature commits accumulate onmain. - After the user signs off on the smoke list, a single
pnpm release 0.19.0 "<combined notes>"bumps and ships everything.
Out of Scope (future)
- Mobile equivalents of any of these.
- Twitch, Vimeo, or self-hosted video in Watch-Together.
- Chess, Pong, or other mini-games beyond the two.
- Operational-transform / CRDT for Whiteboard (post-MVP if collaboration friction shows).
- Cross-conversation pinning / global pin list.
- Mention auto-suggest improvements (existing
MentionAutocompleteis fine). - Skin-tone reaction picker.
- Voice-message transcription.