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:
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.
|
||||
Reference in New Issue
Block a user