import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/image_group.dart'; import 'package:stream_chat_flutter/src/message_action.dart'; import 'package:stream_chat_flutter/src/message_actions_modal.dart'; import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/src/url_attachment.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Widget builder for building attachments typedef AttachmentBuilder = Widget Function( BuildContext, Message, List, ); /// Callback for when quoted message is tapped typedef OnQuotedMessageTap = void Function(String?); /// The display behaviour of a widget enum DisplayWidget { /// Hides the widget replacing its space with a spacer hide, /// Hides the widget not replacing its space gone, /// Shows the widget normally show, } /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_widget.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_widget_paint.png) /// /// It shows a message with reactions, replies and user avatar. /// /// Usually you don't use this widget as it's the default message widget used by /// [MessageListView]. /// /// The widget components render the ui based on the first ancestor of type /// [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageWidget extends StatefulWidget { /// MessageWidget({ Key? key, required this.message, required this.messageTheme, this.reverse = false, this.translateUserAvatar = true, this.shape, this.attachmentShape, this.borderSide, this.attachmentBorderSide, this.borderRadiusGeometry, this.attachmentBorderRadiusGeometry, this.onMentionTap, this.onMessageTap, this.showReactionPickerIndicator = false, this.showUserAvatar = DisplayWidget.show, this.showSendingIndicator = true, this.showThreadReplyIndicator = false, this.showInChannelIndicator = false, this.onReplyTap, this.onThreadTap, this.showUsername = true, this.showTimestamp = true, this.showReactions = true, this.showDeleteMessage = true, this.showEditMessage = true, this.showReplyMessage = true, this.showThreadReplyMessage = true, this.showResendMessage = true, this.showCopyMessage = true, this.showFlagButton = true, this.onUserAvatarTap, this.onLinkTap, this.onMessageActions, this.onShowMessage, this.editMessageInputBuilder, this.textBuilder, this.onReturnAction, Map? customAttachmentBuilders, this.readList, this.padding, this.textPadding = const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), this.attachmentPadding = EdgeInsets.zero, this.allRead = false, this.onQuotedMessageTap, this.customActions = const [], this.onAttachmentTap, }) : attachmentBuilders = { 'image': (context, message, attachments) { final border = RoundedRectangleBorder( borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); final mediaQueryData = MediaQuery.of(context); if (attachments.length > 1) { return Padding( padding: attachmentPadding, child: wrapAttachmentWidget( context, Material( color: messageTheme.messageBackgroundColor, child: ImageGroup( size: Size( mediaQueryData.size.width * 0.8, mediaQueryData.size.height * 0.3, ), images: attachments, message: message, messageTheme: messageTheme, onShowMessage: onShowMessage, onReturnAction: onReturnAction, ), ), border, reverse, ), ); } return wrapAttachmentWidget( context, ImageAttachment( attachment: attachments[0], message: message, messageTheme: messageTheme, size: Size( mediaQueryData.size.width * 0.8, mediaQueryData.size.height * 0.3, ), onShowMessage: onShowMessage, onReturnAction: onReturnAction, onAttachmentTap: onAttachmentTap != null ? () { onAttachmentTap.call(message, attachments[0]); } : null, ), border, reverse, ); }, 'video': (context, message, attachments) { final border = RoundedRectangleBorder( borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); return wrapAttachmentWidget( context, Column( children: attachments.map((attachment) { final mediaQueryData = MediaQuery.of(context); return VideoAttachment( attachment: attachment, messageTheme: messageTheme, size: Size( mediaQueryData.size.width * 0.8, mediaQueryData.size.height * 0.3, ), message: message, onShowMessage: onShowMessage, onReturnAction: onReturnAction, onAttachmentTap: onAttachmentTap != null ? () { onAttachmentTap(message, attachment); } : null, ); }).toList(), ), border, reverse, ); }, 'giphy': (context, message, attachments) { final border = RoundedRectangleBorder( borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); return wrapAttachmentWidget( context, Column( children: attachments.map((attachment) { final mediaQueryData = MediaQuery.of(context); return GiphyAttachment( attachment: attachment, message: message, size: Size( mediaQueryData.size.width * 0.8, mediaQueryData.size.height * 0.3, ), onShowMessage: onShowMessage, onReturnAction: onReturnAction, ); }).toList(), ), border, reverse, ); }, 'file': (context, message, attachments) { final border = RoundedRectangleBorder( side: attachmentBorderSide ?? BorderSide( color: StreamChatTheme.of(context).colorTheme.greyWhisper, ), borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); return Column( children: attachments .map((attachment) { final mediaQueryData = MediaQuery.of(context); return wrapAttachmentWidget( context, FileAttachment( message: message, attachment: attachment, size: Size( mediaQueryData.size.width * 0.8, mediaQueryData.size.height * 0.3, ), ), border, reverse, ); }) .insertBetween(SizedBox( height: attachmentPadding.vertical / 2, )) .toList(), ); }, }..addAll(customAttachmentBuilders ?? {}), super(key: key); /// Function called on mention tap final void Function(User)? onMentionTap; /// The function called when tapping on threads final void Function(Message)? onThreadTap; /// The function called when tapping on replies final void Function(Message)? onReplyTap; /// Widget builder for edit message layout final Widget Function(BuildContext, Message)? editMessageInputBuilder; /// Widget builder for building text final Widget Function(BuildContext, Message)? textBuilder; /// Function called on long press final void Function(BuildContext, Message)? onMessageActions; /// The message final Message message; /// The message theme final MessageTheme messageTheme; /// If true the widget will be mirrored final bool reverse; /// The shape of the message text final ShapeBorder? shape; /// The shape of an attachment final ShapeBorder? attachmentShape; /// The borderside of the message text final BorderSide? borderSide; /// The borderside of an attachment final BorderSide? attachmentBorderSide; /// The border radius of the message text final BorderRadiusGeometry? borderRadiusGeometry; /// The border radius of an attachment final BorderRadiusGeometry? attachmentBorderRadiusGeometry; /// The padding of the widget final EdgeInsetsGeometry? padding; /// The internal padding of the message text final EdgeInsets textPadding; /// The internal padding of an attachment final EdgeInsetsGeometry attachmentPadding; /// It controls the display behaviour of the user avatar final DisplayWidget showUserAvatar; /// It controls the display behaviour of the sending indicator final bool showSendingIndicator; /// If true the widget will show the reactions final bool showReactions; /// final bool allRead; /// If true the widget will show the thread reply indicator final bool showThreadReplyIndicator; /// If true the widget will show the show in channel indicator final bool showInChannelIndicator; /// The function called when tapping on UserAvatar final void Function(User)? onUserAvatarTap; /// The function called when tapping on a link final void Function(String)? onLinkTap; /// Used in [MessageReactionsModal] and [MessageActionsModal] final bool showReactionPickerIndicator; /// List of users who read final List? readList; /// Callback when show message is tapped final ShowMessageCallback? onShowMessage; /// Handle return actions like reply message final ValueChanged? onReturnAction; /// If true show the users username next to the timestamp of the message final bool showUsername; /// Show message timestamp final bool showTimestamp; /// Show reply action final bool showReplyMessage; /// Show thread reply action final bool showThreadReplyMessage; /// Show edit action final bool showEditMessage; /// Show copy action final bool showCopyMessage; /// Show delete action final bool showDeleteMessage; /// Show resend action final bool showResendMessage; /// Show flag action final bool showFlagButton; /// Builder for respective attachment types final Map attachmentBuilders; /// Center user avatar with bottom of the message final bool translateUserAvatar; /// Function called when quotedMessage is tapped final OnQuotedMessageTap? onQuotedMessageTap; /// Function called when message is tapped final void Function(Message)? onMessageTap; /// List of custom actions shown on message long tap final List customActions; /// Customize onTap on attachment final void Function(Message message, Attachment attachment)? onAttachmentTap; @override _MessageWidgetState createState() => _MessageWidgetState(); } class _MessageWidgetState extends State with AutomaticKeepAliveClientMixin { bool get showThreadReplyIndicator => widget.showThreadReplyIndicator; bool get showSendingIndicator => widget.showSendingIndicator; bool get isDeleted => widget.message.isDeleted; bool get showUsername => widget.showUsername; bool get showTimeStamp => widget.showTimestamp; bool get isMessageRead => widget.readList?.isNotEmpty == true; bool get showInChannel => widget.showInChannelIndicator; bool get hasQuotedMessage => widget.message.quotedMessage != null; bool get isSendFailed => widget.message.status == MessageSendingStatus.failed; bool get isUpdateFailed => widget.message.status == MessageSendingStatus.failed_update; bool get isDeleteFailed => widget.message.status == MessageSendingStatus.failed_delete; bool get isFailedState => isSendFailed || isUpdateFailed || isDeleteFailed; bool get isGiphy => widget.message.attachments.any((element) => element.type == 'giphy') == true; bool get hasNonUrlAttachments => widget.message.attachments .where((it) => it.ogScrapeUrl == null) .isNotEmpty == true; bool get hasUrlAttachments => widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true; bool get showBottomRow => showThreadReplyIndicator || showUsername || showTimeStamp || showInChannel || showSendingIndicator || isDeleted; @override bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true; @override Widget build(BuildContext context) { super.build(context); final avatarWidth = widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; final leftPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; return Material( type: MaterialType.transparency, child: Portal( child: InkWell( onTap: () { widget.onMessageTap!(widget.message); }, onLongPress: widget.message.isDeleted && !isFailedState ? null : () => onLongPress(context), child: Padding( padding: widget.padding ?? const EdgeInsets.all(8), child: FractionallySizedBox( alignment: widget.reverse ? Alignment.centerRight : Alignment.centerLeft, widthFactor: 0.78, child: Column( crossAxisAlignment: widget.reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Stack( clipBehavior: Clip.none, alignment: widget.reverse ? AlignmentDirectional.bottomEnd : AlignmentDirectional.bottomStart, children: [ Column( crossAxisAlignment: widget.reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ if (widget.showUserAvatar == DisplayWidget.show && widget.message.user != null) ...[ _buildUserAvatar(), const SizedBox(width: 4), ], if (widget.showUserAvatar == DisplayWidget.hide) SizedBox(width: avatarWidth + 4), Flexible( child: PortalEntry( portal: Container( transform: Matrix4.translationValues( widget.reverse ? 12 : -12, 0, 0), constraints: const BoxConstraints( maxWidth: 22 * 6.0), child: _buildReactionIndicator(context), ), portalAnchor: Alignment(widget.reverse ? 1 : -1, -1), childAnchor: Alignment(widget.reverse ? -1 : 1, -1), child: Stack( clipBehavior: Clip.none, children: [ Padding( padding: widget.showReactions ? EdgeInsets.only( top: widget .message .reactionCounts ?.isNotEmpty == true ? 18 : 0, ) : EdgeInsets.zero, child: (widget.message.isDeleted && !isFailedState) ? Container( // ignore: lines_longer_than_80_chars margin: EdgeInsets.symmetric( horizontal: // ignore: lines_longer_than_80_chars widget.showUserAvatar == // ignore: lines_longer_than_80_chars DisplayWidget .gone ? 0 : 4.0), child: DeletedMessage( borderRadiusGeometry: widget .borderRadiusGeometry, borderSide: widget.borderSide, shape: widget.shape, messageTheme: widget.messageTheme, ), ) : Card( clipBehavior: Clip.antiAlias, elevation: 0, margin: EdgeInsets.symmetric( horizontal: (isFailedState ? 15.0 : 0.0) + // ignore: lines_longer_than_80_chars (widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0), ), shape: widget.shape ?? RoundedRectangleBorder( side: widget.borderSide ?? BorderSide( color: widget // ignore: lines_longer_than_80_chars .messageTheme // ignore: lines_longer_than_80_chars .messageBorderColor ?? Colors.grey, ), borderRadius: widget // ignore: lines_longer_than_80_chars .borderRadiusGeometry ?? BorderRadius.zero, ), color: _getBackgroundColor(), child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ if (hasQuotedMessage) _buildQuotedMessage(), if (hasNonUrlAttachments) _parseAttachments(), if (!isGiphy) _buildTextBubble(), ], ), ), ), if (widget.showReactionPickerIndicator) Positioned( right: widget.reverse ? null : 4, left: widget.reverse ? 4 : null, top: -8, child: CustomPaint( painter: ReactionBubblePainter( StreamChatTheme.of(context) .colorTheme .white, Colors.transparent, Colors.transparent, tailCirclesSpace: 1, ), ), ), ], ), ), ), ], ), if (showBottomRow) SizedBox(height: context.textScaleFactor * 18.0), ], ), if (showBottomRow) Padding( padding: EdgeInsets.only(left: leftPadding), child: _bottomRow, ), if (isFailedState) Positioned( left: widget.reverse ? 0 : null, right: widget.reverse ? null : 0, bottom: showBottomRow ? 18 : -2, child: StreamSvgIcon.error(size: 20), ), ], ), ], ), ), ), ), ), ); } Widget _buildQuotedMessage() { final isMyMessage = widget.message.user?.id == StreamChat.of(context).user?.id; final onTap = widget.message.quotedMessage?.isDeleted != true && widget.onQuotedMessageTap != null ? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId) : null; final chatThemeData = StreamChatTheme.of(context); return QuotedMessageWidget( onTap: onTap, message: widget.message.quotedMessage!, messageTheme: isMyMessage ? chatThemeData.otherMessageTheme : chatThemeData.ownMessageTheme, reverse: widget.reverse, padding: EdgeInsets.only( right: 8, left: 8, top: 8, bottom: hasNonUrlAttachments ? 8 : 0), ); } Widget get _bottomRow { if (isDeleted) { final chatThemeData = StreamChatTheme.of(context); return Row( mainAxisSize: MainAxisSize.min, children: [ StreamSvgIcon.eye( color: chatThemeData.colorTheme.grey, size: 16, ), const SizedBox(width: 8), Text( 'Only visible to you', style: chatThemeData.textTheme.footnote .copyWith(color: chatThemeData.colorTheme.grey), ), ], ); } final children = []; final threadParticipants = widget.message.threadParticipants?.take(2); final showThreadParticipants = threadParticipants?.isNotEmpty == true; final replyCount = widget.message.replyCount; var msg = 'Thread Reply'; if (showThreadReplyIndicator && replyCount! > 1) { msg = '$replyCount Thread Replies'; } // ignore: prefer_function_declarations_over_variables final onThreadTap = () async { try { var message = widget.message; if (showInChannel) { final channel = StreamChannel.of(context); message = await channel.getMessage(widget.message.parentId!); } return widget.onThreadTap!(message); } catch (e, stk) { print(e); print(stk); // ignore: avoid_returning_null_for_void return null; } }; const usernameKey = Key('username'); children.addAll([ if (showInChannel || showThreadReplyIndicator) ...[ if (showThreadParticipants) SizedBox.fromSize( size: Size((threadParticipants!.length * 8.0) + 8, 16), child: _buildThreadParticipantsIndicator(threadParticipants), ), InkWell( onTap: widget.onThreadTap != null ? onThreadTap : null, child: Text(msg, style: widget.messageTheme.replies), ), ], if (showUsername) Text( widget.message.user!.name, maxLines: 1, key: usernameKey, style: widget.messageTheme.messageAuthor, overflow: TextOverflow.ellipsis, ), if (showTimeStamp) Text( Jiffy(widget.message.createdAt.toLocal()).jm, style: widget.messageTheme.createdAt, ), if (showSendingIndicator) _buildSendingIndicator(), ]); final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) && (showThreadReplyIndicator || showInChannel); return Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: widget.reverse ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ if (showThreadTail && !widget.reverse) Container( margin: EdgeInsets.only( bottom: context.textScaleFactor * ((widget.messageTheme.replies?.fontSize ?? 1) / 2), ), child: CustomPaint( size: const Size(16, 32) * context.textScaleFactor, painter: _ThreadReplyPainter( context: context, color: widget.messageTheme.messageBorderColor, reverse: widget.reverse, ), ), ), ...children.map( (child) { Widget mappedChild = SizedBox( height: context.textScaleFactor * 14, child: child, ); if (child.key == usernameKey) { mappedChild = Flexible(child: mappedChild); } return mappedChild; }, ), if (showThreadTail && widget.reverse) Container( margin: EdgeInsets.only( bottom: context.textScaleFactor * ((widget.messageTheme.replies?.fontSize ?? 1) / 2), ), child: CustomPaint( size: const Size(16, 32) * context.textScaleFactor, painter: _ThreadReplyPainter( context: context, color: widget.messageTheme.messageBorderColor, reverse: widget.reverse, ), ), ), ].insertBetween(const SizedBox(width: 8)), ); } Widget _buildUrlAttachment() { final urlAttachment = widget.message.attachments .firstWhere((element) => element.ogScrapeUrl != null); final host = Uri.parse(urlAttachment.ogScrapeUrl!).host; final splitList = host.split('.'); final hostName = splitList.length == 3 ? splitList[1] : splitList[0]; final hostDisplayName = urlAttachment.authorName?.capitalize() ?? getWebsiteName(hostName.toLowerCase()) ?? hostName.capitalize(); return UrlAttachment( urlAttachment: urlAttachment, hostDisplayName: hostDisplayName, textPadding: widget.textPadding, ); } Widget _buildThreadParticipantsIndicator(Iterable threadParticipants) { var padding = 0.0; return Stack( children: threadParticipants.map((user) { padding += 8.0; return Positioned( right: padding - 8, bottom: 0, top: 0, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: StreamChatTheme.of(context).colorTheme.white, ), padding: const EdgeInsets.all(1), child: UserAvatar( user: user, constraints: BoxConstraints.loose(const Size.fromRadius(7)), showOnlineStatus: false, ), ), ); }).toList(), ); } Widget _buildReactionIndicator( BuildContext context, ) { final ownId = StreamChat.of(context).user!.id; final reactionsMap = {}; widget.message.latestReactions?.forEach((element) { if (!reactionsMap.containsKey(element.type) || element.user!.id == ownId) { reactionsMap[element.type] = element; } }); final reactionsList = reactionsMap.values.toList() ..sort((a, b) => a.user!.id == ownId ? 1 : -1); return AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: (widget.showReactions && (widget.message.reactionCounts?.isNotEmpty == true) && !widget.message.isDeleted) ? GestureDetector( onTap: () => _showMessageReactionsModalBottomSheet(context), child: ReactionBubble( key: ValueKey('${widget.message.id}.reactions'), reverse: widget.reverse, flipTail: widget.reverse, backgroundColor: widget.messageTheme.reactionsBackgroundColor ?? Colors.transparent, borderColor: widget.messageTheme.reactionsBorderColor ?? Colors.transparent, maskColor: widget.messageTheme.reactionsMaskColor ?? Colors.transparent, reactions: reactionsList, ), ) : const SizedBox(), ); } void _showMessageActionModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showDialog( context: context, barrierColor: StreamChatTheme.of(context).colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, child: MessageActionsModal( onCopyTap: (message) => Clipboard.setData(ClipboardData(text: message.text)), attachmentBorderRadiusGeometry: widget.attachmentBorderRadiusGeometry as BorderRadius?, showUserAvatar: widget.message.user!.id == channel.client.state.user!.id ? DisplayWidget.gone : DisplayWidget.show, messageTheme: widget.messageTheme, messageShape: widget.shape ?? _getDefaultShape(context), attachmentShape: widget.attachmentShape ?? _getDefaultAttachmentShape(context), reverse: widget.reverse, showDeleteMessage: widget.showDeleteMessage || isDeleteFailed, message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, onReplyTap: widget.onReplyTap, onThreadReplyTap: widget.onThreadTap, showResendMessage: widget.showResendMessage && (isSendFailed || isUpdateFailed), showCopyMessage: widget.showCopyMessage && !isFailedState && widget.message.text?.trim().isNotEmpty == true, showEditMessage: widget.showEditMessage && !isDeleteFailed && widget.message.attachments .any((element) => element.type == 'giphy') != true, showReactions: widget.showReactions, showReplyMessage: widget.showReplyMessage && !isFailedState && widget.onReplyTap != null, showThreadReplyMessage: widget.showThreadReplyMessage && !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, customActions: widget.customActions, ), )); } void _showMessageReactionsModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showDialog( context: context, barrierColor: StreamChatTheme.of(context).colorTheme.overlay, builder: (context) => StreamChannel( channel: channel, child: MessageReactionsModal( attachmentBorderRadiusGeometry: widget.attachmentBorderRadiusGeometry as BorderRadius?, showUserAvatar: widget.message.user!.id == channel.client.state.user!.id ? DisplayWidget.gone : DisplayWidget.show, onUserAvatarTap: widget.onUserAvatarTap, messageTheme: widget.messageTheme, messageShape: widget.shape ?? _getDefaultShape(context), attachmentShape: widget.attachmentShape ?? _getDefaultAttachmentShape(context), reverse: widget.reverse, message: widget.message, showReactions: widget.showReactions, ), ), ); } ShapeBorder _getDefaultAttachmentShape(BuildContext context) { final hasFiles = widget.message.attachments.any((it) => it.type == 'file') == true; return RoundedRectangleBorder( side: hasFiles ? widget.attachmentBorderSide ?? BorderSide( color: StreamChatTheme.of(context).colorTheme.greyWhisper, ) : BorderSide.none, borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero, ); } ShapeBorder _getDefaultShape(BuildContext context) => RoundedRectangleBorder( side: widget.borderSide ?? BorderSide( color: StreamChatTheme.of(context).colorTheme.greyWhisper, ), borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, ); Widget _parseAttachments() { final attachmentGroups = >{}; widget.message.attachments .where((element) => element.ogScrapeUrl == null && element.type != null) .forEach((e) { if (attachmentGroups[e.type] == null) { attachmentGroups[e.type!] = []; } attachmentGroups[e.type]?.add(e); }); final attachmentList = []; attachmentGroups.forEach((type, attachments) { final attachmentBuilder = widget.attachmentBuilders[type]; if (attachmentBuilder == null) return; final attachmentWidget = attachmentBuilder( context, widget.message, attachments, ); attachmentList.add(attachmentWidget); }); return Padding( padding: widget.attachmentPadding, child: Column( mainAxisSize: MainAxisSize.min, children: attachmentList.insertBetween(SizedBox( height: widget.attachmentPadding.vertical / 2, )), ), ); } void onLongPress(BuildContext context) { if (widget.message.isEphemeral || widget.message.status == MessageSendingStatus.sending) { return; } if (widget.onMessageActions != null) { widget.onMessageActions!(context, widget.message); } else { _showMessageActionModalBottomSheet(context); } return; } Widget _buildSendingIndicator() { final style = widget.messageTheme.createdAt; final message = widget.message; if (hasNonUrlAttachments && (message.status == MessageSendingStatus.sending || message.status == MessageSendingStatus.updating)) { final totalAttachments = message.attachments.length; final uploadRemaining = message.attachments.where((it) => !it.uploadState.isSuccess).length; if (uploadRemaining == 0) { return StreamSvgIcon.check( size: style!.fontSize, color: IconTheme.of(context).color!.withOpacity(0.5), ); } return Text( 'Uploading $uploadRemaining/$totalAttachments ...', style: style, ); } Widget child = SendingIndicator( message: message, isMessageRead: isMessageRead, size: style!.fontSize, ); if (isMessageRead) { child = Row( children: [ if (StreamChannel.of(context).channel.memberCount! > 2) Text( widget.readList!.length.toString(), style: style.copyWith( color: StreamChatTheme.of(context).colorTheme.accentBlue, ), ), const SizedBox(width: 2), child, ], ); } return child; } Widget _buildUserAvatar() => Transform.translate( offset: Offset( 0, widget.translateUserAvatar ? (widget.messageTheme.avatarTheme?.constraints.maxHeight ?? 40) / 2 : 0, ), child: UserAvatar( user: widget.message.user!, onTap: widget.onUserAvatarTap, constraints: widget.messageTheme.avatarTheme!.constraints, borderRadius: widget.messageTheme.avatarTheme!.borderRadius, showOnlineStatus: false, ), ); Widget _buildTextBubble() { if (widget.message.text!.trim().isEmpty) return const Offstage(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: isOnlyEmoji ? EdgeInsets.zero : widget.textPadding, child: widget.textBuilder != null ? widget.textBuilder!(context, widget.message) : MessageText( onLinkTap: widget.onLinkTap, message: widget.message, onMentionTap: widget.onMentionTap, messageTheme: isOnlyEmoji ? widget.messageTheme.copyWith( messageText: widget.messageTheme.messageText!.copyWith( fontSize: 42, )) : widget.messageTheme, ), ), if (hasUrlAttachments && !hasQuotedMessage) _buildUrlAttachment(), ], ); } bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji; Color? _getBackgroundColor() { if (hasQuotedMessage) { return widget.messageTheme.messageBackgroundColor; } if (hasUrlAttachments) { return StreamChatTheme.of(context).colorTheme.blueAlice; } if (isOnlyEmoji) { return Colors.transparent; } if (isGiphy) { return Colors.transparent; } return widget.messageTheme.messageBackgroundColor; } void retryMessage(BuildContext context) { final channel = StreamChannel.of(context).channel; if (widget.message.status == MessageSendingStatus.failed) { channel.sendMessage(widget.message); return; } if (widget.message.status == MessageSendingStatus.failed_update) { channel.updateMessage(widget.message); return; } if (widget.message.status == MessageSendingStatus.failed_delete) { channel.deleteMessage(widget.message); return; } } } class _ThreadReplyPainter extends CustomPainter { const _ThreadReplyPainter({ this.context, required this.color, this.reverse = false, }); final Color? color; final BuildContext? context; final bool reverse; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ?? StreamChatTheme.of(context!).colorTheme.greyGainsboro ..style = PaintingStyle.stroke ..strokeWidth = 1 ..strokeCap = StrokeCap.round; final path = Path() ..moveTo(reverse ? size.width : 0, 0) ..quadraticBezierTo(reverse ? size.width : 0, size.height * 0.38, reverse ? size.width : 0, size.height * 0.50) ..quadraticBezierTo( reverse ? size.width : 0, size.height, reverse ? 0 : size.width, size.height, ); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }