- 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>
366 lines
11 KiB
Dart
366 lines
11 KiB
Dart
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;
|
|
}
|