dd6ae63491
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>
1191 lines
35 KiB
Markdown
1191 lines
35 KiB
Markdown
# 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).
|