feat: Phase 2 complete — calls, media, spaces, persistence, chat improvements
- LiveKit/MatrixRTC voice+video calls with full call screen UI - Incoming call overlay (accept/decline) - Media upload/download — file picker, image rendering, file download - Spaces navigation — space list + expandable child rooms - Drift persistence — rooms + messages written on every sync - Sync persistence auto-starts on login and session restore - Chat: typing indicators, long-press menu, reply, emoji reactions - User search dialog + start DM from rooms screen - Android: INTERNET + CAMERA + RECORD_AUDIO permissions in main manifest - Emoji picker for reactions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// 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.
|
||||
// Version: 1.1.0 | Created: 2026-04-01
|
||||
// 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';
|
||||
@@ -23,39 +23,66 @@ class ChatRepository {
|
||||
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.
|
||||
},
|
||||
);
|
||||
final timeline = await room.getTimeline();
|
||||
|
||||
// Emit the initial state.
|
||||
yield _mapTimeline(timeline, room);
|
||||
yield await _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);
|
||||
yield await _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 {
|
||||
/// 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;
|
||||
await room.sendTextEvent(text);
|
||||
|
||||
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].
|
||||
@@ -67,27 +94,63 @@ class ChatRepository {
|
||||
await room.setReadMarker(lastEventId, mRead: lastEventId);
|
||||
}
|
||||
|
||||
/// Requests older messages be loaded (pagination).
|
||||
/// Requests older messages (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) {
|
||||
Future<List<MessageModel>> _mapTimeline(Timeline timeline, Room room) async {
|
||||
final myUserId = _client.userID ?? '';
|
||||
return timeline.events
|
||||
.map((e) => _toModel(e, timeline, myUserId))
|
||||
.toList()
|
||||
.reversed
|
||||
.toList();
|
||||
final models = <MessageModel>[];
|
||||
for (final e in timeline.events) {
|
||||
models.add(await _toModel(e, timeline, myUserId));
|
||||
}
|
||||
return models.reversed.toList();
|
||||
}
|
||||
|
||||
MessageModel _toModel(Event event, Timeline timeline, String myUserId) {
|
||||
Future<MessageModel> _toModel(
|
||||
Event event,
|
||||
Timeline timeline,
|
||||
String myUserId,
|
||||
) async {
|
||||
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
|
||||
event.senderId,
|
||||
);
|
||||
|
||||
// Resolve mxc:// to an authenticated HTTP URL for display.
|
||||
final mxcUrl = _extractMxcUrl(event);
|
||||
String? resolvedMediaUrl;
|
||||
if (mxcUrl != null) {
|
||||
try {
|
||||
final mxcUri = Uri.parse(mxcUrl);
|
||||
final httpUri = await mxcUri.getDownloadUri(_client);
|
||||
resolvedMediaUrl = httpUri.toString();
|
||||
} on Exception {
|
||||
// Leave as null — the bubble will show a broken image indicator.
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ?? '',
|
||||
@@ -98,10 +161,13 @@ class ChatRepository {
|
||||
timestamp: event.originServerTs,
|
||||
type: _messageType(event),
|
||||
body: event.redacted ? null : event.body,
|
||||
mxcUrl: _extractMxcUrl(event),
|
||||
mediaUrl: resolvedMediaUrl,
|
||||
mxcUrl: mxcUrl,
|
||||
inReplyToEventId: event.relationshipEventId,
|
||||
isMine: event.senderId == myUserId,
|
||||
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
|
||||
reactions: reactions,
|
||||
readByUserIds: readBy,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user