# 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 `` per handle. Phase 2 caps at one image per message; multi-image is a future polish. 3. `` calls `chat.downloadAndDecryptAttachment(...)`, gets a `Uint8Array`, converts to a data URL via `data:;base64,` and renders ``. 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`. 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` | | `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.