- 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>
144 lines
5.0 KiB
Dart
144 lines
5.0 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 '../state.dart';
|
|
import '../util/format.dart';
|
|
import '../widgets/avatar.dart';
|
|
import '../widgets/message_extras.dart';
|
|
import 'thread_screen.dart';
|
|
|
|
/// A room's threads, the most recently active first (`/threads`); those
|
|
/// with replies you haven't read are bold, with a dot.
|
|
class ThreadListScreen extends ConsumerStatefulWidget {
|
|
const ThreadListScreen({super.key, required this.roomId});
|
|
final String roomId;
|
|
|
|
@override
|
|
ConsumerState<ThreadListScreen> createState() => _ThreadListScreenState();
|
|
}
|
|
|
|
class _ThreadListScreenState extends ConsumerState<ThreadListScreen> {
|
|
late Future<List<Event>> _threads = _load();
|
|
|
|
Future<List<Event>> _load() async {
|
|
final client = ref.read(clientProvider);
|
|
final room = client.getRoomById(widget.roomId)!;
|
|
final response = await client.getThreadRoots(room.id, limit: 50);
|
|
final roots = <Event>[];
|
|
for (final m in response.chunk) {
|
|
var event = Event.fromMatrixEvent(m, room);
|
|
final encryption = client.encryption;
|
|
if (event.type == EventTypes.Encrypted && encryption != null) {
|
|
try {
|
|
event = await encryption.decryptRoomEvent(event);
|
|
} catch (_) {}
|
|
}
|
|
roots.add(event);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Receipts come with syncs: the unread marks follow them.
|
|
ref.watch(syncProvider);
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Threads')),
|
|
body: RefreshIndicator(
|
|
onRefresh: () async {
|
|
setState(() {
|
|
_threads = _load();
|
|
});
|
|
await _threads;
|
|
},
|
|
child: FutureBuilder<List<Event>>(
|
|
future: _threads,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasError) {
|
|
return ListView(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(describeError(snapshot.error!)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
final threads = snapshot.data;
|
|
if (threads == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (threads.isEmpty) {
|
|
return ListView(
|
|
children: const [
|
|
Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Text(
|
|
'No threads yet. Long-press a message and choose '
|
|
'Reply in thread to start one.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
itemCount: threads.length,
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
itemBuilder: (context, i) {
|
|
final root = threads[i];
|
|
final sender = root.senderFromMemoryOrFallback;
|
|
final summary = root.unsigned
|
|
?.tryGetMap<String, Object?>('m.relations')
|
|
?.tryGetMap<String, Object?>(RelationshipTypes.thread);
|
|
final count = summary?.tryGet<int>('count') ?? 0;
|
|
final latestTs = summary
|
|
?.tryGetMap<String, Object?>('latest_event')
|
|
?.tryGet<int>('origin_server_ts');
|
|
final when = latestTs == null
|
|
? root.originServerTs
|
|
: DateTime.fromMillisecondsSinceEpoch(latestTs);
|
|
final unread = threadUnread(root);
|
|
return ListTile(
|
|
leading: Avatar(
|
|
name: sender.calcDisplayname(),
|
|
seed: sender.id,
|
|
mxc: sender.avatarUrl,
|
|
),
|
|
title: Text(
|
|
quoteText(root),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: unread
|
|
? const TextStyle(fontWeight: FontWeight.w700)
|
|
: null,
|
|
),
|
|
subtitle: Text(
|
|
'${sender.calcDisplayname()} · '
|
|
'${count == 1 ? '1 reply' : '$count replies'} · '
|
|
'${formatListTime(when)}',
|
|
),
|
|
trailing: unread
|
|
? Icon(
|
|
Icons.circle,
|
|
size: 10,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
semanticLabel: 'Unread',
|
|
)
|
|
: null,
|
|
onTap: () => Navigator.of(
|
|
context,
|
|
).push(ThreadScreen.route(widget.roomId, root.eventId)),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|