Files
m8chat-app2/lib/features/chat/data/chat_repository.dart
help4bis 8f13c725a4 feat: Phase 1 complete — Matrix login, rooms, chat, profile
- Direct m.login.password auth against matrix.m8chat.au
- Room list with unread badges, last message, timestamps
- Chat timeline (text, images, files, replies, reactions)
- Profile screen with expandable Notifications and Security sections
- Olm E2EE initialisation (web WASM bootstrap)
- Global error handler preventing Matrix SDK crashes
- GoRouter with refreshListenable (no recreation on auth change)
- Feature-first clean architecture: Riverpod + GoRouter + Drift
- Deployed to https://app2.m8chat.au

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 06:26:57 +10:00

130 lines
4.0 KiB
Dart

// Version: 1.0.1 | Created: 2026-04-01
// Chat repository. Bridges Matrix SDK timeline to app domain models.
// Uses room.getTimeline() — timeline is async in matrix 0.33.0.
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/network/matrix_client.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].
///
/// Opens the room's timeline once and then emits on every update.
/// The timeline object is closed when the stream subscription is cancelled.
Stream<List<MessageModel>> watchTimeline(String roomId) async* {
final room = _getRoom(roomId);
if (room == null) return;
final timeline = await room.getTimeline(
onUpdate: () {
// Handled by the stream controller below.
},
);
// Emit the initial state.
yield _mapTimeline(timeline, room);
// Emit on subsequent sync events that affect this room.
await for (final update in _client.onSync.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
yield _mapTimeline(timeline, room);
}
}
// Clean up timeline subscriptions when the stream is cancelled.
timeline.cancelSubscriptions();
}
/// Sends a plain text message to [roomId].
Future<void> sendTextMessage(String roomId, String text) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendTextEvent(text);
}
/// 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 be loaded (pagination).
Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.requestHistory();
}
List<MessageModel> _mapTimeline(Timeline timeline, Room room) {
final myUserId = _client.userID ?? '';
return timeline.events
.map((e) => _toModel(e, timeline, myUserId))
.toList()
.reversed
.toList();
}
MessageModel _toModel(Event event, Timeline timeline, String myUserId) {
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
event.senderId,
);
return MessageModel(
eventId: event.eventId,
roomId: event.roomId ?? '',
senderId: event.senderId,
senderDisplayName:
senderProfile.displayName ?? event.senderId.split(':').first,
senderAvatarUrl: senderProfile.avatarUrl?.toString(),
timestamp: event.originServerTs,
type: _messageType(event),
body: event.redacted ? null : event.body,
mxcUrl: _extractMxcUrl(event),
inReplyToEventId: event.relationshipEventId,
isMine: event.senderId == myUserId,
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
);
}
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;
}
}