feat(matrix-mobile): add reactions, read receipts, forward & fix DM encryption

Quick wins for the mobile Matrix client:
- Populate reactions from m.reaction timeline events in store
- Display read receipt avatars next to message timestamps
- Add reaction details modal (long-press reaction to see who reacted)
- Add message forwarding with room picker modal
- Add Forward option to message long-press context menu
- Listen for Room.receipt events to update read receipts live
- Remove misleading E2EE encryption state from DM creation
  (crypto not yet implemented, was giving false sense of security)
- Fix FlatList keyExtractor for unread separator items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-06 12:37:22 +01:00
parent a4a7032515
commit a9c05ca46b
4 changed files with 243 additions and 20 deletions

View file

@ -3,6 +3,7 @@ import {
View,
FlatList,
Text,
TextInput,
Pressable,
ActivityIndicator,
Modal,
@ -27,7 +28,7 @@ import UserProfileModal from '~/src/components/UserProfileModal';
import VoiceRecorder from '~/src/components/VoiceRecorder';
import UnreadSeparator from '~/src/components/UnreadSeparator';
import { getMimetypeFromFilename } from '~/src/matrix/upload';
import type { SimpleMessage, RoomMember } from '~/src/matrix/types';
import type { SimpleMessage, SimpleRoom, RoomMember } from '~/src/matrix/types';
type ListItem =
| { type: 'message'; data: SimpleMessage }
@ -111,12 +112,14 @@ export default function RoomScreen() {
const [showMembers, setShowMembers] = useState(false);
const [viewingImage, setViewingImage] = useState<string | null>(null);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const [forwardingMessage, setForwardingMessage] = useState<SimpleMessage | null>(null);
const [forwardSearch, setForwardSearch] = useState('');
const {
rooms, messages, firstUnreadEventId, typingUsers, roomMembers, client, credentials,
selectRoom, loadRoomMembers, sendMessage, editMessage,
sendReaction, redactMessage, sendTyping,
sendImage, sendFile, sendVoice, leaveRoom,
sendImage, sendFile, sendVoice, forwardMessage, leaveRoom,
} = useMatrixStore();
const room = rooms.find((r) => r.id === id);
@ -234,6 +237,21 @@ export default function RoomScreen() {
} finally { setUploading(false); }
};
const handleForward = useCallback((msg: SimpleMessage) => {
setForwardingMessage(msg);
setForwardSearch('');
}, []);
const handleForwardToRoom = useCallback(async (targetRoom: SimpleRoom) => {
if (!forwardingMessage) return;
try {
await forwardMessage(forwardingMessage.id, targetRoom.id);
setForwardingMessage(null);
} catch (err) {
Alert.alert('Forward failed', err instanceof Error ? err.message : 'Unknown error');
}
}, [forwardingMessage, forwardMessage]);
const handleEdit = useCallback((msg: SimpleMessage) => {
setReplyTo(null);
setEditingMessage(msg);
@ -259,6 +277,7 @@ export default function RoomScreen() {
onEdit={handleEdit}
onReact={sendReaction}
onDelete={redactMessage}
onForward={handleForward}
onImagePress={setViewingImage}
onAvatarPress={setProfileUserId}
/>
@ -302,7 +321,7 @@ export default function RoomScreen() {
<FlatList
ref={listRef}
data={listItems}
keyExtractor={(item) => item.type === 'date' ? item.key : item.data.id}
keyExtractor={(item) => item.type === 'message' ? item.data.id : item.key}
renderItem={renderItem}
contentContainerClassName="px-0 py-2"
onEndReached={handleLoadMore}
@ -369,6 +388,59 @@ export default function RoomScreen() {
<ImageViewer uri={viewingImage} onClose={() => setViewingImage(null)} />
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
{/* Forward message modal */}
<Modal visible={!!forwardingMessage} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setForwardingMessage(null)}>
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">Forward to...</Text>
<Pressable onPress={() => setForwardingMessage(null)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<X size={22} color="#6b7280" />
</Pressable>
</View>
<View className="px-4 py-2">
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-2.5 text-foreground text-sm"
placeholder="Search rooms..."
placeholderTextColor="#6b7280"
value={forwardSearch}
onChangeText={setForwardSearch}
autoFocus
/>
</View>
{forwardingMessage && (
<View className="mx-4 mb-2 px-3 py-2 bg-surface border border-border rounded-xl">
<Text className="text-muted-foreground text-xs mb-0.5">Message:</Text>
<Text className="text-foreground text-sm" numberOfLines={2}>{forwardingMessage.body}</Text>
</View>
)}
<ScrollView contentContainerClassName="py-1">
{rooms
.filter((r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase()))
.map((r) => (
<Pressable
key={r.id}
onPress={() => handleForwardToRoom(r)}
className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
>
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{r.avatar ? (
<Image source={{ uri: r.avatar }} style={{ width: 40, height: 40 }} contentFit="cover" />
) : (
<Text className="text-foreground font-semibold">
{r.name[0]?.toUpperCase() ?? '?'}
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-foreground text-sm font-medium" numberOfLines={1}>{r.name}</Text>
{r.isDirect && <Text className="text-muted-foreground text-xs">Direct message</Text>}
</View>
</Pressable>
))}
</ScrollView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}

View file

@ -52,13 +52,6 @@ export default function NewRoomScreen() {
is_direct: true,
invite: [userId],
preset: 'trusted_private_chat' as any,
initial_state: [
{
type: 'm.room.encryption',
state_key: '',
content: { algorithm: 'm.megolm' },
},
],
});
selectRoom(room.room_id);

View file

@ -1,9 +1,10 @@
import { View, Text, Pressable, ActionSheetIOS, Platform, Alert, Clipboard } from 'react-native';
import { useState } from 'react';
import { View, Text, Pressable, ActionSheetIOS, Platform, Alert, Clipboard, Modal, ScrollView } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated';
import { Image } from 'expo-image';
import { ArrowBendUpLeft } from 'phosphor-react-native';
import type { SimpleMessage } from '~/src/matrix/types';
import type { SimpleMessage, MessageReaction } from '~/src/matrix/types';
import MessageText from './MessageText';
import VoiceMessage from './VoiceMessage';
@ -14,6 +15,7 @@ interface Props {
onEdit?: (message: SimpleMessage) => void;
onReact?: (eventId: string, emoji: string) => void;
onDelete?: (eventId: string) => void;
onForward?: (message: SimpleMessage) => void;
onImagePress?: (uri: string) => void;
onAvatarPress?: (userId: string) => void;
}
@ -61,7 +63,52 @@ function SwipeReplyAction({ progress }: { progress: Animated.SharedValue<number>
);
}
export default function MessageBubble({ message, prevMessage, onReply, onEdit, onReact, onDelete, onImagePress, onAvatarPress }: Props) {
function ReactionDetailsModal({ reactions, visible, onClose }: { reactions: MessageReaction[]; visible: boolean; onClose: () => void }) {
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const selected = selectedKey ? reactions.find((r) => r.key === selectedKey) : reactions[0];
return (
<Modal visible={visible} animationType="slide" presentationStyle="formSheet" onRequestClose={onClose}>
<View className="flex-1 bg-background">
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">Reactions</Text>
<Pressable onPress={onClose} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<Text className="text-primary text-base">Done</Text>
</Pressable>
</View>
<ScrollView horizontal className="border-b border-border" contentContainerClassName="px-3 py-2 gap-2">
{reactions.map((r) => (
<Pressable
key={r.key}
onPress={() => setSelectedKey(r.key)}
className={`flex-row items-center gap-1 px-3 py-1.5 rounded-full border ${
(selected?.key === r.key) ? 'bg-primary/20 border-primary/40' : 'bg-surface border-border'
}`}
>
<Text className="text-sm">{r.key}</Text>
<Text className={`text-sm ${(selected?.key === r.key) ? 'text-primary' : 'text-muted-foreground'}`}>{r.count}</Text>
</Pressable>
))}
</ScrollView>
<ScrollView contentContainerClassName="py-2">
{selected?.users.map((userId) => (
<View key={userId} className="flex-row items-center gap-3 px-4 py-2.5">
<View className="w-8 h-8 rounded-full bg-surface border border-border items-center justify-center">
<Text className="text-foreground font-semibold text-sm">
{userId.replace(/^@/, '')[0]?.toUpperCase() ?? '?'}
</Text>
</View>
<Text className="text-foreground text-sm flex-1" numberOfLines={1}>{userId}</Text>
</View>
))}
</ScrollView>
</View>
</Modal>
);
}
export default function MessageBubble({ message, prevMessage, onReply, onEdit, onReact, onDelete, onForward, onImagePress, onAvatarPress }: Props) {
const [showReactionDetails, setShowReactionDetails] = useState(false);
const isOwn = message.isOwn;
const isGrouped =
!message.redacted &&
@ -73,7 +120,7 @@ export default function MessageBubble({ message, prevMessage, onReply, onEdit, o
const handleLongPress = () => {
const extraOptions = isOwn && !message.redacted ? ['Edit', 'Delete'] : [];
const options = ['Cancel', 'Reply', ...QUICK_REACTIONS, 'Copy text', ...extraOptions];
const options = ['Cancel', 'Reply', 'Forward', ...QUICK_REACTIONS, 'Copy text', ...extraOptions];
const destructiveIndex = isOwn && !message.redacted ? options.length - 1 : undefined;
if (Platform.OS === 'ios') {
@ -82,9 +129,10 @@ export default function MessageBubble({ message, prevMessage, onReply, onEdit, o
(index) => {
if (index === 0) return;
if (index === 1) { onReply?.(message); return; }
const ri = index - 2;
if (index === 2) { onForward?.(message); return; }
const ri = index - 3;
if (ri < QUICK_REACTIONS.length) { onReact?.(message.id, QUICK_REACTIONS[ri]); return; }
const ai = index - 2 - QUICK_REACTIONS.length;
const ai = index - 3 - QUICK_REACTIONS.length;
if (ai === 0) { Clipboard.setString(message.body); return; }
if (ai === 1 && isOwn) { onEdit?.(message); return; }
if (ai === 2 && isOwn) { onDelete?.(message.id); }
@ -93,6 +141,7 @@ export default function MessageBubble({ message, prevMessage, onReply, onEdit, o
} else {
Alert.alert('Message', undefined, [
{ text: 'Reply', onPress: () => onReply?.(message) },
{ text: 'Forward', onPress: () => onForward?.(message) },
...(isOwn ? [{ text: 'Edit', onPress: () => onEdit?.(message) }] : []),
{ text: 'Copy text', onPress: () => Clipboard.setString(message.body) },
...(isOwn && !message.redacted
@ -220,6 +269,7 @@ export default function MessageBubble({ message, prevMessage, onReply, onEdit, o
<Pressable
key={r.key}
onPress={() => onReact?.(message.id, r.key)}
onLongPress={() => setShowReactionDetails(true)}
className={`flex-row items-center gap-0.5 px-2 py-0.5 rounded-full border ${
r.includesMe ? 'bg-primary/20 border-primary/40' : 'bg-surface border-border'
}`}
@ -235,12 +285,42 @@ export default function MessageBubble({ message, prevMessage, onReply, onEdit, o
</View>
)}
<Text className="text-muted-foreground text-xs mt-0.5 mx-1">
{formatTime(message.timestamp)}
{message.edited && ' · edited'}
</Text>
{/* Timestamp + Read receipts */}
<View className="flex-row items-center gap-1 mt-0.5 mx-1">
<Text className="text-muted-foreground text-xs">
{formatTime(message.timestamp)}
{message.edited && ' · edited'}
</Text>
{message.readBy && message.readBy.length > 0 && (
<View className="flex-row items-center ml-1">
{message.readBy.slice(0, 3).map((r, i) => (
<View
key={r.userId}
className="w-3.5 h-3.5 rounded-full bg-primary/30 items-center justify-center border border-background"
style={i > 0 ? { marginLeft: -3 } : undefined}
>
<Text style={{ fontSize: 7 }} className="text-primary font-bold">
{r.userName[0]?.toUpperCase() ?? '?'}
</Text>
</View>
))}
{message.readBy.length > 3 && (
<Text className="text-muted-foreground text-xs ml-0.5">+{message.readBy.length - 3}</Text>
)}
</View>
)}
</View>
</View>
</View>
{/* Reaction details modal */}
{message.reactions && message.reactions.length > 0 && (
<ReactionDetailsModal
reactions={message.reactions}
visible={showReactionDetails}
onClose={() => setShowReactionDetails(false)}
/>
)}
</Swipeable>
);
}

View file

@ -8,6 +8,8 @@ import type {
SimpleMessage,
SyncState,
MessageType,
MessageReaction,
ReadReceipt,
RoomMember,
} from './types';
import { resolveMxcThumbnail, resolveMxcUrl } from './media';
@ -44,6 +46,7 @@ interface MatrixState {
sendFile: (fileUri: string, filename: string, mimetype: string) => Promise<void>;
editMessage: (eventId: string, newBody: string) => Promise<void>;
sendVoice: (fileUri: string, durationMs: number) => Promise<void>;
forwardMessage: (eventId: string, targetRoomId: string) => Promise<void>;
acceptInvite: (roomId: string) => Promise<void>;
declineInvite: (roomId: string) => Promise<void>;
leaveRoom: (roomId: string) => Promise<void>;
@ -126,6 +129,57 @@ function eventToMessage(event: MatrixEvent, userId: string, baseUrl: string, roo
}
}
// Reactions
let reactions: MessageReaction[] | undefined;
if (room) {
const eventId = event.getId();
const reactionEvents = room.getLiveTimeline().getEvents().filter(
(e) =>
e.getType() === 'm.reaction' &&
e.getContent()?.['m.relates_to']?.event_id === eventId &&
e.getContent()?.['m.relates_to']?.rel_type === 'm.annotation',
);
if (reactionEvents.length > 0) {
const grouped = new Map<string, { users: string[]; includesMe: boolean }>();
for (const re of reactionEvents) {
const key = re.getContent()['m.relates_to'].key as string;
if (!grouped.has(key)) grouped.set(key, { users: [], includesMe: false });
const entry = grouped.get(key)!;
const sender = re.getSender() ?? '';
entry.users.push(sender);
if (sender === userId) entry.includesMe = true;
}
reactions = Array.from(grouped.entries()).map(([key, { users, includesMe }]) => ({
key,
count: users.length,
users,
includesMe,
}));
}
}
// Read receipts
let readBy: ReadReceipt[] | undefined;
if (room) {
const eventId = event.getId();
if (eventId) {
const receipts: ReadReceipt[] = [];
const members = room.getMembersWithMembership('join');
for (const member of members) {
if (member.userId === userId) continue;
const readUpTo = (room as any).getEventReadUpTo?.(member.userId) as string | null;
if (readUpTo === eventId) {
receipts.push({
userId: member.userId,
userName: member.name || member.userId,
timestamp: 0,
});
}
}
if (receipts.length > 0) readBy = receipts;
}
}
return {
id: event.getId() ?? `${event.getTs()}_${event.getSender()}`,
sender: event.getSender() ?? '',
@ -142,6 +196,8 @@ function eventToMessage(event: MatrixEvent, userId: string, baseUrl: string, roo
edited: !!event.replacingEvent(),
redacted: event.isRedacted(),
media,
reactions,
readBy,
};
}
@ -258,6 +314,9 @@ export const useMatrixStore = create<MatrixState>((set, get) => ({
client.on('Room.name' as any, () => refresh());
client.on('RoomState.events' as any, () => refresh());
client.on('Room.myMembership' as any, () => refresh());
client.on('Room.receipt' as any, (_: unknown, room: Room) => {
refreshMessages(room);
});
client.on('RoomMember.typing' as any, (_: unknown, member: any) => {
const { currentRoomId } = get();
@ -438,6 +497,25 @@ export const useMatrixStore = create<MatrixState>((set, get) => ({
});
},
forwardMessage: async (eventId: string, targetRoomId: string) => {
const { client, currentRoomId } = get();
if (!client || !currentRoomId) return;
const room = client.getRoom(currentRoomId);
const event = room?.findEventById(eventId);
if (!event) return;
const content = event.getContent();
const msgtype = content.msgtype ?? 'm.text';
// Forward as a fresh message (strip reply relations)
const forwarded: Record<string, any> = { msgtype, body: content.body };
if (content.url) forwarded.url = content.url;
if (content.info) forwarded.info = content.info;
if (content.formatted_body) {
forwarded.format = content.format;
forwarded.formatted_body = content.formatted_body;
}
await (client as any).sendMessage(targetRoomId, forwarded);
},
leaveRoom: async (roomId: string) => {
const { client } = get();
if (!client) return;