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>
35 KiB
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(viapnpm add) -
Modify:
apps/mobile/app.json -
Step 1: Install the dep
pnpm --filter @chat-app/mobile add expo-image-picker
- Step 2: Register the plugin in
apps/mobile/app.json
Find the plugins array — currently:
"plugins": [
"expo-router",
"expo-secure-store",
"expo-sqlite",
[
"expo-notifications",
{
"color": "#0b0b0f"
}
]
],
Replace with:
"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
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
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
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
// 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
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
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
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
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
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
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
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
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
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
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
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
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
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).