Files
ChatApp/docs/superpowers/specs/2026-05-14-mobile-phase-2-messaging-features-design.md
T
byGalax dd6ae63491 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>
2026-05-14 00:14:39 +02:00

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:

  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.