Files
ChatApp/docs/superpowers/plans/2026-05-14-mobile-phase-2-messaging-features.md
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

1191 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<PickedImage | null> {
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<PickedImage | null> {
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<string, string>();
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<string | null>(() => getCachedAttachment(handle.id) ?? null);
const [error, setError] = useState<string | null>(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 (
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
<Text style={styles.errorText}>🔒 {error}</Text>
</View>
);
}
if (!dataUrl) {
return (
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
return (
<Image
source={{ uri: dataUrl }}
style={[styles.image, { aspectRatio: aspect }]}
resizeMode="cover"
/>
);
}
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 (
<View style={styles.row}>
{QUICK_REACTIONS.map((e) => (
<Pressable
key={e}
onPress={() => onReact(e)}
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
hitSlop={6}
>
<Text style={styles.emoji}>{e}</Text>
</Pressable>
))}
</View>
);
}
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 (
<View style={styles.row}>
{grouped.map((g) => (
<Pressable
key={g.emoji}
onPress={() => onToggle(g.emoji)}
style={[styles.pill, g.mine && styles.pillMine]}
>
<Text style={styles.emoji}>{g.emoji}</Text>
<Text style={[styles.count, g.mine && styles.countMine]}>{g.count}</Text>
</Pressable>
))}
</View>
);
}
function groupByEmoji(
rows: MessageReaction[],
myUserId: string | null,
): Array<{ emoji: string; count: number; mine: boolean }> {
const m = new Map<string, { count: number; mine: boolean }>();
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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={() => undefined}>
<ReactionStrip
onReact={(e) => {
onReact(e);
onClose();
}}
/>
<Action label="Antworten" onPress={() => { onReply(); onClose(); }} />
{mine && (
<Action
label="Löschen"
danger
onPress={() => { onDelete(); onClose(); }}
/>
)}
<Action label="Abbrechen" muted onPress={onClose} />
</Pressable>
</Pressable>
</Modal>
);
}
function Action({
label,
danger,
muted,
onPress,
}: {
label: string;
danger?: boolean;
muted?: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
>
<Text style={[styles.actionText, danger && styles.actionDanger, muted && styles.actionMuted]}>
{label}
</Text>
</Pressable>
);
}
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 (
<View style={[styles.wrap, mine ? styles.wrapMine : styles.wrapOther]}>
<Pressable
onLongPress={onLongPress}
delayLongPress={250}
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}
>
{!mine && !deleted && <Text style={styles.sender}>{senderName}</Text>}
{message.replyToId && (
<View style={[styles.replyQuote, mine ? styles.replyQuoteMine : null]}>
<Text style={styles.replyAuthor}>{parent ? parentSenderName : 'Original'}</Text>
<Text style={styles.replyBody} numberOfLines={2}>
{parent
? quotedPreview(parent)
: '↩ Original-Nachricht außerhalb dieses Fensters'}
</Text>
</View>
)}
{deleted ? (
<Text style={styles.deleted}>Nachricht gelöscht</Text>
) : (
<>
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
{firstImage && ownDeviceId && ownPrivateKey && (
<AttachmentImage
handle={firstImage}
ownDeviceId={ownDeviceId}
ownPrivateKey={ownPrivateKey}
/>
)}
</>
)}
<Text style={styles.time}>{time}</Text>
</Pressable>
{!deleted && (
<ReactionPills
reactions={reactions}
myUserId={myUserId}
onToggle={onToggleReaction}
/>
)}
</View>
);
}
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<ConversationSummary | null>(null);
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
const [reactions, setReactions] = useState<Map<string, MessageReaction[]>>(new Map());
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [activeMessage, setActiveMessage] = useState<DecryptedMessage | null>(null);
const listRef = useRef<FlatList<DecryptedMessage>>(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<string, MessageReaction[]>();
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<string, MessageReaction[]>();
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<string, DecryptedMessage>();
for (const msg of messages ?? []) m.set(msg.id, msg);
return m;
}, [messages]);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 80 : 0}
>
<Stack.Screen
options={{
title,
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerBackTitle: 'Chats',
}}
/>
{messages === null && !error && (
<View style={styles.loading}>
<ActivityIndicator color={colors.accent} />
</View>
)}
{error && <Text style={styles.error}>{error}</Text>}
{messages && (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => {
const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null;
return (
<MessageBubble
message={item}
mine={item.senderId === user?.id}
senderName={senderName(item.senderId)}
parent={parent}
parentSenderName={parent ? senderName(parent.senderId) : ''}
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
reactions={reactions.get(item.id) ?? []}
myUserId={user?.id ?? null}
ownDeviceId={device?.id ?? null}
ownPrivateKey={ownPrivateKey}
onLongPress={() => setActiveMessage(item)}
onToggleReaction={(emoji) => {
void handleReact(emoji, item.id);
}}
/>
);
}}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
/>
)}
{replyTo && (
<View style={styles.replyBanner}>
<View style={styles.replyBannerLeft}>
<Text style={styles.replyBannerLabel}>
Antwort an {senderName(replyTo.senderId)}
</Text>
<Text style={styles.replyBannerBody} numberOfLines={1}>
{replyTo.plaintext ?? '…'}
</Text>
</View>
<Pressable onPress={() => setReplyTo(null)} hitSlop={10}>
<Text style={styles.replyBannerClose}>×</Text>
</Pressable>
</View>
)}
<View style={styles.inputRow}>
<Pressable
style={styles.plusBtn}
onPress={openImageMenu}
disabled={sending}
hitSlop={6}
>
<Text style={styles.plusText}></Text>
</Pressable>
<TextInput
value={text}
onChangeText={setText}
placeholder="Nachricht schreiben…"
placeholderTextColor={colors.textDim}
style={styles.input}
multiline
editable={!sending}
/>
<Pressable
style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
disabled={!text.trim() || sending}
onPress={handleSendText}
>
{sending ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.sendBtnText}>Senden</Text>
)}
</Pressable>
</View>
<MessageActionsSheet
visible={activeMessage !== null}
mine={activeMessage?.senderId === user?.id}
onClose={() => setActiveMessage(null)}
onReact={(emoji) => {
if (activeMessage) void handleReact(emoji, activeMessage.id);
}}
onReply={() => {
if (activeMessage) setReplyTo(activeMessage);
}}
onDelete={() => {
if (activeMessage) handleDelete(activeMessage.id);
}}
/>
</KeyboardAvoidingView>
);
}
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).