docs(mobile): phase 2 spec + plan — image attachments, reactions, reply, delete

9 tasks taking the mobile chat from text-only to feature parity on the
high-impact subset of the desktop's messaging surface:

  1. Add expo-image-picker dep + permission strings in app.json.
  2. imagePicker.ts helper for library + camera capture.
  3. attachmentCache.ts in-memory data-URL cache.
  4. AttachmentImage component with on-demand decrypt.
  5. ReactionStrip (6 emojis) + ReactionPills (count badges).
  6. MessageActionsSheet long-press modal (react/reply/delete).
  7. MessageBubble extraction with reply quote + deleted state.
  8. Wire everything into conversations/[id].tsx.
  9. Workspace typecheck pass.

Explicitly out of scope: file attachments, voice messages, edit,
forward, read receipts, typing, polls. Those move to Phase 2.5 / later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-14 00:14:39 +02:00
parent b1cdc4d184
commit dd6ae63491
2 changed files with 1333 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
# Mobile Phase 2 — Messaging Features
**Date:** 2026-05-14
**Scope:** `apps/mobile`
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
---
## Problem
Phase 1 ships text-only chat. A mobile chat client without image attachments or reactions feels half-built. To make Netralax mobile genuinely competitive — and to call the goal of "features working" satisfied — we need the high-impact subset of the desktop's messaging feature set wired into mobile screens.
## Goal
After Phase 2, a Netralax mobile user can:
1. **Attach an image** to a message — from the photo library or via the camera — and watch it upload, encrypt, and arrive on the desktop client decrypted.
2. **React to a message** with an emoji via long-press → quick reaction strip; see reaction badges underneath the bubble; tap a badge to toggle their own reaction off.
3. **Reply to a message** via long-press → Reply → see the quoted source in a banner above the input; send the message with `replyToId` set; the reply renders the quoted preview on both sides.
4. **Delete an own message** via long-press → Delete → soft-delete confirmation; the bubble flips to "Diese Nachricht wurde gelöscht".
## Non-goals
- File / document attachments (high effort, low frequency on mobile — Phase 2.5).
- Voice messages (record + play UI is a substantial sub-feature — Phase 2.5).
- Edit own message (Phase 2.5).
- Forward (low priority — post-Phase-4).
- Read receipts + delivery state (needs realtime — Phase 1.5).
- Typing indicator (needs realtime — Phase 1.5).
- Polls (post-Phase-4).
- Reaction picker beyond a fixed 6-emoji strip (full picker is post-Phase-4).
- Multi-image gallery (one image per message in Phase 2).
## Design
### 1. New dependency
Add `expo-image-picker` to the mobile workspace. It bundles the OS image-picker + the camera-permission flow.
### 2. Image attachments
`apps/mobile/lib/imagePicker.ts` — wrapper around `expo-image-picker` that requests permissions on demand and returns a `{ uri, mimeType, sizeBytes, width, height }` handle or `null` on cancel.
Sending an image:
1. User taps a `+` button next to the input → `ActionSheet` with "Foto aufnehmen" / "Aus Galerie wählen" / "Abbrechen".
2. The picker returns the URI. The conversation detail loads the URI as a `Blob` via `fetch(uri).then((r) => r.blob())`.
3. Pass to `chat.encryptAndUploadAttachment({ client, conversationId, file, mimeType, sizeBytes, width, height })` — returns an `EncryptedAttachmentResult`.
4. After upload, call `chat.sendEncryptedMessage(...)` with `attachmentHandles: [result.handle]` and empty `plaintext`. `sendEncryptedMessage` writes the message_attachments rows internally.
Rendering an image:
1. `parseMessagePayload(plaintext)` returns either `{ kind: 'text', text, attachments }` or other shapes.
2. For text-with-attachments, the bubble renders the text plus an `<AttachmentImage>` per handle. Phase 2 caps at one image per message; multi-image is a future polish.
3. `<AttachmentImage>` calls `chat.downloadAndDecryptAttachment(...)`, gets a `Uint8Array`, converts to a data URL via `data:<mime>;base64,<b64>` and renders `<Image source={{ uri }} />`.
4. Cache by handle id in memory (`apps/mobile/lib/attachmentCache.ts`) to avoid re-downloading on re-render. No disk cache in Phase 2.
### 3. Reactions
`apps/mobile/components/ReactionStrip.tsx` — horizontal row of 6 hardcoded emoji buttons (👍 ❤️ 😂 😮 😢 🎉) shown inside the long-press modal.
Long-press on a message opens `MessageActionsSheet` (§6 below) which contains the reaction strip + action rows. Tapping an emoji calls `chat.addReaction(supabase, messageId, emoji)` (or `removeReaction` if the user already reacted with that emoji), closes the sheet, and re-fetches.
Display: `chat.listReactionsForMessages(supabase, messageIds)` runs after every message-load, stashed in a `Map<messageId, MessageReaction[]>`. The bubble's footer renders a `flex-row` of `[emoji count]` pills (`ReactionPills.tsx`); pills are tappable to toggle.
### 4. Reply
The reply target lives in a `replyTo: ChatMessage | null` state in `[id].tsx`.
Flow:
1. Long-press → sheet → "Antworten".
2. `setReplyTo(message)`.
3. A banner above the `TextInput` shows quoted sender + first line of body + an `X` to cancel.
4. On send: pass `replyToId: replyTo.id` to `sendEncryptedMessage`, then clear the banner.
Rendering a reply:
- A message with `replyToId` set looks up the parent in the local messages array. If found, render a compact quote line above the body inside the same outer bubble. If not, render "↩ Original-Nachricht außerhalb dieses Fensters".
### 5. Delete own message
Long-press on an own message → sheet → "Löschen" → `Alert.alert` confirm. On confirm: `chat.softDeleteMessage(supabase, messageId)`. The server trigger enforces sender-only + 24h window.
Bubble rendering for `deletedAt !== null`: italic placeholder ("Nachricht gelöscht") in `colors.textMuted`.
### 6. Shared message-action modal
`apps/mobile/components/MessageActionsSheet.tsx` — RN `Modal` with `presentationStyle="overFullScreen"` + `transparent`, rendered conditionally from `[id].tsx`. Props: `message`, `mine`, `onClose`, `onReact`, `onReply`, `onDelete`. The sheet renders the reaction strip + action rows on a `colors.surface` panel that slides from the bottom. Touching the backdrop dismisses.
### 7. Bubble extraction
The Phase-1 `MessageRow` was inlined in `[id].tsx`. Phase 2 extracts it to `apps/mobile/components/MessageBubble.tsx` because it now needs to render:
- Reply quote preview.
- Body text (or "Nachricht gelöscht").
- Attachment image.
- Reaction pills.
- Long-press handler.
The single-responsibility expansion warrants its own file.
## File structure (deltas)
| File | Status | Responsibility |
|---|---|---|
| `apps/mobile/package.json` | MODIFIED | Add `expo-image-picker` |
| `apps/mobile/app.json` | MODIFIED | Add `expo-image-picker` plugin with NS*UsageDescription strings |
| `apps/mobile/lib/imagePicker.ts` | NEW | Permission + pick helper |
| `apps/mobile/lib/attachmentCache.ts` | NEW | In-memory `Map<handleId, dataURL>` |
| `apps/mobile/components/AttachmentImage.tsx` | NEW | Renders an encrypted image attachment |
| `apps/mobile/components/ReactionStrip.tsx` | NEW | 6-emoji quick reactor |
| `apps/mobile/components/ReactionPills.tsx` | NEW | Below-bubble reaction counts |
| `apps/mobile/components/MessageActionsSheet.tsx` | NEW | Long-press modal with reactions + Reply/Delete |
| `apps/mobile/components/MessageBubble.tsx` | NEW | Bubble with text + attachments + reply preview + reactions + deleted state |
| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Wires attachments, reactions, reply, delete; uses `MessageBubble` |
## Risks
- **Image picker permissions on iOS.** `NSPhotoLibraryUsageDescription` + `NSCameraUsageDescription` are required in `Info.plist`. Expo manages them via the `expo-image-picker` plugin in `app.json`.
- **Encrypted-attachment data-URL size.** Decoded images can be several MB; converting to a `data:` URI inflates memory. Phase 2 accepts this with an in-memory LRU-free cache (good enough for a few images).
- **Reaction count race.** Two users react simultaneously → server stores both, local needs to refetch. `listReactionsForMessages` is cheap enough to call after each user reaction.
- **Soft-delete UX without realtime.** Other clients see the deletion only after refetch. Pull-to-refresh propagates; Phase 1.5 realtime would fix this.
## Verification
1. `pnpm --filter @chat-app/mobile typecheck` exits 0.
2. On a real device + the desktop signed into the same account:
- Mobile: snap a photo, send it. Desktop receives and renders it inline.
- Desktop: sends a message. Mobile receives it, long-presses, sends a 👍. Desktop shows the reaction badge.
- Mobile: long-press → Reply → type → send. Desktop shows the threaded reply preview.
- Mobile: long-press own message → Delete → confirm. Both clients show "Nachricht gelöscht" after refresh.
## Out of scope
- File / document attachments.
- Voice messages.
- Edit message.
- Forward.
- Full emoji picker.
- Disk-cached image decryption.
- Realtime subscriptions.
- Conversation creation from mobile.