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