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>
8.1 KiB
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:
- 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.
- 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.
- Reply to a message via long-press → Reply → see the quoted source in a banner above the input; send the message with
replyToIdset; the reply renders the quoted preview on both sides. - 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:
- User taps a
+button next to the input →ActionSheetwith "Foto aufnehmen" / "Aus Galerie wählen" / "Abbrechen". - The picker returns the URI. The conversation detail loads the URI as a
Blobviafetch(uri).then((r) => r.blob()). - Pass to
chat.encryptAndUploadAttachment({ client, conversationId, file, mimeType, sizeBytes, width, height })— returns anEncryptedAttachmentResult. - After upload, call
chat.sendEncryptedMessage(...)withattachmentHandles: [result.handle]and emptyplaintext.sendEncryptedMessagewrites the message_attachments rows internally.
Rendering an image:
parseMessagePayload(plaintext)returns either{ kind: 'text', text, attachments }or other shapes.- 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. <AttachmentImage>callschat.downloadAndDecryptAttachment(...), gets aUint8Array, converts to a data URL viadata:<mime>;base64,<b64>and renders<Image source={{ uri }} />.- 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:
- Long-press → sheet → "Antworten".
setReplyTo(message).- A banner above the
TextInputshows quoted sender + first line of body + anXto cancel. - On send: pass
replyToId: replyTo.idtosendEncryptedMessage, then clear the banner.
Rendering a reply:
- A message with
replyToIdset 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+NSCameraUsageDescriptionare required inInfo.plist. Expo manages them via theexpo-image-pickerplugin inapp.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.
listReactionsForMessagesis 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
pnpm --filter @chat-app/mobile typecheckexits 0.- 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.