Critical: - Fix MXC URI resolution: all avatars/images now resolve mxc:// to HTTP - Sync persistence: only write changed rooms, batch message upserts - lastActivityAt uses room.lastEvent.originServerTs, not creation time High: - Shared MatrixAvatar widget replaces 6 duplicate implementations - CallScreen decodes roomId before LiveKit JWT fetch - Decline button actually dismisses incoming call overlay - EventTypes constants replace raw string literals - LiveKitService uses lazy auth reads, onDispose disconnects Medium: - CallController is keepAlive with timer/room cleanup - authRepository is keepAlive (used from keepAlive notifier) - StreamController not closed in stopListening (crash fix) - Index on messages.roomId for query performance - 400ms debounce on user search - Static DateFormat in MessageBubble - Hardcoded strings replaced with AppConfig refs - Duplicate isDirectMessage field removed from RoomModel - E2EE profile claim corrected to Phase 3 Shared utilities: - lib/shared/widgets/matrix_avatar.dart - lib/shared/utils/mxc_url.dart - lib/shared/utils/room_preview.dart - lib/shared/utils/matrix_id.dart rawJson column removed (unused, caused main-thread jsonEncode) Schema migrated to v2 with roomId index. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
188 lines
5.9 KiB
Dart
188 lines
5.9 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();
|
|
}
|
|
|
|
Future<List<MessageModel>> _mapTimeline(Timeline timeline, Room room) async {
|
|
final myUserId = _client.userID ?? '';
|
|
final models = <MessageModel>[];
|
|
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 = <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;
|
|
}
|
|
}
|