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:
2026-04-02 06:48:03 +10:00
parent 8f13c725a4
commit f12a7ac1fd
20 changed files with 2458 additions and 191 deletions

View File

@@ -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,
);
}