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('m.relates_to') ?.tryGet('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 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 askReaction(BuildContext context) { final controller = TextEditingController(); return showDialog( 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 = >{}; 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 list) { showModalBottomSheet( 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( 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('m.relations') ?.tryGetMap(RelationshipTypes.thread) ?.tryGet('count') ?? 0; final loaded = root .aggregatedEvents(timeline, RelationshipTypes.thread) .length; return bundled > loaded ? bundled : loaded; }