// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Chat repository — bridges Matrix SDK timeline to app domain models. // Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. import 'package:matrix/matrix.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/network/matrix_client.dart'; import '../../../shared/utils/matrix_id.dart'; import '../../../shared/utils/mxc_url.dart'; import '../domain/message_model.dart'; part 'chat_repository.g.dart'; @riverpod ChatRepository chatRepository(Ref ref) { return ChatRepository(client: ref.watch(matrixClientProvider)); } class ChatRepository { ChatRepository({required Client client}) : _client = client; final Client _client; Room? _getRoom(String roomId) => _client.getRoomById(roomId); /// Returns a stream of message lists for [roomId]. Stream> watchTimeline(String roomId) async* { final room = _getRoom(roomId); if (room == null) return; final timeline = await room.getTimeline(); yield await _mapTimeline(timeline, room); await for (final update in _client.onSync.stream) { final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false; if (updatesThisRoom) { yield await _mapTimeline(timeline, room); } } timeline.cancelSubscriptions(); } /// Sends a plain text message. Supports replies via [inReplyToEventId]. Future sendTextMessage( String roomId, String text, { String? inReplyToEventId, }) async { final room = _getRoom(roomId); if (room == null) return; if (inReplyToEventId != null) { // Find the original event in the timeline for the in-reply-to relation. final timeline = await room.getTimeline(); final inReplyTo = timeline.events .where((e) => e.eventId == inReplyToEventId) .firstOrNull; timeline.cancelSubscriptions(); await room.sendTextEvent(text, inReplyTo: inReplyTo); } else { await room.sendTextEvent(text); } } /// Uploads [file] using Matrix media API, then sends an m.room.message. Future sendFile(String roomId, MatrixFile file) async { final room = _getRoom(roomId); if (room == null) return; await room.sendFileEvent(file); } /// Sends an emoji reaction to [eventId]. Future sendReaction(String roomId, String eventId, String emoji) async { final room = _getRoom(roomId); if (room == null) return; await room.sendReaction(eventId, emoji); } /// Redacts (deletes) a message event. Future redactEvent(String roomId, String eventId) async { final room = _getRoom(roomId); if (room == null) return; await room.redactEvent(eventId); } /// Sends a read receipt for the latest event in [roomId]. Future markAsRead(String roomId) async { final room = _getRoom(roomId); if (room == null) return; final lastEventId = room.lastEvent?.eventId ?? ''; if (lastEventId.isEmpty) return; await room.setReadMarker(lastEventId, mRead: lastEventId); } /// Requests older messages (pagination). Future loadMoreMessages(String roomId) async { final room = _getRoom(roomId); if (room == null) return; await room.requestHistory(); } Future> _mapTimeline(Timeline timeline, Room room) async { final myUserId = _client.userID ?? ''; final models = []; for (final e in timeline.events) { models.add(_toModel(e, timeline, myUserId)); } return models.reversed.toList(); } MessageModel _toModel(Event event, Timeline timeline, String myUserId) { final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback( event.senderId, ); // Resolve mxc:// to an HTTP URL for display. final mxcUrl = _extractMxcUrl(event); String? resolvedMediaUrl; if (mxcUrl != null) { resolvedMediaUrl = resolveMxcUrl(_client, Uri.parse(mxcUrl)); } // Build reactions map: emoji → [senderId, ...] final reactionEvents = event.aggregatedEvents( timeline, RelationshipTypes.reaction, ); final reactions = >{}; for (final r in reactionEvents) { final emoji = r.content.tryGet>('m.relates_to')?['key'] as String? ?? r.content['key'] as String?; if (emoji != null) { reactions.putIfAbsent(emoji, () => []).add(r.senderId); } } // Read receipts: user IDs that have a receipt pointing to this event. final readBy = event.receipts.map((r) => r.user.id).toList(); return MessageModel( eventId: event.eventId, roomId: event.roomId ?? '', senderId: event.senderId, senderDisplayName: senderProfile.displayName ?? event.senderId.matrixLocalpart, senderAvatarUrl: resolveMxcUrl(_client, senderProfile.avatarUrl), timestamp: event.originServerTs, type: _messageType(event), body: event.redacted ? null : event.body, mediaUrl: resolvedMediaUrl, mxcUrl: mxcUrl, inReplyToEventId: event.relationshipEventId, isMine: event.senderId == myUserId, isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit), reactions: reactions, readByUserIds: readBy, ); } MessageType _messageType(Event event) { if (event.redacted) return MessageType.redacted; return switch (event.messageType) { MessageTypes.Text => MessageType.text, MessageTypes.Image => MessageType.image, MessageTypes.File => MessageType.file, MessageTypes.Audio => MessageType.audio, MessageTypes.Video => MessageType.video, MessageTypes.Sticker => MessageType.sticker, _ => MessageType.unsupported, }; } String? _extractMxcUrl(Event event) { final content = event.content; if (content.containsKey('url')) { return content['url'] as String?; } return null; } }