Filter out org.matrix.msc3401.call.member, org.matrix.msc4075.rtc.notification, m.call.*, m.room.member, and other state events from the chat timeline. Users only see actual messages, not protocol noise. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
214 lines
6.6 KiB
Dart
214 lines
6.6 KiB
Dart
// 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<List<MessageModel>> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> loadMoreMessages(String roomId) async {
|
|
final room = _getRoom(roomId);
|
|
if (room == null) return;
|
|
await room.requestHistory();
|
|
}
|
|
|
|
/// Event types that should never appear in the chat timeline.
|
|
static const _hiddenEventTypes = {
|
|
'org.matrix.msc3401.call.member',
|
|
'org.matrix.msc4075.rtc.notification',
|
|
'com.famedly.call.member',
|
|
'm.call.invite',
|
|
'm.call.answer',
|
|
'm.call.hangup',
|
|
'm.call.candidates',
|
|
'm.call.reject',
|
|
'm.room.member',
|
|
'm.room.power_levels',
|
|
'm.room.join_rules',
|
|
'm.room.history_visibility',
|
|
'm.room.guest_access',
|
|
'm.room.create',
|
|
'm.room.topic',
|
|
'm.room.name',
|
|
'm.room.avatar',
|
|
'm.room.canonical_alias',
|
|
'm.room.encryption',
|
|
};
|
|
|
|
Future<List<MessageModel>> _mapTimeline(Timeline timeline, Room room) async {
|
|
final myUserId = _client.userID ?? '';
|
|
final models = <MessageModel>[];
|
|
for (final e in timeline.events) {
|
|
if (_hiddenEventTypes.contains(e.type)) continue;
|
|
models.add(_toModel(e, timeline, myUserId));
|
|
}
|
|
// Matrix SDK timeline.events is newest-first. Keep that order because
|
|
// the chat ListView uses reverse:true — index 0 (newest) at the bottom.
|
|
return models;
|
|
}
|
|
|
|
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 = <String, List<String>>{};
|
|
for (final r in reactionEvents) {
|
|
final emoji =
|
|
r.content.tryGet<Map<String, dynamic>>('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;
|
|
}
|
|
}
|