From dd6ae634910c62963e92293e16f7266048612891 Mon Sep 17 00:00:00 2001 From: byGalax Date: Thu, 14 May 2026 00:14:39 +0200 Subject: [PATCH] =?UTF-8?q?docs(mobile):=20phase=202=20spec=20+=20plan=20?= =?UTF-8?q?=E2=80=94=20image=20attachments,=20reactions,=20reply,=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...05-14-mobile-phase-2-messaging-features.md | 1190 +++++++++++++++++ ...obile-phase-2-messaging-features-design.md | 143 ++ 2 files changed, 1333 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-mobile-phase-2-messaging-features.md create mode 100644 docs/superpowers/specs/2026-05-14-mobile-phase-2-messaging-features-design.md diff --git a/docs/superpowers/plans/2026-05-14-mobile-phase-2-messaging-features.md b/docs/superpowers/plans/2026-05-14-mobile-phase-2-messaging-features.md new file mode 100644 index 0000000..17e3245 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-mobile-phase-2-messaging-features.md @@ -0,0 +1,1190 @@ +# Mobile Phase 2 — Messaging Features Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. Implementer typechecks before every commit. + +**Goal:** Image attachments, reactions, reply, and delete-own-message work end-to-end on the mobile app. + +**Architecture:** Plug `expo-image-picker` into a new `imagePicker.ts` helper; use `chat.encryptAndUploadAttachment` + `sendEncryptedMessage` for sending; `downloadAndDecryptAttachment` + an in-memory cache for rendering. Reactions, reply, and delete share a `MessageActionsSheet` modal opened on long-press; the message rendering moves into a dedicated `MessageBubble` component. + +**Tech Stack:** Expo SDK 52, RN 0.76, expo-router 4, expo-image-picker (added Task 1), @chat-app/shared, TypeScript 5.6. + +**Spec:** `docs/superpowers/specs/2026-05-14-mobile-phase-2-messaging-features-design.md` + +**Testing note:** No device emulator in this loop. Each task gate: `pnpm --filter @chat-app/mobile typecheck` + code review against spec. End-to-end verification is on the user's hardware. + +--- + +## File structure (touchpoints) + +| File | Status | +|---|---| +| `apps/mobile/package.json` | MODIFIED — add expo-image-picker | +| `apps/mobile/app.json` | MODIFIED — add expo-image-picker plugin + permission strings | +| `apps/mobile/lib/imagePicker.ts` | NEW | +| `apps/mobile/lib/attachmentCache.ts` | NEW | +| `apps/mobile/components/AttachmentImage.tsx` | NEW | +| `apps/mobile/components/ReactionStrip.tsx` | NEW | +| `apps/mobile/components/ReactionPills.tsx` | NEW | +| `apps/mobile/components/MessageActionsSheet.tsx` | NEW | +| `apps/mobile/components/MessageBubble.tsx` | NEW | +| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED — uses every new component | + +--- + +## Task 1: Add `expo-image-picker` + plugin config + +**Files:** +- Modify: `apps/mobile/package.json` (via `pnpm add`) +- Modify: `apps/mobile/app.json` + +- [ ] **Step 1: Install the dep** + +```bash +pnpm --filter @chat-app/mobile add expo-image-picker +``` + +- [ ] **Step 2: Register the plugin in `apps/mobile/app.json`** + +Find the `plugins` array — currently: + +```json + "plugins": [ + "expo-router", + "expo-secure-store", + "expo-sqlite", + [ + "expo-notifications", + { + "color": "#0b0b0f" + } + ] + ], +``` + +Replace with: + +```json + "plugins": [ + "expo-router", + "expo-secure-store", + "expo-sqlite", + [ + "expo-notifications", + { + "color": "#0b0b0f" + } + ], + [ + "expo-image-picker", + { + "photosPermission": "Netralax greift auf deine Fotos zu, damit du sie in Nachrichten teilen kannst.", + "cameraPermission": "Netralax nutzt die Kamera für Fotos in Nachrichten." + } + ] + ], +``` + +This is what Expo translates into `NSPhotoLibraryUsageDescription` + `NSCameraUsageDescription` in the generated `Info.plist`. + +- [ ] **Step 3: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/package.json pnpm-lock.yaml apps/mobile/app.json +git commit -m "chore(mobile): add expo-image-picker dep + permission strings" +``` + +--- + +## Task 2: `lib/imagePicker.ts` + +**Files:** +- Create: `apps/mobile/lib/imagePicker.ts` + +- [ ] **Step 1: Create the file** + +```ts +import * as ImagePicker from 'expo-image-picker'; + +export interface PickedImage { + uri: string; + mimeType: string; + sizeBytes: number; + width: number; + height: number; +} + +// Pick an image from the photo library. Requests permission on demand; +// returns null on cancel / denial. +export async function pickFromLibrary(): Promise { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) return null; + const res = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.85, + base64: false, + exif: false, + }); + if (res.canceled || res.assets.length === 0) return null; + return toPicked(res.assets[0]); +} + +// Capture an image via the device camera. Same return shape as +// pickFromLibrary so call sites can stay shape-agnostic. +export async function captureFromCamera(): Promise { + const perm = await ImagePicker.requestCameraPermissionsAsync(); + if (!perm.granted) return null; + const res = await ImagePicker.launchCameraAsync({ + quality: 0.85, + base64: false, + exif: false, + }); + if (res.canceled || res.assets.length === 0) return null; + return toPicked(res.assets[0]); +} + +function toPicked(asset: ImagePicker.ImagePickerAsset): PickedImage { + return { + uri: asset.uri, + mimeType: asset.mimeType ?? 'image/jpeg', + sizeBytes: asset.fileSize ?? 0, + width: asset.width, + height: asset.height, + }; +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/lib/imagePicker.ts +git commit -m "feat(mobile): imagePicker helper with library + camera flows" +``` + +--- + +## Task 3: `lib/attachmentCache.ts` + +**Files:** +- Create: `apps/mobile/lib/attachmentCache.ts` + +- [ ] **Step 1: Create the file** + +```ts +// Module-level memo of decrypted-attachment data URLs by handle id. +// Survives screen unmounts (e.g. user pops in and out of a conversation) +// but evicts on app restart — good enough for Phase 2; an LRU + disk +// cache is a later polish. + +const cache = new Map(); + +export function getCachedAttachment(id: string): string | undefined { + return cache.get(id); +} + +export function setCachedAttachment(id: string, dataUrl: string): void { + cache.set(id, dataUrl); +} + +export function clearAttachmentCache(): void { + cache.clear(); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/lib/attachmentCache.ts +git commit -m "feat(mobile): in-memory attachment cache by handle id" +``` + +--- + +## Task 4: `components/AttachmentImage.tsx` + +**Files:** +- Create: `apps/mobile/components/AttachmentImage.tsx` + +- [ ] **Step 1: Create the file** + +```tsx +import { chat } from '@chat-app/shared'; +import type { AttachmentHandle } from '@chat-app/shared/chat'; +import { Buffer } from 'buffer'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native'; + +import { getCachedAttachment, setCachedAttachment } from '../lib/attachmentCache'; +import { supabase } from '../lib/supabase'; +import { colors } from '../theme/colors'; + +interface Props { + handle: AttachmentHandle; + ownDeviceId: string; + ownPrivateKey: Uint8Array; +} + +// Decrypts an encrypted image attachment on first mount and renders it +// inline. Subsequent mounts hit the in-memory cache. Failure (key not +// shared yet for this device, network error, etc.) shows a small error +// placeholder rather than crashing the parent message bubble. +export function AttachmentImage({ handle, ownDeviceId, ownPrivateKey }: Props) { + const [dataUrl, setDataUrl] = useState(() => getCachedAttachment(handle.id) ?? null); + const [error, setError] = useState(null); + + useEffect(() => { + if (dataUrl) return; + let cancelled = false; + void (async () => { + try { + const bytes = await chat.downloadAndDecryptAttachment({ + client: supabase, + handle, + ownDeviceId, + ownPrivateKey, + }); + const b64 = Buffer.from(bytes).toString('base64'); + const url = 'data:' + handle.mimeType + ';base64,' + b64; + if (cancelled) return; + setCachedAttachment(handle.id, url); + setDataUrl(url); + } catch (err: unknown) { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'decrypt failed'); + } + })(); + return () => { + cancelled = true; + }; + }, [dataUrl, handle, ownDeviceId, ownPrivateKey]); + + // Aspect ratio honoured if available; fall back to a 4:3 placeholder. + const aspect = + handle.width && handle.height && handle.height > 0 ? handle.width / handle.height : 4 / 3; + + if (error) { + return ( + + 🔒 {error} + + ); + } + if (!dataUrl) { + return ( + + + + ); + } + return ( + + ); +} + +const styles = StyleSheet.create({ + image: { + width: '100%', + borderRadius: 8, + marginTop: 4, + }, + placeholder: { + width: '100%', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.surface, + borderRadius: 8, + borderColor: colors.border, + borderWidth: 1, + marginTop: 4, + }, + errorText: { color: colors.danger, fontSize: 12 }, +}); +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/components/AttachmentImage.tsx +git commit -m "feat(mobile): AttachmentImage component with on-demand decrypt + cache" +``` + +--- + +## Task 5: `components/ReactionStrip.tsx` + `components/ReactionPills.tsx` + +**Files:** +- Create: `apps/mobile/components/ReactionStrip.tsx`, `apps/mobile/components/ReactionPills.tsx` + +- [ ] **Step 1: Create `apps/mobile/components/ReactionStrip.tsx`** + +```tsx +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../theme/colors'; + +// Hardcoded 6-emoji quick reactor shown at the top of the long-press +// sheet. A full emoji picker is post-Phase-4; this is the Discord-style +// fast path the vast majority of reactions go through. +export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🎉'] as const; + +export function ReactionStrip({ onReact }: { onReact: (emoji: string) => void }) { + return ( + + {QUICK_REACTIONS.map((e) => ( + onReact(e)} + style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]} + hitSlop={6} + > + {e} + + ))} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingVertical: 12, + paddingHorizontal: 8, + borderBottomColor: colors.border, + borderBottomWidth: 1, + }, + button: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 22, + }, + buttonPressed: { backgroundColor: colors.accentMuted }, + emoji: { fontSize: 26 }, +}); +``` + +- [ ] **Step 2: Create `apps/mobile/components/ReactionPills.tsx`** + +```tsx +import type { MessageReaction } from '@chat-app/shared/chat'; +import { useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../theme/colors'; + +interface Props { + reactions: MessageReaction[]; + myUserId: string | null; + onToggle: (emoji: string) => void; +} + +// Renders the per-message reaction badges underneath a bubble. Pills +// the current user has reacted to get a tinted background so they know +// which ones to tap to un-react. +export function ReactionPills({ reactions, myUserId, onToggle }: Props) { + const grouped = useMemo(() => groupByEmoji(reactions, myUserId), [reactions, myUserId]); + if (grouped.length === 0) return null; + return ( + + {grouped.map((g) => ( + onToggle(g.emoji)} + style={[styles.pill, g.mine && styles.pillMine]} + > + {g.emoji} + {g.count} + + ))} + + ); +} + +function groupByEmoji( + rows: MessageReaction[], + myUserId: string | null, +): Array<{ emoji: string; count: number; mine: boolean }> { + const m = new Map(); + for (const r of rows) { + const prev = m.get(r.emoji) ?? { count: 0, mine: false }; + m.set(r.emoji, { + count: prev.count + 1, + mine: prev.mine || (myUserId !== null && r.userId === myUserId), + }); + } + return Array.from(m.entries()).map(([emoji, v]) => ({ emoji, ...v })); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 }, + pill: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, + backgroundColor: colors.surface, + borderColor: colors.border, + borderWidth: 1, + gap: 4, + }, + pillMine: { backgroundColor: colors.accentMuted, borderColor: colors.accent }, + emoji: { fontSize: 13 }, + count: { color: colors.textMuted, fontSize: 12, fontWeight: '600' }, + countMine: { color: colors.accent }, +}); +``` + +- [ ] **Step 3: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/components/ReactionStrip.tsx apps/mobile/components/ReactionPills.tsx +git commit -m "feat(mobile): ReactionStrip quick-reactor + ReactionPills display" +``` + +--- + +## Task 6: `components/MessageActionsSheet.tsx` + +**Files:** +- Create: `apps/mobile/components/MessageActionsSheet.tsx` + +- [ ] **Step 1: Create the file** + +```tsx +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { ReactionStrip } from './ReactionStrip'; +import { colors } from '../theme/colors'; + +interface Props { + visible: boolean; + mine: boolean; + onClose: () => void; + onReact: (emoji: string) => void; + onReply: () => void; + onDelete: () => void; +} + +// Bottom-sheet-style modal opened on long-press of a message bubble. +// Contains the reaction quick-strip plus the action rows. The "Löschen" +// row is hidden for messages not authored by the current user — the +// server-side trigger would refuse anyway, but trimming UI keeps the +// surface honest. +export function MessageActionsSheet({ + visible, + mine, + onClose, + onReact, + onReply, + onDelete, +}: Props) { + return ( + + + undefined}> + { + onReact(e); + onClose(); + }} + /> + { onReply(); onClose(); }} /> + {mine && ( + { onDelete(); onClose(); }} + /> + )} + + + + + ); +} + +function Action({ + label, + danger, + muted, + onPress, +}: { + label: string; + danger?: boolean; + muted?: boolean; + onPress: () => void; +}) { + return ( + [styles.action, pressed && styles.actionPressed]} + > + + {label} + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingBottom: 24, + }, + action: { + paddingVertical: 14, + paddingHorizontal: 20, + borderBottomColor: colors.border, + borderBottomWidth: 1, + }, + actionPressed: { backgroundColor: colors.bg }, + actionText: { color: colors.text, fontSize: 15, fontWeight: '500' }, + actionDanger: { color: colors.danger }, + actionMuted: { color: colors.textMuted }, +}); +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/components/MessageActionsSheet.tsx +git commit -m "feat(mobile): MessageActionsSheet bottom modal — react/reply/delete" +``` + +--- + +## Task 7: `components/MessageBubble.tsx` + +**Files:** +- Create: `apps/mobile/components/MessageBubble.tsx` + +- [ ] **Step 1: Create the file** + +```tsx +import { chat as chatNs } from '@chat-app/shared'; +import type { DecryptedMessage, MessageReaction } from '@chat-app/shared/chat'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../theme/colors'; +import { AttachmentImage } from './AttachmentImage'; +import { ReactionPills } from './ReactionPills'; + +interface Props { + message: DecryptedMessage; + mine: boolean; + senderName: string; + time: string; + parent: DecryptedMessage | null; + parentSenderName: string; + reactions: MessageReaction[]; + myUserId: string | null; + ownDeviceId: string | null; + ownPrivateKey: Uint8Array | null; + onLongPress: () => void; + onToggleReaction: (emoji: string) => void; +} + +// Single message bubble. Renders, in order: +// * Reply quote (when this message has a replyToId). +// * Body text (or "Nachricht gelöscht" placeholder). +// * Image attachment (max 1 in Phase 2 — first one wins). +// * Reaction pills. +// Long-press surfaces the MessageActionsSheet via the onLongPress callback. +export function MessageBubble({ + message, + mine, + senderName, + time, + parent, + parentSenderName, + reactions, + myUserId, + ownDeviceId, + ownPrivateKey, + onLongPress, + onToggleReaction, +}: Props) { + const deleted = message.deletedAt !== null; + const parsed = chatNs.parseMessagePayload(message.plaintext); + const text = parsed.kind === 'text' ? parsed.text : ''; + const attachments = parsed.kind === 'text' ? parsed.attachments : []; + const firstImage = attachments.find((a) => a.mimeType.startsWith('image/')); + + return ( + + + {!mine && !deleted && {senderName}} + + {message.replyToId && ( + + {parent ? parentSenderName : 'Original'} + + {parent + ? quotedPreview(parent) + : '↩ Original-Nachricht außerhalb dieses Fensters'} + + + )} + + {deleted ? ( + Nachricht gelöscht + ) : ( + <> + {text.length > 0 && {text}} + {firstImage && ownDeviceId && ownPrivateKey && ( + + )} + + )} + + {time} + + + {!deleted && ( + + )} + + ); +} + +function quotedPreview(m: DecryptedMessage): string { + if (m.deletedAt) return '[gelöscht]'; + const parsed = chatNs.parseMessagePayload(m.plaintext); + if (parsed.kind === 'text') { + if (parsed.text.length > 0) return parsed.text; + if (parsed.attachments.length > 0) return '📎 Anhang'; + } + return '…'; +} + +const styles = StyleSheet.create({ + wrap: { marginVertical: 4, maxWidth: '78%' }, + wrapMine: { alignSelf: 'flex-end', alignItems: 'flex-end' }, + wrapOther: { alignSelf: 'flex-start', alignItems: 'flex-start' }, + bubble: { padding: 10, borderRadius: 14 }, + bubbleMine: { backgroundColor: colors.accent }, + bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 }, + sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 }, + body: { color: colors.text, fontSize: 15, lineHeight: 20 }, + time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' }, + deleted: { color: colors.textMuted, fontSize: 14, fontStyle: 'italic' }, + replyQuote: { + borderLeftWidth: 3, + borderLeftColor: colors.accent, + paddingLeft: 8, + paddingVertical: 4, + marginBottom: 6, + backgroundColor: colors.bg, + borderRadius: 4, + }, + replyQuoteMine: { backgroundColor: 'rgba(0,0,0,0.18)', borderLeftColor: colors.text }, + replyAuthor: { color: colors.textMuted, fontSize: 11, fontWeight: '700' }, + replyBody: { color: colors.text, fontSize: 13, opacity: 0.85 }, +}); +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add apps/mobile/components/MessageBubble.tsx +git commit -m "feat(mobile): MessageBubble with reply, attachment, reactions, delete state" +``` + +--- + +## Task 8: Rewrite `app/(app)/conversations/[id].tsx` + +**Files:** +- Modify: `apps/mobile/app/(app)/conversations/[id].tsx` + +- [ ] **Step 1: Replace the file** + +```tsx +import { chat } from '@chat-app/shared'; +import type { + ConversationSummary, + DecryptedMessage, + MessageReaction, +} from '@chat-app/shared/chat'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionSheetIOS, + ActivityIndicator, + Alert, + FlatList, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +import { MessageActionsSheet } from '../../../components/MessageActionsSheet'; +import { MessageBubble } from '../../../components/MessageBubble'; +import { useAuth } from '../../../lib/authContext'; +import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker'; +import { supabase } from '../../../lib/supabase'; +import { colors } from '../../../theme/colors'; + +export default function ConversationDetail() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { user, device, ownPrivateKey } = useAuth(); + const [conversation, setConversation] = useState(null); + const [messages, setMessages] = useState(null); + const [reactions, setReactions] = useState>(new Map()); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [replyTo, setReplyTo] = useState(null); + const [activeMessage, setActiveMessage] = useState(null); + const listRef = useRef>(null); + + const load = useCallback(async () => { + if (!id || !device || !ownPrivateKey) return; + setError(null); + try { + const all = await chat.listConversations(supabase); + setConversation(all.find((c) => c.id === id) ?? null); + const ciphers = await chat.fetchConversationMessages(supabase, id, 50); + const decrypted = await chat.decryptMessages({ + client: supabase, + messages: ciphers, + ownDeviceId: device.id, + ownPrivateKey, + }); + setMessages(decrypted); + const rows = await chat.listReactionsForMessages( + supabase, + decrypted.map((m) => m.id), + ); + const map = new Map(); + for (const r of rows) { + const arr = map.get(r.messageId) ?? []; + arr.push(r); + map.set(r.messageId, arr); + } + setReactions(map); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden'); + } + }, [id, device, ownPrivateKey]); + + useEffect(() => { + void load(); + }, [load]); + + const members = useMemo(() => conversation?.members ?? [], [conversation]); + const senderName = useCallback( + (senderId: string) => { + if (senderId === user?.id) return 'Du'; + const m = members.find((mm) => mm.userId === senderId); + return m?.profile?.displayName ?? m?.profile?.username ?? 'Unbekannt'; + }, + [members, user], + ); + + const title = + conversation?.type === 'group' + ? (conversation.name ?? 'Gruppe') + : (conversation?.peer?.displayName ?? '…'); + + async function handleSendText() { + if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return; + setSending(true); + setError(null); + const replyToId = replyTo?.id; + try { + await chat.sendEncryptedMessage({ + client: supabase, + conversationId: id, + plaintext: text.trim(), + senderUserId: user.id, + senderDeviceId: device.id, + senderPrivateKey: ownPrivateKey, + ...(replyToId ? { replyToId } : {}), + }); + setText(''); + setReplyTo(null); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen'); + } finally { + setSending(false); + } + } + + async function sendImage(pick: PickedImage) { + if (!user || !device || !ownPrivateKey || !id) return; + setSending(true); + setError(null); + try { + const resp = await fetch(pick.uri); + const blob = await resp.blob(); + const result = await chat.encryptAndUploadAttachment({ + client: supabase, + conversationId: id, + file: blob, + mimeType: pick.mimeType, + sizeBytes: pick.sizeBytes, + width: pick.width, + height: pick.height, + }); + await chat.sendEncryptedMessage({ + client: supabase, + conversationId: id, + plaintext: '', + senderUserId: user.id, + senderDeviceId: device.id, + senderPrivateKey: ownPrivateKey, + attachmentHandles: [result.handle], + }); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Bild senden fehlgeschlagen'); + } finally { + setSending(false); + } + } + + function openImageMenu() { + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { options: ['Foto aufnehmen', 'Aus Galerie wählen', 'Abbrechen'], cancelButtonIndex: 2 }, + async (idx) => { + if (idx === 0) { + const p = await captureFromCamera(); + if (p) await sendImage(p); + } else if (idx === 1) { + const p = await pickFromLibrary(); + if (p) await sendImage(p); + } + }, + ); + } else { + Alert.alert('Bild senden', undefined, [ + { + text: 'Foto aufnehmen', + onPress: async () => { + const p = await captureFromCamera(); + if (p) await sendImage(p); + }, + }, + { + text: 'Aus Galerie wählen', + onPress: async () => { + const p = await pickFromLibrary(); + if (p) await sendImage(p); + }, + }, + { text: 'Abbrechen', style: 'cancel' }, + ]); + } + } + + async function handleReact(emoji: string, messageId: string) { + if (!user) return; + try { + const mine = reactions.get(messageId)?.some((r) => r.userId === user.id && r.emoji === emoji); + if (mine) { + await chat.removeReaction(supabase, messageId, emoji); + } else { + await chat.addReaction(supabase, messageId, emoji); + } + const rows = await chat.listReactionsForMessages( + supabase, + (messages ?? []).map((m) => m.id), + ); + const map = new Map(); + for (const r of rows) { + const arr = map.get(r.messageId) ?? []; + arr.push(r); + map.set(r.messageId, arr); + } + setReactions(map); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Reaktion fehlgeschlagen'); + } + } + + function handleDelete(messageId: string) { + Alert.alert('Nachricht löschen', 'Diese Nachricht für alle löschen?', [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Löschen', + style: 'destructive', + onPress: async () => { + try { + await chat.softDeleteMessage(supabase, messageId); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen'); + } + }, + }, + ]); + } + + const parentLookup = useMemo(() => { + const m = new Map(); + for (const msg of messages ?? []) m.set(msg.id, msg); + return m; + }, [messages]); + + return ( + + + + {messages === null && !error && ( + + + + )} + + {error && {error}} + + {messages && ( + m.id} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => { + const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null; + return ( + setActiveMessage(item)} + onToggleReaction={(emoji) => { + void handleReact(emoji, item.id); + }} + /> + ); + }} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + /> + )} + + {replyTo && ( + + + + Antwort an {senderName(replyTo.senderId)} + + + {replyTo.plaintext ?? '…'} + + + setReplyTo(null)} hitSlop={10}> + × + + + )} + + + + + + + + {sending ? ( + + ) : ( + Senden + )} + + + + setActiveMessage(null)} + onReact={(emoji) => { + if (activeMessage) void handleReact(emoji, activeMessage.id); + }} + onReply={() => { + if (activeMessage) setReplyTo(activeMessage); + }} + onDelete={() => { + if (activeMessage) handleDelete(activeMessage.id); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + error: { color: colors.danger, padding: 12, textAlign: 'center' }, + listContent: { padding: 12 }, + replyBanner: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.surface, + gap: 12, + }, + replyBannerLeft: { flex: 1 }, + replyBannerLabel: { color: colors.accent, fontSize: 12, fontWeight: '700' }, + replyBannerBody: { color: colors.textMuted, fontSize: 13, marginTop: 2 }, + replyBannerClose: { color: colors.textMuted, fontSize: 22, paddingHorizontal: 6 }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + padding: 8, + gap: 8, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.surface, + }, + plusBtn: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bg, + borderRadius: 10, + borderColor: colors.border, + borderWidth: 1, + }, + plusText: { color: colors.text, fontSize: 20, lineHeight: 22 }, + input: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.text, + backgroundColor: colors.bg, + borderColor: colors.border, + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + }, + sendBtn: { + backgroundColor: colors.accent, + borderRadius: 10, + paddingHorizontal: 16, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + sendBtnDisabled: { opacity: 0.5 }, + sendBtnText: { color: colors.text, fontWeight: '600' }, +}); +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +pnpm --filter @chat-app/mobile typecheck +git add 'apps/mobile/app/(app)/conversations/[id].tsx' +git commit -m "feat(mobile): wire image attachments, reactions, reply, delete into conversation detail" +``` + +--- + +## Task 9: End-to-end typecheck + +- [ ] **Step 1: Workspace typecheck** + +```bash +pnpm typecheck +``` + +Expected: exit 0 across all 8 packages. + +- [ ] **Step 2: No commit at this step** + +--- + +## Self-Review Notes + +**Spec coverage:** §2 attachments → T1-T4, T8. §3 reactions → T5, T6, T8. §4 reply → T6, T7, T8. §5 delete → T6, T7, T8. §6 sheet → T6. §7 bubble → T7. + +**Type consistency:** `MessageReaction` imported from `@chat-app/shared/chat` consistently. `parseMessagePayload` accessed via `chatNs` to avoid name conflict. + +**Known follow-ups:** reaction refetch on every toggle (later: optimistic); attachment cache in-memory only (later: disk); one image per message (later: gallery). diff --git a/docs/superpowers/specs/2026-05-14-mobile-phase-2-messaging-features-design.md b/docs/superpowers/specs/2026-05-14-mobile-phase-2-messaging-features-design.md new file mode 100644 index 0000000..bd48ae5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-mobile-phase-2-messaging-features-design.md @@ -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 `` 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.