- 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>
308 lines
9.3 KiB
Dart
308 lines
9.3 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|