Files
Warp/apps/mobile/lib/matrix/thread_receipts.dart
T
tajniak81andClaude Opus 5.5 12550c4078 Mobile: calls in the background, screen sharing, read receipts per thread
- Calls keep going with the app in the background: a foreground service
  (CallService.kt, driven from lib/matrix/call_service.dart) declares the
  microphone, the camera while it's on and the screen while it's shared.
  Its notification (CallStyle) shows the call's length, opens the call
  and hangs up. Swiping the app away ends the call.
- Screen sharing on Android: consent, then media projection on the
  service, then LiveKit publishes a screen-share track. A shared screen
  replaces the sharer's camera on their tile, shown whole and unmirrored;
  stopping it from Android's controls stops the share.
- Read receipts per thread (MSC3771): an open thread sends receipts with
  thread_id, "Read" under replies comes from receipts in the thread, and
  threads with unread replies get a dot on "N replies" and in the list.

Not tested on a device yet. README updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:22:56 +02:00

104 lines
3.8 KiB
Dart

import 'package:matrix/matrix.dart';
/// Read receipts per thread (MSC3771, in the spec since v1.4): a receipt
/// with a `thread_id` says how far someone has read in that thread alone,
/// so reading a thread doesn't mark the room read, nor the room a thread.
/// The SDK keeps them apart already (`receiptState.byThread`) but only
/// sends unthreaded ones; the thread screen sends these.
/// Our receipt in the thread under [rootId], public or private, whichever
/// is further.
LatestReceiptStateData? ownThreadReceipt(Room room, String rootId) =>
room.receiptState.byThread[rootId]?.latestOwnReceipt;
/// Marks the thread under [rootId] read up to [eventId], a reply in it.
/// The private receipt always; the public one too unless the user keeps
/// receipts private, as for the room.
Future<void> markThreadRead(Room room, String rootId, String eventId) async {
if (ownThreadReceipt(room, rootId)?.eventId == eventId) return;
final client = room.client;
await client.postReceipt(
room.id,
ReceiptType.mReadPrivate,
eventId,
threadId: rootId,
);
if (client.receiptsPublicByDefault) {
await client.postReceipt(
room.id,
ReceiptType.mRead,
eventId,
threadId: rootId,
);
}
}
/// The other people whose receipt in the thread under [rootId] is on
/// [eventId], with those whose unthreaded receipt is.
Set<String> threadReaders(Room room, String rootId, String eventId) {
final me = room.client.userID;
final state = room.receiptState;
return {
for (final e
in state.byThread[rootId]?.otherUsers.entries ??
const <MapEntry<String, LatestReceiptStateData>>[])
if (e.value.eventId == eventId && e.key != me) e.key,
for (final e in state.global.otherUsers.entries)
if (e.value.eventId == eventId && e.key != me) e.key,
};
}
/// The newest reply in a thread, as far as this device knows: the
/// server's summary on the root, or a newer one that came in since.
({String eventId, String senderId, DateTime time})? latestThreadReply(
Event root, [
Timeline? timeline,
]) {
({String eventId, String senderId, DateTime time})? latest;
final bundled = root.unsigned
?.tryGetMap<String, Object?>('m.relations')
?.tryGetMap<String, Object?>(RelationshipTypes.thread)
?.tryGetMap<String, Object?>('latest_event');
final id = bundled?.tryGet<String>('event_id');
final sender = bundled?.tryGet<String>('sender');
final ts = bundled?.tryGet<int>('origin_server_ts');
if (id != null && sender != null && ts != null) {
latest = (
eventId: id,
senderId: sender,
time: DateTime.fromMillisecondsSinceEpoch(ts),
);
}
if (timeline != null) {
for (final e in root.aggregatedEvents(timeline, RelationshipTypes.thread)) {
if (!e.status.isSynced) continue;
if (latest == null || e.originServerTs.isAfter(latest.time)) {
latest = (
eventId: e.eventId,
senderId: e.senderId,
time: e.originServerTs,
);
}
}
}
return latest;
}
/// Whether the thread under [root] has a reply we haven't read: one from
/// someone else, newer than our receipt in the thread and than our
/// unthreaded one (which covers everything before it).
bool threadUnread(Event root, [Timeline? timeline]) {
final latest = latestThreadReply(root, timeline);
if (latest == null) return false;
final room = root.room;
if (latest.senderId == room.client.userID) return false;
final inThread = ownThreadReceipt(room, root.eventId);
if (inThread?.eventId == latest.eventId) return false;
final unthreaded = room.receiptState.global.latestOwnReceipt;
if (unthreaded?.eventId == latest.eventId) return false;
for (final r in [inThread, unthreaded]) {
if (r != null && !latest.time.isAfter(r.timestamp)) return false;
}
return true;
}