docs(spec): fifteen-features initiative (0.19.0) — 5 phases, single end-of-batch release
This commit is contained in:
@@ -0,0 +1,199 @@
|
|||||||
|
# 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 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 | 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 `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 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_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 1–5. 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.
|
||||||
Reference in New Issue
Block a user