Files
Warp/apps/mobile/lib/widgets/composer.dart
T
tajniak81andClaude Opus 5.5 4271d2ab17 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>
2026-10-18 23:00:57 +02:00

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),
),
],
),
),
],
),
),
);
}
}