mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 04:39:39 +02:00
✨ 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:
parent
a4a7032515
commit
a9c05ca46b
4 changed files with 243 additions and 20 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue