Files
ChatApp/docs/superpowers/specs/2026-05-16-fifteen-features-design.md

16 KiB
Raw Permalink Blame History

Fifteen-Features Initiative — Design

Date: 2026-05-16 Scope: Desktop only (Electron). Mobile is out of scope. Status: Approved by user (sections 16).

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 commit only, no pnpm release. Version stays on 0.18.8 throughout, bumps to 0.19.0 once.
  • 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 34 days
2 Messaging Pinned Messages, Mentions-Notifications, GIF-Picker, View-Once Media ~1 week
3 Security & Devices Session/Device List + Revoke 23 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 keydown listener on window, 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, localStorage except theme + locale + installId.
  • Configurable on app-close (Settings → Security → "Cache beim Schließen leeren"): hooks before-quit in 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.

  • sendEncryptedMessage parses plaintext for @<username> tokens before encrypting. Username → user_id lookup against conversation_members joined with profiles. Bulk-insert into new message_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 on message_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 devices table (telemetry only since the 0.18 encryption refactor).
  • New SettingsPage tab "Geräte" between Sicherheit and Konto. Lists name · platform · last_seen per device, "Dieses Gerät"-badge for the local install.
  • New column devices.revoked_at timestamptz NULL. Revoke RPC revoke_device(p_device_id uuid): requires device.user_id = auth.uid(), sets revoked_at = now().
  • Realtime: every client subscribes devices:user_id=me. On its own revoked_at change → forced signOut() + 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 (reuses crypto/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_soundboards rows 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) and whiteboard_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 on whiteboard_id renders 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_state update. 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.
  • Tic-Tac-Toe: 3×3 board, cell index 08, server validates cell-is-empty + correct turn + win line detection (8 winning lines).
  • Vier-Gewinnt: 7×6 board, column 06 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_mentions rows 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):

  1. Hotkey "M" as Mute, default (window-scoped). Type "m" in Composer → letter appears, mute doesn't fire.
  2. Hotkey "M" toggled to Global → "m" outside the app triggers mute.
  3. Friend sends DM → tray-badge shows 1; click opens app focused.
  4. Empty chat list → "Friend einladen"-button visible + functional.
  5. Right-click friend → set nickname "Bestie" → chat header shows "Bestie".
  6. Sign-out → Storage Inspector shows no Stronghold key, no user-priv, no conv-key cache.
  7. Memory-Wipe toggle on → close app, reopen → PIN required.
  8. Pin a message → conv header shows "📌 1 angepinnt" → click → side panel lists it.
  9. Friend writes "@mich" → notification with [@] prefix + mention sound, even on muted conv.
  10. GIF-Picker → search "cat" → click → GIF sent in chat.
  11. View-once image sent → recipient opens once → bubble shows "Angesehen", image is gone.
  12. Settings → Geräte → revoke another device → that device is force-signed-out.
  13. Send image → "Bearbeiten" → draw arrow → send → arrow is in the final image.
  14. Add soundboard sound on device A → device B (same user, different install) → sound appears.
  15. Open whiteboard → friend draws → strokes appear within 1s.
  16. Start Watch-Together with YouTube URL → friend joins → owner pauses → friend's player pauses.
  17. Start Tic-Tac-Toe → friend accepts → alternate moves → win animation.
  18. Start Vier-Gewinnt → play → 4-in-row detection correct.

Release Policy

  • No pnpm release calls during Phases 15. Only git commit and git tag (if any) — none of which trigger upload.
  • Version stays on 0.18.8 throughout. The migration files and feature commits accumulate on main.
  • 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 MentionAutocomplete is fine).
  • Skin-tone reaction picker.
  • Voice-message transcription.