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

35 KiB
Raw Blame History

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

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