- 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>
333 lines
10 KiB
Dart
333 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import '../matrix/client.dart';
|
|
import '../matrix/thread_receipts.dart';
|
|
import '../matrix/timeline.dart';
|
|
import '../state.dart';
|
|
import '../widgets/composer.dart';
|
|
import '../widgets/message.dart';
|
|
|
|
/// A thread: its root, then its replies, with a composer that replies in
|
|
/// the thread.
|
|
///
|
|
/// The SDK has no thread timeline, so this keeps the room's own [Timeline]
|
|
/// (which receives new replies, their sending status, edits and reactions
|
|
/// like any event) and adds the older replies it doesn't have from
|
|
/// `/relations`.
|
|
///
|
|
/// While it is on screen and the app in front, it marks the thread read
|
|
/// with a receipt for the thread alone; "Read" under your replies comes
|
|
/// from the others' receipts in the thread.
|
|
class ThreadScreen extends ConsumerStatefulWidget {
|
|
const ThreadScreen({super.key, required this.roomId, required this.rootId});
|
|
final String roomId;
|
|
final String rootId;
|
|
|
|
static Route<void> route(String roomId, String rootId) => MaterialPageRoute(
|
|
settings: RouteSettings(name: 'thread/$roomId/$rootId'),
|
|
builder: (_) => ThreadScreen(roomId: roomId, rootId: rootId),
|
|
);
|
|
|
|
@override
|
|
ConsumerState<ThreadScreen> createState() => _ThreadScreenState();
|
|
}
|
|
|
|
class _ThreadScreenState extends ConsumerState<ThreadScreen>
|
|
with WidgetsBindingObserver {
|
|
Timeline? _timeline;
|
|
Event? _root;
|
|
Object? _error;
|
|
|
|
/// Replies fetched from the server, by event ID.
|
|
final _fetched = <String, Event>{};
|
|
String? _nextBatch;
|
|
bool _loadingOlder = false;
|
|
bool _reachedStart = false;
|
|
Event? _replyTo;
|
|
final _scroll = ScrollController();
|
|
|
|
/// The reply we last sent a receipt for, until the server echoes it.
|
|
String? _receiptSent;
|
|
|
|
Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_scroll.addListener(_onScroll);
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_timeline?.cancelSubscriptions();
|
|
_scroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final room = _room;
|
|
try {
|
|
final timeline = await room.getTimeline(
|
|
onUpdate: () {
|
|
if (mounted) setState(() {});
|
|
_markRead();
|
|
},
|
|
);
|
|
final root =
|
|
await timeline.getEventById(widget.rootId) ??
|
|
await room.getEventById(widget.rootId);
|
|
if (!mounted) {
|
|
timeline.cancelSubscriptions();
|
|
return;
|
|
}
|
|
setState(() {
|
|
_timeline = timeline;
|
|
_root = root;
|
|
});
|
|
await _loadOlder();
|
|
_markRead();
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) _markRead();
|
|
}
|
|
|
|
/// Sends a receipt in the thread for its newest reply, while the thread
|
|
/// is on screen and the app is in front.
|
|
void _markRead() {
|
|
if (!mounted || _timeline == null) return;
|
|
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
|
|
return;
|
|
}
|
|
final room = _room;
|
|
if (room.membership != Membership.join) return;
|
|
final newest = _replies().where((e) => e.status.isSynced).firstOrNull;
|
|
if (newest == null || newest.eventId == _receiptSent) return;
|
|
if (ownThreadReceipt(room, widget.rootId)?.eventId == newest.eventId) {
|
|
return;
|
|
}
|
|
_receiptSent = newest.eventId;
|
|
markThreadRead(room, widget.rootId, newest.eventId).catchError((Object e) {
|
|
Logs().w('Could not send a thread read receipt', e);
|
|
_receiptSent = null;
|
|
});
|
|
}
|
|
|
|
Future<Event> _decrypt(Event event) async {
|
|
final encryption = event.room.client.encryption;
|
|
if (event.type != EventTypes.Encrypted || encryption == null) return event;
|
|
try {
|
|
return await encryption.decryptRoomEvent(event);
|
|
} catch (_) {
|
|
return event;
|
|
}
|
|
}
|
|
|
|
/// Fetches the next page of older replies.
|
|
Future<void> _loadOlder() async {
|
|
if (_loadingOlder || _reachedStart) return;
|
|
final room = _room;
|
|
setState(() => _loadingOlder = true);
|
|
try {
|
|
final response = await room.client.getRelatingEventsWithRelType(
|
|
room.id,
|
|
widget.rootId,
|
|
RelationshipTypes.thread,
|
|
from: _nextBatch,
|
|
dir: Direction.b,
|
|
limit: 30,
|
|
);
|
|
for (final m in response.chunk) {
|
|
final event = await _decrypt(Event.fromMatrixEvent(m, room));
|
|
_fetched[event.eventId] = event;
|
|
}
|
|
final root = _root;
|
|
if (root != null && root.type == EventTypes.Encrypted) {
|
|
_root = await _decrypt(root);
|
|
}
|
|
_nextBatch = response.nextBatch;
|
|
_reachedStart = response.nextBatch == null;
|
|
} catch (e) {
|
|
Logs().w('Could not load the thread', e);
|
|
_reachedStart = true;
|
|
} finally {
|
|
if (mounted) setState(() => _loadingOlder = false);
|
|
}
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scroll.hasClients) return;
|
|
final position = _scroll.position;
|
|
if (position.pixels > position.maxScrollExtent - 400) _loadOlder();
|
|
}
|
|
|
|
/// The replies, newest first: the room timeline's (live) and the fetched
|
|
/// ones it doesn't have.
|
|
List<Event> _replies() {
|
|
final timeline = _timeline;
|
|
final live = <String, Event>{
|
|
for (final e in timeline?.events ?? <Event>[])
|
|
if (e.relationshipType == RelationshipTypes.thread &&
|
|
e.relationshipEventId == widget.rootId)
|
|
e.eventId: e,
|
|
};
|
|
final all = [
|
|
...live.values,
|
|
for (final e in _fetched.values)
|
|
if (!live.containsKey(e.eventId)) e,
|
|
];
|
|
all.sort((a, b) {
|
|
// Pending messages are the newest.
|
|
if (!a.status.isSynced && b.status.isSynced) return -1;
|
|
if (a.status.isSynced && !b.status.isSynced) return 1;
|
|
return b.originServerTs.compareTo(a.originServerTs);
|
|
});
|
|
return all;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.watch(syncProvider);
|
|
final room = ref.watch(clientProvider).getRoomById(widget.roomId);
|
|
final timeline = _timeline;
|
|
final root = _root;
|
|
if (room == null) return Scaffold(appBar: AppBar());
|
|
|
|
Widget body;
|
|
if (_error != null) {
|
|
body = Center(child: Text(describeError(_error!)));
|
|
} else if (timeline == null) {
|
|
body = const Center(child: CircularProgressIndicator());
|
|
} else {
|
|
final replies = _replies();
|
|
final entries = [...replies, ?root];
|
|
final readUpTo = _readByOthersIndex(entries, room, widget.rootId);
|
|
final items = projectTimeline([
|
|
for (final e in entries)
|
|
(
|
|
sender: e.senderId,
|
|
time: e.originServerTs,
|
|
kind: kindOf(e, inThread: true),
|
|
),
|
|
]);
|
|
body = ListView.builder(
|
|
controller: _scroll,
|
|
reverse: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
itemCount: items.length + 1,
|
|
itemBuilder: (context, i) {
|
|
if (i == items.length) {
|
|
return _loadingOlder
|
|
? const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
: root == null
|
|
? const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(
|
|
child: Text(
|
|
"The thread's first message isn't available.",
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox(height: 8);
|
|
}
|
|
final item = items[i];
|
|
return switch (item) {
|
|
DayItem() => const SizedBox(height: 8),
|
|
EventItem(:final index, :final kind)
|
|
when kind == EntryKind.notice =>
|
|
NoticeRow(event: entries[index]),
|
|
EventItem(:final index, :final firstInGroup, :final lastInGroup) =>
|
|
Column(
|
|
children: [
|
|
MessageRow(
|
|
key: ValueKey(entries[index].eventId),
|
|
event: entries[index],
|
|
timeline: timeline,
|
|
firstInGroup: firstInGroup,
|
|
lastInGroup: lastInGroup,
|
|
readByOthers: readUpTo != null && index >= readUpTo,
|
|
onReply: (e) => setState(() => _replyTo = e),
|
|
),
|
|
if (entries[index].eventId == widget.rootId)
|
|
const _ThreadDivider(),
|
|
],
|
|
),
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Thread'),
|
|
Text(
|
|
room.getLocalizedDisplayname(),
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(child: body),
|
|
if (room.membership == Membership.join && room.canSendDefaultMessages)
|
|
Composer(
|
|
room: room,
|
|
replyTo: _replyTo,
|
|
onCancelReply: () => setState(() => _replyTo = null),
|
|
target: SendTarget(
|
|
threadRootId: widget.rootId,
|
|
threadLastEventId: () =>
|
|
_replies()
|
|
.where((e) => e.status.isSynced)
|
|
.firstOrNull
|
|
?.eventId ??
|
|
widget.rootId,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The index (newest first) of the newest entry someone else has read,
|
|
/// by their receipt in the thread or an unthreaded one, so your replies
|
|
/// from there back say "Read".
|
|
int? _readByOthersIndex(List<Event> entries, Room room, String rootId) {
|
|
for (var i = 0; i < entries.length; i++) {
|
|
final e = entries[i];
|
|
if (!e.status.isSynced) continue;
|
|
if (threadReaders(room, rootId, e.eventId).isNotEmpty ||
|
|
e.receipts.any((r) => r.user.id != room.client.userID)) {
|
|
return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
class _ThreadDivider extends StatelessWidget {
|
|
const _ThreadDivider();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
|
|
child: Divider(),
|
|
);
|
|
}
|