diff --git a/apps/matrix/apps/mobile/app/room/[id].tsx b/apps/matrix/apps/mobile/app/room/[id].tsx index 63e069cd6..a37abe9e1 100644 --- a/apps/matrix/apps/mobile/app/room/[id].tsx +++ b/apps/matrix/apps/mobile/app/room/[id].tsx @@ -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(null); const [profileUserId, setProfileUserId] = useState(null); + const [forwardingMessage, setForwardingMessage] = useState(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() { 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() { setViewingImage(null)} /> setProfileUserId(null)} /> + + {/* Forward message modal */} + setForwardingMessage(null)}> + + + Forward to... + setForwardingMessage(null)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}> + + + + + + + {forwardingMessage && ( + + Message: + {forwardingMessage.body} + + )} + + {rooms + .filter((r) => r.id !== id && r.name.toLowerCase().includes(forwardSearch.toLowerCase())) + .map((r) => ( + handleForwardToRoom(r)} + className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`} + > + + {r.avatar ? ( + + ) : ( + + {r.name[0]?.toUpperCase() ?? '?'} + + )} + + + {r.name} + {r.isDirect && Direct message} + + + ))} + + + ); } diff --git a/apps/matrix/apps/mobile/app/room/new.tsx b/apps/matrix/apps/mobile/app/room/new.tsx index e83dda99a..20982d85f 100644 --- a/apps/matrix/apps/mobile/app/room/new.tsx +++ b/apps/matrix/apps/mobile/app/room/new.tsx @@ -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); diff --git a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx index e5fc46d21..455b3c500 100644 --- a/apps/matrix/apps/mobile/src/components/MessageBubble.tsx +++ b/apps/matrix/apps/mobile/src/components/MessageBubble.tsx @@ -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 ); } -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(null); + const selected = selectedKey ? reactions.find((r) => r.key === selectedKey) : reactions[0]; + + return ( + + + + Reactions + `p-1 ${pressed ? 'opacity-50' : ''}`}> + Done + + + + {reactions.map((r) => ( + 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' + }`} + > + {r.key} + {r.count} + + ))} + + + {selected?.users.map((userId) => ( + + + + {userId.replace(/^@/, '')[0]?.toUpperCase() ?? '?'} + + + {userId} + + ))} + + + + ); +} + +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 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 )} - - {formatTime(message.timestamp)} - {message.edited && ' · edited'} - + {/* Timestamp + Read receipts */} + + + {formatTime(message.timestamp)} + {message.edited && ' · edited'} + + {message.readBy && message.readBy.length > 0 && ( + + {message.readBy.slice(0, 3).map((r, i) => ( + 0 ? { marginLeft: -3 } : undefined} + > + + {r.userName[0]?.toUpperCase() ?? '?'} + + + ))} + {message.readBy.length > 3 && ( + +{message.readBy.length - 3} + )} + + )} + + + {/* Reaction details modal */} + {message.reactions && message.reactions.length > 0 && ( + setShowReactionDetails(false)} + /> + )} ); } diff --git a/apps/matrix/apps/mobile/src/matrix/store.ts b/apps/matrix/apps/mobile/src/matrix/store.ts index 5f98ea8a6..f119ea0ad 100644 --- a/apps/matrix/apps/mobile/src/matrix/store.ts +++ b/apps/matrix/apps/mobile/src/matrix/store.ts @@ -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; editMessage: (eventId: string, newBody: string) => Promise; sendVoice: (fileUri: string, durationMs: number) => Promise; + forwardMessage: (eventId: string, targetRoomId: string) => Promise; acceptInvite: (roomId: string) => Promise; declineInvite: (roomId: string) => Promise; leaveRoom: (roomId: string) => Promise; @@ -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(); + 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((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((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 = { 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;