Files
m8chat-app2/lib/features/chat/data/chat_repository.dart
help4bis e4aa5277d7 feat(chat): load older messages on scroll-to-top (history pagination) (v1.11.0+15)
Repository keeps one live Timeline per room and re-yields on its
onUpdate callback (fires for both live events AND requestHistory),
not just onSync which never fires for pagination. loadMoreMessages
now paginates that same instance; added canLoadMore.

Timeline widget requests older history when scrolled within 400px of
the oldest end, guarded against re-entry, with a top loading spinner.

Verified: widget test drives a drag-to-top and asserts loadMoreMessages
fires; live smoke test confirms the onUpdate delivery path (reused by
history) still shows new messages after the refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:44:38 +10:00

249 lines
8.0 KiB
Dart

// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
// v1.5.0: keep one live Timeline per room so scroll-back history pagination
// (requestHistory) acts on the same instance the stream is watching, and
// re-yield on the timeline's own onUpdate (fires for both live events and
// history) rather than only on onSync (which does not fire for pagination).
import 'dart:async';
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;
/// Live timelines keyed by room id, so [loadMoreMessages] paginates the
/// same instance the active [watchTimeline] stream is mapping.
final Map<String, Timeline> _timelines = {};
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 signal = StreamController<void>();
final timeline = await room.getTimeline(
onUpdate: () {
if (!signal.isClosed) signal.add(null);
},
);
_timelines[roomId] = timeline;
try {
yield await _mapTimeline(timeline, room);
await for (final _ in signal.stream) {
yield await _mapTimeline(timeline, room);
}
} finally {
timeline.cancelSubscriptions();
if (identical(_timelines[roomId], timeline)) {
_timelines.remove(roomId);
}
await signal.close();
}
}
/// Whether [roomId] has older messages that can still be paginated in.
bool canLoadMore(String roomId) =>
_timelines[roomId]?.canRequestHistory ?? false;
/// 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);
}
/// Replaces the body of an existing text message (m.replace edit).
Future<void> editMessage(
String roomId,
String eventId,
String newText,
) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendTextEvent(newText, editEventId: eventId);
}
/// 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) on the active timeline for [roomId].
/// No-op if there is no live timeline or no more history to fetch.
Future<void> loadMoreMessages(String roomId) async {
final timeline = _timelines[roomId];
if (timeline == null || !timeline.canRequestHistory) return;
await timeline.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;
if (event.type == EventTypes.Encrypted) return MessageType.encrypted;
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;
}
}