import 'package:file_picker/file_picker.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:matrix/matrix.dart'; import 'package:url_launcher/url_launcher.dart'; import '../matrix/client.dart'; import '../matrix/media.dart'; import '../theme.dart'; import '../util/format.dart'; import 'avatar.dart'; import 'message_extras.dart'; const _i18n = MatrixDefaultLocalizations(); /// A one-line room event in the middle of the timeline: "Alice joined". class NoticeRow extends StatelessWidget { const NoticeRow({super.key, required this.event}); final Event event; @override Widget build(BuildContext context) { final c = context.warp; return Padding( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 6), child: Text( event.calcLocalizedBodyFallback(_i18n), textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.labelMedium?.copyWith(color: c.inkMuted), ), ); } } /// A message: a bubble on the right for the user's own, on the left with /// the sender's avatar and name for everyone else's. class MessageRow extends StatelessWidget { const MessageRow({ super.key, required this.event, required this.timeline, required this.firstInGroup, required this.lastInGroup, required this.readByOthers, this.onReply, this.onOpenThread, }); final Event event; final Timeline timeline; final bool firstInGroup; final bool lastInGroup; final bool readByOthers; /// Starts a reply to this message in the composer. final ValueChanged? onReply; /// Opens the thread this message starts (or would start). Null inside a /// thread, where there is no thread to open. final ValueChanged? onOpenThread; bool get _mine => event.senderId == event.room.client.userID; @override Widget build(BuildContext context) { final c = context.warp; final text = Theme.of(context).textTheme; final display = event.getDisplayEvent(timeline); final sender = event.senderFromMemoryOrFallback; final showSender = !_mine && firstInGroup && !event.room.isDirectChat; final bubbleColour = _mine ? c.tealSoft : c.bubbleOther; final isMedia = !display.redacted && (display.messageType == MessageTypes.Image || display.messageType == MessageTypes.Sticker); final radius = Radius.circular(18); final tight = Radius.circular(6); final shape = BorderRadius.only( topLeft: !_mine && !firstInGroup ? tight : radius, bottomLeft: !_mine && !lastInGroup ? tight : radius, topRight: _mine && !firstInGroup ? tight : radius, bottomRight: _mine && !lastInGroup ? tight : radius, ); 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( '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 ? ClipRRect(borderRadius: shape, child: body) : Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), decoration: BoxDecoration( color: bubbleColour, borderRadius: shape, ), child: body, ), ); return Padding( padding: EdgeInsets.fromLTRB( _mine ? 56 : 12, firstInGroup ? 8 : 2, _mine ? 12 : 56, lastInGroup ? 4 : 0, ), child: Row( mainAxisAlignment: _mine ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!_mine) ...[ SizedBox( width: 32, child: lastInGroup ? Avatar( name: sender.calcDisplayname(), seed: sender.id, mxc: sender.avatarUrl, size: 32, ) : null, ), const SizedBox(width: 8), ], Flexible( child: Column( crossAxisAlignment: _mine ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (showSender) Padding( padding: const EdgeInsets.only(left: 4, bottom: 2), child: Text( sender.calcDisplayname(), style: text.labelMedium?.copyWith( color: c.tealText, fontWeight: FontWeight.w600, ), ), ), 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), child: _Status( event: event, edited: display != event || event.hasAggregatedEvents( timeline, RelationshipTypes.edit, ), mine: _mine, read: readByOthers, ), ), ], ), ), ], ), ); } Future _showActions(BuildContext context, Event display) async { final hasText = !display.redacted && display.type == EventTypes.Message && !display.hasAttachment; final canSave = !display.redacted && display.hasAttachment; final canDelete = !event.redacted && event.status.isSent && event.canRedact && event.room.canRedact; final failed = event.status.isError; final canRespond = event.status.isSent && !event.redacted && event.room.canSendDefaultMessages; final action = await showModalBottomSheet( context: context, builder: (sheet) => SafeArea( 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), title: const Text('Retry'), onTap: () => Navigator.pop(sheet, 'retry'), ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Delete'), onTap: () => Navigator.pop(sheet, 'cancel'), ), ], if (hasText) ListTile( leading: const Icon(Icons.copy), title: const Text('Copy text'), onTap: () => Navigator.pop(sheet, 'copy'), ), if (canSave) ListTile( leading: const Icon(Icons.download_outlined), title: const Text('Save'), onTap: () => Navigator.pop(sheet, 'save'), ), if (canDelete) ListTile( leading: Icon(Icons.delete_outline, color: context.warp.danger), title: Text( 'Delete', style: TextStyle(color: context.warp.danger), ), onTap: () => Navigator.pop(sheet, 'redact'), ), const SizedBox(height: 8), ], ), ), ); 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': await event.cancelSend(); case 'copy': await Clipboard.setData( ClipboardData( text: display.calcLocalizedBodyFallback( _i18n, hideReply: true, plaintextBody: false, ), ), ); case 'save': await saveAttachment(context, display); case 'redact': final ok = await showDialog( context: context, builder: (dialog) => AlertDialog( title: const Text('Delete message?'), content: const Text( 'It is removed for everyone in the room. This can\'t be ' 'undone.', ), actions: [ TextButton( onPressed: () => Navigator.pop(dialog, false), child: const Text('Cancel'), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: context.warp.danger, ), onPressed: () => Navigator.pop(dialog, true), child: const Text('Delete'), ), ], ), ); if (ok == true) await event.redactEvent(); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(describeError(e)))); } } } } /// Under a group: the time, "edited", and for the user's own messages /// whether it is sending, sent, read, or failed. class _Status extends StatelessWidget { const _Status({ required this.event, required this.edited, required this.mine, required this.read, }); final Event event; final bool edited; final bool mine; final bool read; @override Widget build(BuildContext context) { final c = context.warp; final style = Theme.of( context, ).textTheme.labelSmall?.copyWith(color: c.inkMuted); final status = event.status; final parts = [ TextSpan(text: formatTime(event.originServerTs)), if (edited) const TextSpan(text: ' · edited'), ]; if (mine) { if (status.isError) { parts.add( TextSpan( text: ' · Not sent. Hold for Retry', style: style?.copyWith(color: c.danger), ), ); } else if (status.isSending) { parts.add(const TextSpan(text: ' · Sending')); } else if (read) { parts.add( TextSpan( text: ' · Read', style: style?.copyWith(color: c.tealText), ), ); } else { parts.add(const TextSpan(text: ' · Sent')); } } return Text.rich(TextSpan(children: parts), style: style); } } class _MessageBody extends StatelessWidget { const _MessageBody({ required this.event, required this.original, required this.mine, }); /// The event as shown: the latest edit of [original]. final Event event; final Event original; final bool mine; @override Widget build(BuildContext context) { final c = context.warp; final text = Theme.of(context).textTheme; final muted = text.bodyLarge?.copyWith( color: c.inkMuted, fontStyle: FontStyle.italic, ); if (event.redacted) { return Text('Message deleted', style: muted); } if (event.type == EventTypes.Encrypted) { return _UndecryptableBody(event: event); } switch (event.messageType) { case MessageTypes.Image: case MessageTypes.Sticker: return _ImageBody(event: event); case MessageTypes.File: case MessageTypes.Video: case MessageTypes.Audio: return _FileBody(event: event); case MessageTypes.BadEncrypted: return _UndecryptableBody(event: event); } final body = event.calcLocalizedBodyFallback( _i18n, hideReply: true, plaintextBody: false, ); final style = event.messageType == MessageTypes.Emote ? text.bodyLarge?.copyWith(fontStyle: FontStyle.italic) : event.messageType == MessageTypes.Notice ? text.bodyLarge?.copyWith(color: c.inkMuted) : text.bodyLarge; final emote = event.messageType == MessageTypes.Emote ? '* ${event.senderFromMemoryOrFallback.calcDisplayname()} ' : ''; return LinkifiedText('$emote$body', style: style); } } /// A message this session has no key for: why, in words, rather than just /// "encrypted". class _UndecryptableBody extends StatelessWidget { const _UndecryptableBody({required this.event}); final Event event; @override Widget build(BuildContext context) { final c = context.warp; final client = event.room.client; final String reason; if (!client.encryptionEnabled) { reason = "Encrypted. This app can't decrypt messages."; } else if (client.isUnknownSession) { reason = "Can't decrypt this yet. Verify this session in Settings to read " 'older messages.'; } else if (event.content['can_request_session'] == true) { reason = 'Waiting for the key to this message. It may have been sent ' 'before this session signed in.'; } else { reason = "Couldn't decrypt this message."; } return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.lock_clock_outlined, size: 16, color: c.inkMuted), const SizedBox(width: 6), Flexible( child: Text( reason, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: c.inkMuted, fontStyle: FontStyle.italic, ), ), ), ], ); } } class _ImageBody extends StatefulWidget { const _ImageBody({required this.event}); final Event event; @override State<_ImageBody> createState() => _ImageBodyState(); } class _ImageBodyState extends State<_ImageBody> { late Future _bytes = Media.attachment( widget.event, thumbnail: true, ); @override Widget build(BuildContext context) { final c = context.warp; final info = widget.event.infoMap; final w = (info['w'] as num?)?.toDouble(); final h = (info['h'] as num?)?.toDouble(); final maxWidth = MediaQuery.sizeOf(context).width * 0.66; var width = maxWidth.clamp(120.0, 320.0); var height = width * 0.75; if (w != null && h != null && w > 0 && h > 0) { height = (width * h / w).clamp(80.0, 360.0); if (h > w) width = (height * w / h).clamp(80.0, width); } return FutureBuilder( future: _bytes, builder: (context, snapshot) { final data = snapshot.data; final placeholder = Container( width: width, height: height, color: c.bubbleOther, alignment: Alignment.center, child: snapshot.connectionState == ConnectionState.done ? IconButton( tooltip: 'Retry', icon: Icon(Icons.broken_image_outlined, color: c.inkMuted), onPressed: () => setState(() { Media.evict(widget.event); _bytes = Media.attachment(widget.event, thumbnail: true); }), ) : const SizedBox.square( dimension: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ); if (data == null) return placeholder; return GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => ImageViewer(event: widget.event)), ), child: Image.memory( data, width: width, height: height, fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => placeholder, ), ); }, ); } } class _FileBody extends StatefulWidget { const _FileBody({required this.event}); final Event event; @override State<_FileBody> createState() => _FileBodyState(); } class _FileBodyState extends State<_FileBody> { bool _saving = false; @override Widget build(BuildContext context) { final c = context.warp; final text = Theme.of(context).textTheme; final event = widget.event; final size = event.infoMap['size']; final icon = switch (event.messageType) { MessageTypes.Video => Icons.movie_outlined, MessageTypes.Audio => Icons.audiotrack_outlined, _ => Icons.insert_drive_file_outlined, }; return InkWell( onTap: _saving ? null : () async { setState(() => _saving = true); try { await saveAttachment(context, event); } finally { if (mounted) setState(() => _saving = false); } }, child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: c.surface, borderRadius: BorderRadius.circular(10), ), child: _saving ? const Padding( padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2), ) : Icon(icon, color: c.tealText), ), const SizedBox(width: 10), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( event.body, maxLines: 2, overflow: TextOverflow.ellipsis, style: text.titleSmall, ), Text( [ if (size is int) formatBytes(size), 'Tap to save', ].join(' · '), style: text.labelSmall, ), ], ), ), ], ), ); } } /// Downloads (and decrypts) an attachment and asks where to save it. Future saveAttachment(BuildContext context, Event event) async { final messenger = ScaffoldMessenger.of(context); try { final file = await event.downloadAndDecryptAttachment(); final saved = await FilePicker.saveFile( fileName: file.name, bytes: file.bytes, ); if (saved != null) { messenger.showSnackBar(SnackBar(content: Text('Saved ${file.name}'))); } } catch (e) { messenger.showSnackBar(SnackBar(content: Text(describeError(e)))); } } /// An image full screen, pinch to zoom. class ImageViewer extends StatelessWidget { const ImageViewer({super.key, required this.event}); final Event event; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, foregroundColor: Colors.white, shape: const Border(), title: Text( event.body, style: const TextStyle(color: Colors.white, fontSize: 16), ), actions: [ IconButton( tooltip: 'Save', icon: const Icon(Icons.download_outlined), onPressed: () => saveAttachment(context, event), ), ], ), body: FutureBuilder( future: Media.attachment(event), builder: (context, snapshot) { final data = snapshot.data; if (data == null) { return Center( child: snapshot.connectionState == ConnectionState.done ? const Text( "Couldn't load the image.", style: TextStyle(color: Colors.white), ) : const CircularProgressIndicator(), ); } return InteractiveViewer( maxScale: 6, child: Center(child: Image.memory(data)), ); }, ), ); } } /// Text with web links that open in the browser. class LinkifiedText extends StatefulWidget { const LinkifiedText(this.text, {super.key, this.style}); final String text; final TextStyle? style; static final _url = RegExp( r'''\bhttps?://[^\s<>"']+[^\s<>"'.,;:!?)\]]''', caseSensitive: false, ); @override State createState() => _LinkifiedTextState(); } class _LinkifiedTextState extends State { final _recognizers = []; @override void dispose() { for (final r in _recognizers) { r.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { for (final r in _recognizers) { r.dispose(); } _recognizers.clear(); final text = widget.text; final matches = LinkifiedText._url.allMatches(text).toList(); if (matches.isEmpty) return Text(text, style: widget.style); final link = widget.style?.copyWith( color: context.warp.tealText, decoration: TextDecoration.underline, decorationColor: context.warp.tealText, ); final spans = []; var at = 0; for (final m in matches) { if (m.start > at) spans.add(TextSpan(text: text.substring(at, m.start))); final url = m.group(0)!; final recognizer = TapGestureRecognizer() ..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); _recognizers.add(recognizer); spans.add(TextSpan(text: url, style: link, recognizer: recognizer)); at = m.end; } if (at < text.length) spans.add(TextSpan(text: text.substring(at))); return Text.rich(TextSpan(children: spans), style: widget.style); } }