Mobile: replies, reactions and threads
- Replies: Reply on a message's long press puts it above the composer; sent replies quote the message they answer (sender and first line), with the reply fallback left out of the body. - Reactions: six quick ones and any other on long press, chips under the message with counts, the user's own highlighted; tapping a chip adds or takes back a reaction, a long press lists who reacted. - Threads: Reply in thread opens a thread view (the root, then its replies, older ones fetched from /relations, new ones live from the room timeline) with a composer that sends into the thread. Thread replies are hidden from the main timeline; the root shows "N replies". Room info lists the room's threads (/threads). The composer moved out of the room screen into widgets/composer.dart so the room and thread views share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3356089c13
commit
4271d2ab17
10 files changed
+1349
-218
No files matched your search
@@ -27,8 +27,13 @@ const noticeStateTypes = {
|
||||
EventTypes.RoomTombstone,
|
||||
};
|
||||
|
||||
EntryKind kindOf(Event event) {
|
||||
/// [inThread] is set for a thread's own view; in the room's main timeline
|
||||
/// a thread's replies are hidden behind the "N replies" under its root.
|
||||
EntryKind kindOf(Event event, {bool inThread = false}) {
|
||||
if (event.relationshipType == RelationshipTypes.edit) return EntryKind.hidden;
|
||||
if (!inThread && event.relationshipType == RelationshipTypes.thread) {
|
||||
return EntryKind.hidden;
|
||||
}
|
||||
switch (event.type) {
|
||||
case EventTypes.Message:
|
||||
case EventTypes.Sticker:
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../state.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/avatar.dart';
|
||||
import '../widgets/verification_sheet.dart';
|
||||
import 'thread_list_screen.dart';
|
||||
|
||||
class RoomInfoScreen extends ConsumerStatefulWidget {
|
||||
const RoomInfoScreen({super.key, required this.roomId});
|
||||
@@ -213,6 +214,16 @@ class _RoomInfoScreenState extends ConsumerState<RoomInfoScreen> {
|
||||
)
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.forum_outlined),
|
||||
title: const Text('Threads'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ThreadListScreen(roomId: room.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Notifications'),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../matrix/client.dart';
|
||||
@@ -14,8 +11,10 @@ import '../state.dart';
|
||||
import '../theme.dart';
|
||||
import '../util/format.dart';
|
||||
import '../widgets/avatar.dart';
|
||||
import '../widgets/composer.dart';
|
||||
import '../widgets/message.dart';
|
||||
import 'room_info_screen.dart';
|
||||
import 'thread_screen.dart';
|
||||
|
||||
class RoomScreen extends ConsumerStatefulWidget {
|
||||
const RoomScreen({super.key, required this.roomId});
|
||||
@@ -35,13 +34,12 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
Timeline? _timeline;
|
||||
Object? _loadError;
|
||||
final _scroll = ScrollController();
|
||||
final _composer = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
bool _loadingHistory = false;
|
||||
Timer? _typingTimer;
|
||||
bool _typing = false;
|
||||
StreamSubscription? _syncSub;
|
||||
|
||||
/// The message the user is replying to.
|
||||
Event? _replyTo;
|
||||
|
||||
Room? get _room => ref.read(clientProvider).getRoomById(widget.roomId);
|
||||
|
||||
@override
|
||||
@@ -51,7 +49,6 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
Notifications.activeRoomId = widget.roomId;
|
||||
Notifications.clearRoom(widget.roomId);
|
||||
_scroll.addListener(_onScroll);
|
||||
_composer.addListener(_onComposerChanged);
|
||||
_syncSub = ref.read(clientProvider).onSync.stream.listen((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
@@ -87,11 +84,7 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
}
|
||||
_timeline?.cancelSubscriptions();
|
||||
_syncSub?.cancel();
|
||||
_typingTimer?.cancel();
|
||||
if (_typing) _room?.setTyping(false);
|
||||
_scroll.dispose();
|
||||
_composer.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -144,123 +137,6 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _onComposerChanged() {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
final hasText = _composer.text.trim().isNotEmpty;
|
||||
if (hasText && !_typing) {
|
||||
_typing = true;
|
||||
room.setTyping(true, timeout: 30000).catchError((_) {});
|
||||
}
|
||||
_typingTimer?.cancel();
|
||||
if (hasText) {
|
||||
_typingTimer = Timer(const Duration(seconds: 5), _stopTyping);
|
||||
} else {
|
||||
_stopTyping();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _stopTyping() {
|
||||
if (!_typing) return;
|
||||
_typing = false;
|
||||
_room?.setTyping(false).catchError((_) {});
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final room = _room;
|
||||
final text = _composer.text.trim();
|
||||
if (room == null || text.isEmpty) return;
|
||||
_composer.clear();
|
||||
_stopTyping();
|
||||
try {
|
||||
// The pending message shows at once as "Sending"; a failure leaves it
|
||||
// in the timeline with Retry and Delete.
|
||||
await room.sendTextEvent(text);
|
||||
} catch (e) {
|
||||
_snack(describeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendFile(MatrixFile file) async {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
try {
|
||||
await room.sendFileEvent(
|
||||
file,
|
||||
shrinkImageMaxDimension: 1600,
|
||||
extraContent: {'body': file.name},
|
||||
);
|
||||
} catch (e) {
|
||||
_snack(describeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _attach() async {
|
||||
final choice = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (sheet) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Photo or video'),
|
||||
onTap: () => Navigator.pop(sheet, 'gallery'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Take a photo'),
|
||||
onTap: () => Navigator.pop(sheet, 'camera'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: const Text('File'),
|
||||
onTap: () => Navigator.pop(sheet, 'file'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (choice == null) return;
|
||||
try {
|
||||
if (choice == 'file') {
|
||||
for (final f in await FilePicker.pickFiles()) {
|
||||
final bytes = await f.xFile.readAsBytes();
|
||||
await _sendFile(MatrixFile.fromMimeType(bytes: bytes, name: f.name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final picker = ImagePicker();
|
||||
final picked = choice == 'camera'
|
||||
? [
|
||||
?await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 90,
|
||||
),
|
||||
]
|
||||
: await picker.pickMultipleMedia(imageQuality: 90);
|
||||
for (final x in picked) {
|
||||
final bytes = await x.readAsBytes();
|
||||
await _sendFile(
|
||||
MatrixFile.fromMimeType(
|
||||
bytes: bytes,
|
||||
name: x.name,
|
||||
mimeType: x.mimeType,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
_snack(e.message ?? "Couldn't open that.");
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final room = _room;
|
||||
@@ -279,12 +155,10 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
_InviteBar(room: room)
|
||||
else if (room.membership == Membership.join)
|
||||
room.canSendDefaultMessages
|
||||
? _Composer(
|
||||
controller: _composer,
|
||||
focus: _focus,
|
||||
encrypted: room.encrypted,
|
||||
onSend: _send,
|
||||
onAttach: _attach,
|
||||
? Composer(
|
||||
room: room,
|
||||
replyTo: _replyTo,
|
||||
onCancelReply: () => setState(() => _replyTo = null),
|
||||
)
|
||||
: const _ReadOnlyBar(),
|
||||
],
|
||||
@@ -336,6 +210,10 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
|
||||
firstInGroup: firstInGroup,
|
||||
lastInGroup: lastInGroup,
|
||||
readByOthers: readUpTo != null && index >= readUpTo,
|
||||
onReply: (e) => setState(() => _replyTo = e),
|
||||
onOpenThread: (e) => Navigator.of(
|
||||
context,
|
||||
).push(ThreadScreen.route(room.id, e.eventId)),
|
||||
),
|
||||
};
|
||||
},
|
||||
@@ -540,87 +418,6 @@ class _TypingRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Composer extends StatelessWidget {
|
||||
const _Composer({
|
||||
required this.controller,
|
||||
required this.focus,
|
||||
required this.encrypted,
|
||||
required this.onSend,
|
||||
required this.onAttach,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focus;
|
||||
final bool encrypted;
|
||||
final VoidCallback onSend;
|
||||
final VoidCallback onAttach;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.warp;
|
||||
final canSend = controller.text.trim().isNotEmpty;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
border: Border(top: BorderSide(color: c.line)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 8, 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Attach',
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
color: c.inkMuted,
|
||||
onPressed: onAttach,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintText: encrypted ? 'Encrypted message' : 'Message',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide(color: c.tealStrong),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filled(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSend ? onSend : null,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InviteBar extends StatefulWidget {
|
||||
const _InviteBar({required this.room});
|
||||
final Room room;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../matrix/client.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`).
|
||||
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) {
|
||||
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);
|
||||
return ListTile(
|
||||
leading: Avatar(
|
||||
name: sender.calcDisplayname(),
|
||||
seed: sender.id,
|
||||
mxc: sender.avatarUrl,
|
||||
),
|
||||
title: Text(
|
||||
quoteText(root),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${sender.calcDisplayname()} · '
|
||||
'${count == 1 ? '1 reply' : '$count replies'} · '
|
||||
'${formatListTime(when)}',
|
||||
),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(ThreadScreen.route(widget.roomId, root.eventId)),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../matrix/client.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`.
|
||||
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> {
|
||||
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();
|
||||
|
||||
Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_onScroll);
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timeline?.cancelSubscriptions();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final room = _room;
|
||||
try {
|
||||
final timeline = await room.getTimeline(
|
||||
onUpdate: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
);
|
||||
final root =
|
||||
await timeline.getEventById(widget.rootId) ??
|
||||
await room.getEventById(widget.rootId);
|
||||
if (!mounted) {
|
||||
timeline.cancelSubscriptions();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_timeline = timeline;
|
||||
_root = root;
|
||||
});
|
||||
await _loadOlder();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e);
|
||||
}
|
||||
}
|
||||
|
||||
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 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: false,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreadDivider extends StatelessWidget {
|
||||
const _ThreadDivider();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Divider(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../matrix/client.dart';
|
||||
import '../theme.dart';
|
||||
import 'message_extras.dart';
|
||||
|
||||
/// Where a message goes: the room's main timeline, or a thread in it.
|
||||
class SendTarget {
|
||||
const SendTarget({this.threadRootId, this.threadLastEventId});
|
||||
|
||||
/// The thread's root, when sending into a thread.
|
||||
final String? threadRootId;
|
||||
|
||||
/// The newest event in the thread: what a client that doesn't know
|
||||
/// threads shows the message as a reply to.
|
||||
final String? Function()? threadLastEventId;
|
||||
}
|
||||
|
||||
/// The message box: text, attachments, typing notifications, and the
|
||||
/// message being replied to, shown above it until sent or dismissed.
|
||||
class Composer extends StatefulWidget {
|
||||
const Composer({
|
||||
super.key,
|
||||
required this.room,
|
||||
this.target = const SendTarget(),
|
||||
this.replyTo,
|
||||
this.onCancelReply,
|
||||
this.focus,
|
||||
});
|
||||
|
||||
final Room room;
|
||||
final SendTarget target;
|
||||
|
||||
/// The message being answered, or null.
|
||||
final Event? replyTo;
|
||||
final VoidCallback? onCancelReply;
|
||||
final FocusNode? focus;
|
||||
|
||||
@override
|
||||
State<Composer> createState() => _ComposerState();
|
||||
}
|
||||
|
||||
class _ComposerState extends State<Composer> {
|
||||
final _controller = TextEditingController();
|
||||
late final FocusNode _focus = widget.focus ?? FocusNode();
|
||||
Timer? _typingTimer;
|
||||
bool _typing = false;
|
||||
|
||||
Room get _room => widget.room;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Composer old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Choosing a message to reply to puts the cursor in the box.
|
||||
if (widget.replyTo != null && widget.replyTo != old.replyTo) {
|
||||
_focus.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_typingTimer?.cancel();
|
||||
if (_typing) _room.setTyping(false).catchError((_) {});
|
||||
_controller.dispose();
|
||||
if (widget.focus == null) _focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
final hasText = _controller.text.trim().isNotEmpty;
|
||||
if (hasText && !_typing) {
|
||||
_typing = true;
|
||||
_room.setTyping(true, timeout: 30000).catchError((_) {});
|
||||
}
|
||||
_typingTimer?.cancel();
|
||||
if (hasText) {
|
||||
_typingTimer = Timer(const Duration(seconds: 5), _stopTyping);
|
||||
} else {
|
||||
_stopTyping();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _stopTyping() {
|
||||
if (!_typing) return;
|
||||
_typing = false;
|
||||
_room.setTyping(false).catchError((_) {});
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final replyTo = widget.replyTo;
|
||||
_controller.clear();
|
||||
_stopTyping();
|
||||
widget.onCancelReply?.call();
|
||||
try {
|
||||
// The pending message shows at once as "Sending"; a failure leaves it
|
||||
// in the timeline with Retry and Delete.
|
||||
await _room.sendTextEvent(
|
||||
text,
|
||||
inReplyTo: replyTo,
|
||||
threadRootEventId: widget.target.threadRootId,
|
||||
threadLastEventId: widget.target.threadLastEventId?.call(),
|
||||
);
|
||||
} catch (e) {
|
||||
_snack(describeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendFile(MatrixFile file, Event? replyTo) async {
|
||||
try {
|
||||
await _room.sendFileEvent(
|
||||
file,
|
||||
inReplyTo: replyTo,
|
||||
threadRootEventId: widget.target.threadRootId,
|
||||
threadLastEventId: widget.target.threadLastEventId?.call(),
|
||||
shrinkImageMaxDimension: 1600,
|
||||
extraContent: {'body': file.name},
|
||||
);
|
||||
} catch (e) {
|
||||
_snack(describeError(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _attach() async {
|
||||
final choice = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (sheet) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Photo or video'),
|
||||
onTap: () => Navigator.pop(sheet, 'gallery'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Take a photo'),
|
||||
onTap: () => Navigator.pop(sheet, 'camera'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: const Text('File'),
|
||||
onTap: () => Navigator.pop(sheet, 'file'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (choice == null) return;
|
||||
final replyTo = widget.replyTo;
|
||||
widget.onCancelReply?.call();
|
||||
try {
|
||||
if (choice == 'file') {
|
||||
for (final f in await FilePicker.pickFiles()) {
|
||||
final bytes = await f.xFile.readAsBytes();
|
||||
await _sendFile(
|
||||
MatrixFile.fromMimeType(bytes: bytes, name: f.name),
|
||||
replyTo,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final picker = ImagePicker();
|
||||
final picked = choice == 'camera'
|
||||
? [
|
||||
?await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 90,
|
||||
),
|
||||
]
|
||||
: await picker.pickMultipleMedia(imageQuality: 90);
|
||||
for (final x in picked) {
|
||||
final bytes = await x.readAsBytes();
|
||||
await _sendFile(
|
||||
MatrixFile.fromMimeType(
|
||||
bytes: bytes,
|
||||
name: x.name,
|
||||
mimeType: x.mimeType,
|
||||
),
|
||||
replyTo,
|
||||
);
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
_snack(e.message ?? "Couldn't open that.");
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.warp;
|
||||
final canSend = _controller.text.trim().isNotEmpty;
|
||||
final replyTo = widget.replyTo;
|
||||
final inThread = widget.target.threadRootId != null;
|
||||
final hint = inThread
|
||||
? 'Reply in thread'
|
||||
: _room.encrypted
|
||||
? 'Encrypted message'
|
||||
: 'Message';
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
border: Border(top: BorderSide(color: c.line)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (replyTo != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.reply, size: 18, color: c.tealText),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: QuoteBlock(
|
||||
sender:
|
||||
'Replying to ${replyTo.senderFromMemoryOrFallback.calcDisplayname()}',
|
||||
text: quoteText(replyTo),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Cancel reply',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onCancelReply,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 8, 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Attach',
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
color: c.inkMuted,
|
||||
onPressed: _attach,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide(color: c.tealStrong),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filled(
|
||||
tooltip: 'Send',
|
||||
onPressed: canSend ? _send : null,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../matrix/media.dart';
|
||||
import '../theme.dart';
|
||||
import '../util/format.dart';
|
||||
import 'avatar.dart';
|
||||
import 'message_extras.dart';
|
||||
|
||||
const _i18n = MatrixDefaultLocalizations();
|
||||
|
||||
@@ -44,6 +45,8 @@ class MessageRow extends StatelessWidget {
|
||||
required this.firstInGroup,
|
||||
required this.lastInGroup,
|
||||
required this.readByOthers,
|
||||
this.onReply,
|
||||
this.onOpenThread,
|
||||
});
|
||||
|
||||
final Event event;
|
||||
@@ -52,6 +55,13 @@ class MessageRow extends StatelessWidget {
|
||||
final bool lastInGroup;
|
||||
final bool readByOthers;
|
||||
|
||||
/// Starts a reply to this message in the composer.
|
||||
final ValueChanged<Event>? onReply;
|
||||
|
||||
/// Opens the thread this message starts (or would start). Null inside a
|
||||
/// thread, where there is no thread to open.
|
||||
final ValueChanged<Event>? onOpenThread;
|
||||
|
||||
bool get _mine => event.senderId == event.room.client.userID;
|
||||
|
||||
@override
|
||||
@@ -76,7 +86,30 @@ class MessageRow extends StatelessWidget {
|
||||
bottomRight: _mine && !lastInGroup ? tight : radius,
|
||||
);
|
||||
|
||||
final body = _MessageBody(event: display, original: event, mine: _mine);
|
||||
final replyTo = event.inReplyToEventId(includingFallback: false);
|
||||
// A thread reply's in-reply-to is the thread's fallback: not shown.
|
||||
final isThreadFallback =
|
||||
event.relationshipType == RelationshipTypes.thread &&
|
||||
event.content.tryGetMap<String, Object?>(
|
||||
'm.relates_to',
|
||||
)?['is_falling_back'] ==
|
||||
true;
|
||||
final content = _MessageBody(event: display, original: event, mine: _mine);
|
||||
final body = replyTo == null || display.redacted || isThreadFallback
|
||||
? content
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: isMedia
|
||||
? const EdgeInsets.all(6)
|
||||
: const EdgeInsets.only(bottom: 6),
|
||||
child: ReplyPreview(event: event, timeline: timeline),
|
||||
),
|
||||
content,
|
||||
],
|
||||
);
|
||||
final bubble = GestureDetector(
|
||||
onLongPress: () => _showActions(context, display),
|
||||
child: isMedia
|
||||
@@ -136,6 +169,13 @@ class MessageRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
bubble,
|
||||
Reactions(event: event, timeline: timeline),
|
||||
if (onOpenThread != null)
|
||||
ThreadSummary(
|
||||
event: event,
|
||||
timeline: timeline,
|
||||
onTap: () => onOpenThread!(event),
|
||||
),
|
||||
if (lastInGroup || event.status.isError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3, left: 4, right: 4),
|
||||
@@ -171,6 +211,10 @@ class MessageRow extends StatelessWidget {
|
||||
event.canRedact &&
|
||||
event.room.canRedact;
|
||||
final failed = event.status.isError;
|
||||
final canRespond =
|
||||
event.status.isSent &&
|
||||
!event.redacted &&
|
||||
event.room.canSendDefaultMessages;
|
||||
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
@@ -178,6 +222,40 @@ class MessageRow extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canRespond && event.room.canSendEvent(EventTypes.Reaction))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final emoji in quickReactions)
|
||||
ReactionButton(
|
||||
emoji: emoji,
|
||||
selected: myReaction(event, timeline, emoji) != null,
|
||||
onTap: () => Navigator.pop(sheet, 'react:$emoji'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Other reaction',
|
||||
icon: const Icon(Icons.add_reaction_outlined),
|
||||
onPressed: () => Navigator.pop(sheet, 'react-other'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (canRespond && onReply != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply'),
|
||||
onTap: () => Navigator.pop(sheet, 'reply'),
|
||||
),
|
||||
if (canRespond &&
|
||||
onOpenThread != null &&
|
||||
event.relationshipType != RelationshipTypes.thread)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.forum_outlined),
|
||||
title: const Text('Reply in thread'),
|
||||
onTap: () => Navigator.pop(sheet, 'thread'),
|
||||
),
|
||||
if (failed) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
@@ -218,7 +296,18 @@ class MessageRow extends StatelessWidget {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
if (action != null && action.startsWith('react:')) {
|
||||
await toggleReaction(event, timeline, action.substring(6));
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case 'react-other':
|
||||
final emoji = await askReaction(context);
|
||||
if (emoji != null) await toggleReaction(event, timeline, emoji);
|
||||
case 'reply':
|
||||
onReply?.call(event);
|
||||
case 'thread':
|
||||
onOpenThread?.call(event);
|
||||
case 'retry':
|
||||
await event.sendAgain();
|
||||
case 'cancel':
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../matrix/client.dart';
|
||||
import '../theme.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
const _i18n = MatrixDefaultLocalizations();
|
||||
|
||||
/// The reactions offered with one tap.
|
||||
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||
|
||||
/// The key of a reaction event: the emoji (or text) it reacts with.
|
||||
String? reactionKey(Event reaction) => reaction.content
|
||||
.tryGetMap<String, Object?>('m.relates_to')
|
||||
?.tryGet<String>('key');
|
||||
|
||||
/// The user's own reaction with [key] to [event], if any.
|
||||
Event? myReaction(Event event, Timeline timeline, String key) => event
|
||||
.aggregatedEvents(timeline, RelationshipTypes.reaction)
|
||||
.where(
|
||||
(r) =>
|
||||
r.senderId == event.room.client.userID &&
|
||||
!r.redacted &&
|
||||
reactionKey(r) == key,
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
/// Reacts to [event] with [key], or takes the reaction back if it's there.
|
||||
Future<void> toggleReaction(Event event, Timeline timeline, String key) async {
|
||||
final mine = myReaction(event, timeline, key);
|
||||
if (mine != null) {
|
||||
await mine.redactEvent();
|
||||
} else {
|
||||
await event.room.sendReaction(event.eventId, key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> askReaction(BuildContext context) {
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialog) => AlertDialog(
|
||||
title: const Text('React with'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: 16,
|
||||
decoration: const InputDecoration(hintText: 'An emoji, or a word'),
|
||||
onSubmitted: (v) =>
|
||||
Navigator.pop(dialog, v.trim().isEmpty ? null : v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialog),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final v = controller.text.trim();
|
||||
Navigator.pop(dialog, v.isEmpty ? null : v);
|
||||
},
|
||||
child: const Text('React'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ReactionButton extends StatelessWidget {
|
||||
const ReactionButton({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
final String emoji;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.warp;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? c.tealSoft : c.surfaceSunken,
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 22)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reactions under a message, one chip per key with its count. Tapping
|
||||
/// one adds or takes back the user's own; a long press says who reacted.
|
||||
class Reactions extends StatelessWidget {
|
||||
const Reactions({super.key, required this.event, required this.timeline});
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reactions = event
|
||||
.aggregatedEvents(timeline, RelationshipTypes.reaction)
|
||||
.where((r) => !r.redacted && reactionKey(r) != null)
|
||||
.toList();
|
||||
if (reactions.isEmpty) return const SizedBox.shrink();
|
||||
final me = event.room.client.userID;
|
||||
final byKey = <String, List<Event>>{};
|
||||
for (final r in reactions) {
|
||||
byKey.putIfAbsent(reactionKey(r)!, () => []).add(r);
|
||||
}
|
||||
final keys = byKey.keys.toList()
|
||||
..sort((a, b) => byKey[b]!.length.compareTo(byKey[a]!.length));
|
||||
final c = context.warp;
|
||||
final text = Theme.of(context).textTheme;
|
||||
final canReact =
|
||||
event.status.isSent && event.room.canSendEvent(EventTypes.Reaction);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final key in keys)
|
||||
_ReactionChip(
|
||||
label: '$key ${byKey[key]!.length}',
|
||||
mine: byKey[key]!.any((r) => r.senderId == me),
|
||||
colours: c,
|
||||
style: text.labelMedium,
|
||||
onTap: !canReact
|
||||
? null
|
||||
: () => toggleReaction(event, timeline, key).catchError((
|
||||
Object e,
|
||||
) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(describeError(e))),
|
||||
);
|
||||
}
|
||||
}),
|
||||
onLongPress: () => _showReactors(context, key, byKey[key]!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showReactors(BuildContext context, String key, List<Event> list) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheet) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(key, style: const TextStyle(fontSize: 32)),
|
||||
for (final r in list)
|
||||
ListTile(
|
||||
leading: Avatar(
|
||||
name: r.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
seed: r.senderId,
|
||||
mxc: r.senderFromMemoryOrFallback.avatarUrl,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(r.senderFromMemoryOrFallback.calcDisplayname()),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionChip extends StatelessWidget {
|
||||
const _ReactionChip({
|
||||
required this.label,
|
||||
required this.mine,
|
||||
required this.colours,
|
||||
required this.style,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
final String label;
|
||||
final bool mine;
|
||||
final WarpColors colours;
|
||||
final TextStyle? style;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = colours;
|
||||
return Material(
|
||||
color: mine ? c.tealSoft : c.surfaceSunken,
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: mine ? c.tealStrong : c.line),
|
||||
),
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: Text(
|
||||
label,
|
||||
style: style?.copyWith(color: mine ? c.tealText : c.ink),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The message a reply answers, quoted above the reply: who wrote it and
|
||||
/// its first line.
|
||||
class ReplyPreview extends StatelessWidget {
|
||||
const ReplyPreview({super.key, required this.event, required this.timeline});
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Event?>(
|
||||
future: event.getReplyEvent(timeline),
|
||||
builder: (context, snapshot) {
|
||||
final original = snapshot.data;
|
||||
return QuoteBlock(
|
||||
sender: original?.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
text: original == null
|
||||
? (snapshot.connectionState == ConnectionState.done
|
||||
? 'Message not available'
|
||||
: '…')
|
||||
: quoteText(original.getDisplayEvent(timeline)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One line describing a message, for a quote or the reply bar.
|
||||
String quoteText(Event event) {
|
||||
if (event.redacted) return 'Message deleted';
|
||||
if (event.type == EventTypes.Encrypted) return 'Encrypted message';
|
||||
return switch (event.messageType) {
|
||||
MessageTypes.Image => '🖼 Image',
|
||||
MessageTypes.Sticker => 'Sticker',
|
||||
MessageTypes.Video => '🎬 Video',
|
||||
MessageTypes.Audio => '🎵 Audio',
|
||||
MessageTypes.File => '📄 ${event.body}',
|
||||
_ =>
|
||||
event.calcLocalizedBodyFallback(_i18n, hideReply: true).split('\n').first,
|
||||
};
|
||||
}
|
||||
|
||||
/// A quoted message: a teal bar, the sender, a line of text.
|
||||
class QuoteBlock extends StatelessWidget {
|
||||
const QuoteBlock({super.key, required this.sender, required this.text});
|
||||
final String? sender;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.warp;
|
||||
final style = Theme.of(context).textTheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 4, 10, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface.withValues(alpha: 0.6),
|
||||
border: Border(left: BorderSide(color: c.tealStrong, width: 3)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (sender != null)
|
||||
Text(
|
||||
sender!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.labelMedium?.copyWith(
|
||||
color: c.tealText,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style.bodySmall?.copyWith(color: c.inkMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a message that starts a thread: how many replies, opening it.
|
||||
class ThreadSummary extends StatelessWidget {
|
||||
const ThreadSummary({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.timeline,
|
||||
required this.onTap,
|
||||
});
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = threadReplyCount(event, timeline);
|
||||
if (count == 0) return const SizedBox.shrink();
|
||||
final c = context.warp;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum_outlined, size: 16, color: c.tealText),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
count == 1 ? '1 reply' : '$count replies',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: c.tealText,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many replies a thread root has: the server's count from the event's
|
||||
/// bundled relations, or the replies loaded here if that is more (some came
|
||||
/// in since the root was fetched).
|
||||
int threadReplyCount(Event root, Timeline timeline) {
|
||||
final bundled =
|
||||
root.unsigned
|
||||
?.tryGetMap<String, Object?>('m.relations')
|
||||
?.tryGetMap<String, Object?>(RelationshipTypes.thread)
|
||||
?.tryGet<int>('count') ??
|
||||
0;
|
||||
final loaded = root
|
||||
.aggregatedEvents(timeline, RelationshipTypes.thread)
|
||||
.length;
|
||||
return bundled > loaded ? bundled : loaded;
|
||||
}
|
||||
@@ -25,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
asn1lib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: asn1lib
|
||||
sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.5"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -129,6 +137,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
connectivity_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.1"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -153,6 +185,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dart_jsonwebtoken:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_jsonwebtoken
|
||||
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
dart_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_webrtc
|
||||
sha256: "078e3c431500147e5cc52b3c6ea41ed538f30c7720cc2467d2186c9251e62716"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -161,6 +209,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.15"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: ade1c36f8355d6284c98e4a47992ea263887ed3d1acb281e37635dd45f04c4aa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -424,6 +488,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: ebf5ceef2897caae425ed65a38b2961c29d9d9a626159418ed460e1a65038c00
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.2+hotfix.4"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -624,6 +696,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
livekit_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: livekit_client
|
||||
sha256: "1d2ae53abac63fdf8ac2c3f37529094b920048e5e459f29c1140b885d0cdf325"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.8.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -680,6 +768,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
mime_type:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime_type
|
||||
sha256: d652b613e84dac1af28030a9fba82c0999be05b98163f9e18a0849c6e63838bb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.4.2"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -696,6 +800,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.7"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -792,6 +904,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -800,6 +920,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "13b6717366aa7ec3792dd62c19c7d33a785708224365f2db23c5ca01f1c1a0f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -808,6 +936,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
random_string:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1205,6 +1349,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
window_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -48,6 +48,9 @@ dependencies:
|
||||
intl: ^0.20.3
|
||||
url_launcher: ^6.3.3
|
||||
mime: ^2.1.0
|
||||
livekit_client: ^2.13.1
|
||||
qr_flutter: ^4.1.0
|
||||
mobile_scanner: ^7.4.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in new issue
Block a user