Files
stream-chat-flutter/packages/stream_chat_flutter/lib/src/message_widget.dart
T
Salvatore GiordanoandGitHub 4561e6077b Merge pull request #690 from GetStream/cds/430
refactor(ui): Use `titleLink` instead of `ogScrapeUrl` for handling link attachments.
2021-09-16 15:49:21 +02:00

1445 lines
54 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/theme/themes.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/packages/stream_chat_flutter/screenshots/message_widget.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/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.showPinButton = true,
this.showPinHighlight = true,
this.onUserAvatarTap,
this.onLinkTap,
this.onMessageActions,
this.onShowMessage,
this.userAvatarBuilder,
this.editMessageInputBuilder,
this.textBuilder,
this.bottomRowBuilder,
this.deletedBottomRowBuilder,
this.onReturnAction,
this.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,
this.usernameBuilder,
}) : 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,
onAttachmentTap: onAttachmentTap,
),
),
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,
onAttachmentTap: onAttachmentTap != null
? () {
onAttachmentTap(message, attachment);
}
: null,
);
}).toList(),
),
border,
reverse,
);
},
'file': (context, message, attachments) {
final border = RoundedRectangleBorder(
side: attachmentBorderSide ??
BorderSide(
color: StreamChatTheme.of(context).colorTheme.borders,
),
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,
),
onAttachmentTap: onAttachmentTap != null
? () {
onAttachmentTap(message, attachment);
}
: null,
),
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;
/// Widget builder for building username
final Widget Function(BuildContext, Message)? usernameBuilder;
/// Function called on long press
final void Function(BuildContext, Message)? onMessageActions;
/// Widget builder for building a bottom row below the message
final Widget Function(BuildContext, Message)? bottomRowBuilder;
/// Widget builder for building a bottom row below a deleted message
final Widget Function(BuildContext, Message)? deletedBottomRowBuilder;
/// Widget builder for building user avatar
final Widget Function(BuildContext, User)? userAvatarBuilder;
/// The message
final Message message;
/// The message theme
final MessageThemeData 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;
/// Show flag action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Builder for respective attachment types
final Map<String, AttachmentBuilder> attachmentBuilders;
/// Builder for respective attachment types (user facing builder)
final Map<String, AttachmentBuilder>? customAttachmentBuilders;
/// 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;
/// Creates a copy of [MessageWidget] with specified attributes overridden.
MessageWidget copyWith({
Key? key,
void Function(User)? onMentionTap,
void Function(Message)? onThreadTap,
void Function(Message)? onReplyTap,
Widget Function(BuildContext, Message)? editMessageInputBuilder,
Widget Function(BuildContext, Message)? textBuilder,
Widget Function(BuildContext, Message)? usernameBuilder,
Widget Function(BuildContext, Message)? bottomRowBuilder,
Widget Function(BuildContext, Message)? deletedBottomRowBuilder,
void Function(BuildContext, Message)? onMessageActions,
Message? message,
MessageThemeData? messageTheme,
bool? reverse,
ShapeBorder? shape,
ShapeBorder? attachmentShape,
BorderSide? borderSide,
BorderSide? attachmentBorderSide,
BorderRadiusGeometry? borderRadiusGeometry,
BorderRadiusGeometry? attachmentBorderRadiusGeometry,
EdgeInsetsGeometry? padding,
EdgeInsets? textPadding,
EdgeInsetsGeometry? attachmentPadding,
DisplayWidget? showUserAvatar,
bool? showSendingIndicator,
bool? showReactions,
bool? allRead,
bool? showThreadReplyIndicator,
bool? showInChannelIndicator,
void Function(User)? onUserAvatarTap,
void Function(String)? onLinkTap,
bool? showReactionPickerIndicator,
List<Read>? readList,
ShowMessageCallback? onShowMessage,
ValueChanged<ReturnActionType>? onReturnAction,
bool? showUsername,
bool? showTimestamp,
bool? showReplyMessage,
bool? showThreadReplyMessage,
bool? showEditMessage,
bool? showCopyMessage,
bool? showDeleteMessage,
bool? showResendMessage,
bool? showFlagButton,
bool? showPinButton,
bool? showPinHighlight,
Map<String, AttachmentBuilder>? customAttachmentBuilders,
bool? translateUserAvatar,
OnQuotedMessageTap? onQuotedMessageTap,
void Function(Message)? onMessageTap,
List<MessageAction>? customActions,
void Function(Message message, Attachment attachment)? onAttachmentTap,
Widget Function(BuildContext, User)? userAvatarBuilder,
}) =>
MessageWidget(
key: key ?? this.key,
onMentionTap: onMentionTap ?? this.onMentionTap,
onThreadTap: onThreadTap ?? this.onThreadTap,
onReplyTap: onReplyTap ?? this.onReplyTap,
editMessageInputBuilder:
editMessageInputBuilder ?? this.editMessageInputBuilder,
textBuilder: textBuilder ?? this.textBuilder,
usernameBuilder: usernameBuilder ?? this.usernameBuilder,
bottomRowBuilder: bottomRowBuilder ?? this.bottomRowBuilder,
deletedBottomRowBuilder:
deletedBottomRowBuilder ?? this.deletedBottomRowBuilder,
onMessageActions: onMessageActions ?? this.onMessageActions,
message: message ?? this.message,
messageTheme: messageTheme ?? this.messageTheme,
reverse: reverse ?? this.reverse,
shape: shape ?? this.shape,
attachmentShape: attachmentShape ?? this.attachmentShape,
borderSide: borderSide ?? this.borderSide,
attachmentBorderSide: attachmentBorderSide ?? this.attachmentBorderSide,
borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry,
attachmentBorderRadiusGeometry: attachmentBorderRadiusGeometry ??
this.attachmentBorderRadiusGeometry,
padding: padding ?? this.padding,
textPadding: textPadding ?? this.textPadding,
attachmentPadding: attachmentPadding ?? this.attachmentPadding,
showUserAvatar: showUserAvatar ?? this.showUserAvatar,
showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator,
showReactions: showReactions ?? this.showReactions,
allRead: allRead ?? this.allRead,
showThreadReplyIndicator:
showThreadReplyIndicator ?? this.showThreadReplyIndicator,
showInChannelIndicator:
showInChannelIndicator ?? this.showInChannelIndicator,
onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap,
onLinkTap: onLinkTap ?? this.onLinkTap,
showReactionPickerIndicator:
showReactionPickerIndicator ?? this.showReactionPickerIndicator,
readList: readList ?? this.readList,
onShowMessage: onShowMessage ?? this.onShowMessage,
onReturnAction: onReturnAction ?? this.onReturnAction,
showUsername: showUsername ?? this.showUsername,
showTimestamp: showTimestamp ?? this.showTimestamp,
showReplyMessage: showReplyMessage ?? this.showReplyMessage,
showThreadReplyMessage:
showThreadReplyMessage ?? this.showThreadReplyMessage,
showEditMessage: showEditMessage ?? this.showEditMessage,
showCopyMessage: showCopyMessage ?? this.showCopyMessage,
showDeleteMessage: showDeleteMessage ?? this.showDeleteMessage,
showResendMessage: showResendMessage ?? this.showResendMessage,
showFlagButton: showFlagButton ?? this.showFlagButton,
showPinButton: showPinButton ?? this.showPinButton,
showPinHighlight: showPinHighlight ?? this.showPinHighlight,
customAttachmentBuilders:
customAttachmentBuilders ?? this.customAttachmentBuilders,
translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar,
onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap,
onMessageTap: onMessageTap ?? this.onMessageTap,
customActions: customActions ?? this.customActions,
onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap,
userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder,
);
@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 isOnlyEmoji => widget.message.text?.isOnlyEmoji == true;
bool get hasNonUrlAttachments =>
widget.message.attachments.where((it) => it.titleLink == null).isNotEmpty;
bool get hasUrlAttachments =>
widget.message.attachments.any((it) => it.titleLink != null) == true;
bool get showBottomRow =>
showThreadReplyIndicator ||
showUsername ||
showTimeStamp ||
showInChannel ||
showSendingIndicator ||
isDeleted;
@override
bool get wantKeepAlive => widget.message.attachments.isNotEmpty == true;
late StreamChatThemeData _streamChatTheme;
late StreamChatState _streamChat;
@override
Widget build(BuildContext context) {
super.build(context);
final avatarWidth =
widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40;
final bottomRowPadding =
widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5;
return Material(
type: widget.message.pinned && widget.showPinHighlight
? MaterialType.card
: MaterialType.transparency,
color: widget.message.pinned && widget.showPinHighlight
? _streamChatTheme.colorTheme.highlight
: null,
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: [
Padding(
padding: EdgeInsets.only(
bottom:
isPinned && widget.showPinHighlight ? 8.0 : 0.0,
),
child: Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message.pinned &&
widget.message.pinnedBy != null &&
widget.showPinHighlight)
_buildPinnedMessage(widget.message),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (!widget.reverse &&
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.hardEdge,
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
.colorTheme.barsBg,
Colors.transparent,
Colors.transparent,
tailCirclesSpace: 1,
),
),
),
],
),
),
),
if (widget.reverse &&
widget.showUserAvatar ==
DisplayWidget.show &&
widget.message.user != null) ...[
_buildUserAvatar(),
const SizedBox(width: 4),
]
],
),
if (showBottomRow)
SizedBox(height: context.textScaleFactor * 18.0),
],
),
),
if (showBottomRow)
Padding(
padding: EdgeInsets.only(
left: !widget.reverse ? bottomRowPadding : 0,
right: widget.reverse ? bottomRowPadding : 0,
bottom:
isPinned && widget.showPinHighlight ? 6.0 : 0.0,
),
child: widget.bottomRowBuilder?.call(
context,
widget.message,
) ??
_bottomRow,
),
if (isFailedState)
Positioned(
left: widget.reverse ? 0 : null,
right: widget.reverse ? null : 0,
bottom: showBottomRow ? 18 : -2,
child: StreamSvgIcon.error(size: 20),
),
],
),
],
),
),
),
),
),
);
}
@override
void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context);
_streamChat = StreamChat.of(context);
super.didChangeDependencies();
}
Widget _buildQuotedMessage() {
final isMyMessage = widget.message.user?.id == _streamChat.currentUser?.id;
final onTap = widget.message.quotedMessage?.isDeleted != true &&
widget.onQuotedMessageTap != null
? () => widget.onQuotedMessageTap!(widget.message.quotedMessageId)
: null;
final chatThemeData = _streamChatTheme;
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) {
return widget.deletedBottomRowBuilder?.call(
context,
widget.message,
) ??
const Offstage();
}
final children = <Widget>[];
final threadParticipants = widget.message.threadParticipants?.take(2);
final showThreadParticipants = threadParticipants?.isNotEmpty == true;
final replyCount = widget.message.replyCount;
var msg = context.translations.threadReplyLabel;
if (showThreadReplyIndicator && replyCount! > 1) {
msg = context.translations.threadReplyCountText(replyCount);
}
// 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.repliesStyle),
),
],
if (showUsername) _buildUsername(usernameKey),
if (showTimeStamp)
Text(
Jiffy(widget.message.createdAt.toLocal()).jm,
style: widget.messageTheme.createdAtStyle,
),
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.repliesStyle?.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.repliesStyle?.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 _buildUsername(Key usernameKey) {
if (widget.usernameBuilder != null) {
return widget.usernameBuilder!(context, widget.message);
}
return Text(
widget.message.user!.name,
maxLines: 1,
key: usernameKey,
style: widget.messageTheme.messageAuthorStyle,
overflow: TextOverflow.ellipsis,
);
}
Widget _buildUrlAttachment() {
final urlAttachment = widget.message.attachments
.firstWhere((element) => element.titleLink != null);
final host = Uri.parse(urlAttachment.titleLink!).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) =>
_ThreadParticipants(
streamChatTheme: _streamChatTheme,
threadParticipants: threadParticipants,
);
Widget _buildReactionIndicator(
BuildContext context,
) {
final ownId = _streamChat.currentUser!.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(
useRootNavigator: false,
context: context,
barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel(
channel: channel,
child: MessageActionsModal(
messageWidget: widget.copyWith(
key: const Key('MessageWidget'),
message: widget.message.copyWith(
text: (widget.message.text?.length ?? 0) > 200
? '${widget.message.text!.substring(0, 200)}...'
: widget.message.text,
),
showReactions: false,
showUsername: false,
showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: false,
padding: const EdgeInsets.all(0),
showReactionPickerIndicator: widget.showReactions &&
(widget.message.status == MessageSendingStatus.sent),
showPinHighlight: false,
showUserAvatar: widget.message.user!.id ==
channel.client.state.currentUser!.id
? DisplayWidget.gone
: DisplayWidget.show,
),
onCopyTap: (message) =>
Clipboard.setData(ClipboardData(text: message.text)),
messageTheme: widget.messageTheme,
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,
showPinButton: widget.showPinButton,
customActions: widget.customActions,
),
));
}
void _showMessageReactionsModalBottomSheet(BuildContext context) {
final channel = StreamChannel.of(context).channel;
showDialog(
useRootNavigator: false,
context: context,
barrierColor: _streamChatTheme.colorTheme.overlay,
builder: (context) => StreamChannel(
channel: channel,
child: MessageReactionsModal(
messageWidget: widget.copyWith(
key: const Key('MessageWidget'),
message: widget.message.copyWith(
text: (widget.message.text?.length ?? 0) > 200
? '${widget.message.text!.substring(0, 200)}...'
: widget.message.text,
),
showReactions: false,
showUsername: false,
showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: false,
padding: const EdgeInsets.all(0),
showReactionPickerIndicator: widget.showReactions &&
(widget.message.status == MessageSendingStatus.sent),
showPinHighlight: false,
showUserAvatar:
widget.message.user!.id == channel.client.state.currentUser!.id
? DisplayWidget.gone
: DisplayWidget.show,
),
onUserAvatarTap: widget.onUserAvatarTap,
messageTheme: widget.messageTheme,
reverse: widget.reverse,
message: widget.message,
showReactions: widget.showReactions,
),
),
);
}
Widget _parseAttachments() {
final attachmentGroups = <String, List<Attachment>>{};
widget.message.attachments
.where((element) => element.titleLink == 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.createdAtStyle;
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(
context.translations.attachmentsUploadProgressText(
remaining: uploadRemaining,
total: 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.colorTheme.accentPrimary,
),
),
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: widget.userAvatarBuilder?.call(context, widget.message.user!) ??
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 ?? false) 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(
messageTextStyle:
widget.messageTheme.messageTextStyle!.copyWith(
fontSize: 42,
))
: widget.messageTheme,
),
),
if (hasUrlAttachments && !hasQuotedMessage) _buildUrlAttachment(),
],
);
}
Widget _buildPinnedMessage(Message message) {
final pinnedBy = message.pinnedBy!;
final currentUser = _streamChat.currentUser!;
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamSvgIcon.pin(
size: 16,
),
const SizedBox(
width: 4,
),
Text(
context.translations.pinnedByUserText(
pinnedBy: pinnedBy,
currentUser: currentUser,
),
style: TextStyle(
color: _streamChatTheme.colorTheme.textLowEmphasis,
fontSize: 13,
fontWeight: FontWeight.w400,
),
)
],
),
);
}
bool get isPinned => widget.message.pinned;
Color? _getBackgroundColor() {
if (hasQuotedMessage) {
return widget.messageTheme.messageBackgroundColor;
}
if (hasUrlAttachments) {
return _streamChatTheme.colorTheme.linkBg;
}
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 _ThreadParticipants extends StatelessWidget {
const _ThreadParticipants({
Key? key,
required StreamChatThemeData streamChatTheme,
required this.threadParticipants,
}) : _streamChatTheme = streamChatTheme,
super(key: key);
final StreamChatThemeData _streamChatTheme;
final Iterable<User> threadParticipants;
@override
Widget build(BuildContext context) {
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.colorTheme.barsBg,
),
padding: const EdgeInsets.all(1),
child: UserAvatar(
user: user,
constraints: BoxConstraints.loose(const Size.fromRadius(7)),
showOnlineStatus: false,
),
),
);
}).toList(),
);
}
}
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.disabled
..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;
}