# 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.
