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

200 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <time>". Storage object is deleted server-side via RPC `mark_attachment_viewed(p_attachment_id)` triggered atomically with the view.
- 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 <Funktion>". 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.