Files
stream-chat-flutter/packages/stream_chat_flutter/lib/src/message_widget.dart
T
2021-05-18 15:34:37 +05:30

1191 lines
43 KiB
Dart

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<Attachment>,
);
/// 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<String, AttachmentBuilder>? 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<Widget>((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<Read>? readList;
/// Callback when show message is tapped
final ShowMessageCallback? onShowMessage;
/// Handle return actions like reply message
final ValueChanged<ReturnActionType>? 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<String, AttachmentBuilder> 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<MessageAction> customActions;
/// Customize onTap on attachment
final void Function(Message message, Attachment attachment)? onAttachmentTap;
@override
_MessageWidgetState createState() => _MessageWidgetState();
}
class _MessageWidgetState extends State<MessageWidget>
with AutomaticKeepAliveClientMixin<MessageWidget> {
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: <Widget>[
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: <Widget>[
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: <Widget>[
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 = <Widget>[];
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<User> 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 = <String, Reaction>{};
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 = <String, List<Attachment>>{};
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 = <Widget>[];
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;
}