Calls: - JWT fetch now uses correct MSC4143 flow: get OpenID token from Synapse, then POST to /_matrix/livekit/jwt/sfu/get (was using GET with Bearer token to wrong path — returned 301→404) - Error messages now visible for 3 seconds before popping screen (was flashing away instantly — user couldn't see failure reason) - Voice vs video calls differentiated via ?video=0/1 query param - Debug logging added to JWT flow for troubleshooting Messages: - Chat timeline now shows newest at bottom (standard behaviour). Was reversed twice: SDK returns newest-first, code reversed to oldest-first, then ListView(reverse:true) put oldest at bottom. Removed the extra .reversed — newest-first + reverse:true = correct. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
6.0 KiB
Dart
190 lines
6.0 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));
|
|
}
|
|
// 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;
|
|
}
|
|
}
|