import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:matrix/matrix.dart'; import 'package:swipe_to_action/swipe_to_action.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; import '../../../config/app_config.dart'; import 'message_content.dart'; import 'message_reactions.dart'; import 'reply_content.dart'; import 'state_message.dart'; class Message extends StatelessWidget { final Event event; final Event? nextEvent; final Event? previousEvent; final bool displayReadMarker; final void Function(Event) onSelect; final void Function(Event) onInfoTab; final void Function(String) scrollToEventId; final void Function() onSwipe; final void Function() onMention; final void Function() onEdit; final bool longPressSelect; final bool selected; final bool singleSelected; final Timeline timeline; final bool highlightMarker; final bool animateIn; final void Function()? resetAnimateIn; final bool wallpaperMode; final ScrollController scrollController; final List colors; final void Function()? onExpand; final bool isCollapsed; const Message( this.event, { this.nextEvent, this.previousEvent, this.displayReadMarker = false, this.longPressSelect = false, required this.onSelect, required this.onInfoTab, required this.scrollToEventId, required this.onSwipe, this.selected = false, required this.onEdit, required this.singleSelected, required this.timeline, this.highlightMarker = false, this.animateIn = false, this.resetAnimateIn, this.wallpaperMode = false, required this.onMention, required this.scrollController, required this.colors, this.onExpand, this.isCollapsed = false, super.key, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); if (!{ EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted, EventTypes.CallInvite, }.contains(event.type)) { if (event.type.startsWith('m.call.')) { return const SizedBox.shrink(); } if (event.type == EventTypes.RoomCreate) { return RoomCreationStateEvent(event: event); } return StateMessage(event, onExpand: onExpand, isCollapsed: isCollapsed); } if (event.type == EventTypes.Message && event.messageType == EventTypes.KeyVerificationRequest) { return StateMessage(event); } final client = Matrix.of(context).client; final ownMessage = event.senderId == client.userID; final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft; var color = theme.colorScheme.surfaceContainerHigh; final displayTime = event.type == EventTypes.RoomCreate || nextEvent == null || !event.originServerTs.sameEnvironment(nextEvent!.originServerTs); final nextEventSameSender = nextEvent != null && { EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted, }.contains(nextEvent!.type) && nextEvent!.senderId == event.senderId && !displayTime; final previousEventSameSender = previousEvent != null && { EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted, }.contains(previousEvent!.type) && previousEvent!.senderId == event.senderId && previousEvent!.originServerTs.sameEnvironment(event.originServerTs); final textColor = ownMessage ? theme.onBubbleColor : theme.colorScheme.onSurface; final linkColor = ownMessage ? theme.brightness == Brightness.light ? theme.colorScheme.primaryFixed : theme.colorScheme.onTertiaryContainer : theme.colorScheme.primary; final rowMainAxisAlignment = ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start; final displayEvent = event.getDisplayEvent(timeline); const hardCorner = Radius.circular(4); const roundedCorner = Radius.circular(AppConfig.borderRadius); final borderRadius = BorderRadius.only( topLeft: !ownMessage && nextEventSameSender ? hardCorner : roundedCorner, topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner, bottomLeft: !ownMessage && previousEventSameSender ? hardCorner : roundedCorner, bottomRight: ownMessage && previousEventSameSender ? hardCorner : roundedCorner, ); final noBubble = ({ MessageTypes.Video, MessageTypes.Image, MessageTypes.Sticker, }.contains(event.messageType) && event.fileDescription == null && !event.redacted) || (event.messageType == MessageTypes.Text && event.relationshipType == null && event.onlyEmotes && event.numberEmotes > 0 && event.numberEmotes <= 3); if (ownMessage) { color = displayEvent.status.isError ? Colors.redAccent : theme.bubbleColor; } final resetAnimateIn = this.resetAnimateIn; var animateIn = this.animateIn; final sentReactions = {}; if (singleSelected) { sentReactions.addAll( event .aggregatedEvents( timeline, RelationshipTypes.reaction, ) .where( (event) => event.senderId == event.room.client.userID && event.type == 'm.reaction', ) .map( (event) => event.content .tryGetMap('m.relates_to') ?.tryGet('key'), ) .whereType(), ); } final showReceiptsRow = event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); final showReactionPicker = singleSelected && event.room.canSendDefaultMessages; return Center( child: Swipeable( key: ValueKey(event.eventId), background: const Padding( padding: EdgeInsets.symmetric(horizontal: 12.0), child: Center( child: Icon(Icons.check_outlined), ), ), direction: AppConfig.swipeRightToLeftToReply ? SwipeDirection.endToStart : SwipeDirection.startToEnd, onSwipe: (_) => onSwipe(), child: Container( constraints: const BoxConstraints( maxWidth: FluffyThemes.maxTimelineWidth, ), padding: EdgeInsets.only( left: 8.0, right: 8.0, top: nextEventSameSender ? 1.0 : 4.0, bottom: previousEventSameSender ? 1.0 : 4.0, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (displayTime || selected) Padding( padding: displayTime ? const EdgeInsets.symmetric(vertical: 8.0) : EdgeInsets.zero, child: Center( child: Padding( padding: const EdgeInsets.only(top: 4.0), child: Material( borderRadius: BorderRadius.circular(AppConfig.borderRadius * 2), color: theme.colorScheme.surface.withAlpha(128), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 2.0, ), child: Text( event.originServerTs.localizedTime(context), style: TextStyle( fontSize: 12 * AppConfig.fontSizeFactor, fontWeight: FontWeight.bold, color: theme.colorScheme.secondary, ), ), ), ), ), ), ), StatefulBuilder( builder: (context, setState) { if (animateIn && resetAnimateIn != null) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { animateIn = false; setState(resetAnimateIn); }); } return AnimatedSize( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, clipBehavior: Clip.none, alignment: ownMessage ? Alignment.bottomRight : Alignment.bottomLeft, child: animateIn ? const SizedBox(height: 0, width: double.infinity) : Stack( clipBehavior: Clip.none, children: [ Positioned( top: 0, bottom: 0, left: 0, right: 0, child: InkWell( hoverColor: longPressSelect ? Colors.transparent : null, enableFeedback: !selected, onTap: longPressSelect ? null : () => onSelect(event), borderRadius: BorderRadius.circular( AppConfig.borderRadius / 2, ), child: Material( borderRadius: BorderRadius.circular( AppConfig.borderRadius / 2, ), color: selected || highlightMarker ? theme.colorScheme.secondaryContainer .withAlpha(128) : Colors.transparent, ), ), ), Row( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: rowMainAxisAlignment, children: [ if (longPressSelect && !event.redacted) SizedBox( height: 32, width: Avatar.defaultSize, child: IconButton( padding: EdgeInsets.zero, tooltip: L10n.of(context).select, icon: Icon( selected ? Icons.check_circle : Icons.circle_outlined, ), onPressed: () => onSelect(event), ), ) else if (nextEventSameSender || ownMessage) SizedBox( width: Avatar.defaultSize, child: Center( child: SizedBox( width: 16, height: 16, child: event.status == EventStatus.error ? const Icon( Icons.error, color: Colors.red, ) : event.fileSendingStatus != null ? const CircularProgressIndicator .adaptive( strokeWidth: 1, ) : null, ), ), ) else FutureBuilder( future: event.fetchSenderUser(), builder: (context, snapshot) { final user = snapshot.data ?? event.senderFromMemoryOrFallback; return Avatar( mxContent: user.avatarUrl, name: user.calcDisplayname(), onTap: () => showMemberActionsPopupMenu( context: context, user: user, onMention: onMention, ), presenceUserId: user.stateKey, presenceBackgroundColor: wallpaperMode ? Colors.transparent : null, ); }, ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (!nextEventSameSender) Padding( padding: const EdgeInsets.only( left: 8.0, bottom: 4, ), child: ownMessage || event.room.isDirectChat ? const SizedBox(height: 12) : FutureBuilder( future: event.fetchSenderUser(), builder: (context, snapshot) { final displayname = snapshot .data ?.calcDisplayname() ?? event .senderFromMemoryOrFallback .calcDisplayname(); return Text( displayname, style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: (theme.brightness == Brightness .light ? displayname .color : displayname .lightColorText), shadows: !wallpaperMode ? null : [ const Shadow( offset: Offset( 0.0, 0.0, ), blurRadius: 3, color: Colors .black, ), ], ), maxLines: 1, overflow: TextOverflow .ellipsis, ); }, ), ), Container( alignment: alignment, padding: const EdgeInsets.only(left: 8), child: GestureDetector( onLongPress: longPressSelect ? null : () { HapticFeedback .heavyImpact(); onSelect(event); }, child: AnimatedOpacity( opacity: animateIn ? 0 : event.messageType == MessageTypes .BadEncrypted || event.status.isSending ? 0.5 : 1, duration: FluffyThemes .animationDuration, curve: FluffyThemes.animationCurve, child: Container( decoration: BoxDecoration( color: noBubble ? Colors.transparent : color, borderRadius: borderRadius, ), clipBehavior: Clip.antiAlias, child: BubbleBackground( colors: colors, ignore: noBubble || !ownMessage || MediaQuery.of(context) .highContrast, scrollController: scrollController, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular( AppConfig.borderRadius, ), ), constraints: const BoxConstraints( maxWidth: FluffyThemes .columnWidth * 1.5, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment .start, children: [ if ({ RelationshipTypes .reply, RelationshipTypes .thread, }.contains( event .relationshipType, )) FutureBuilder( future: event .getReplyEvent( timeline, ), builder: ( BuildContext context, snapshot, ) { final replyEvent = snapshot .hasData ? snapshot .data! : Event( eventId: event.relationshipEventId!, content: { 'msgtype': 'm.text', 'body': '...', }, senderId: event.senderId, type: 'm.room.message', room: event.room, status: EventStatus.sent, originServerTs: DateTime.now(), ); return Padding( padding: const EdgeInsets .only( left: 16, right: 16, top: 8, ), child: Material( color: Colors .transparent, borderRadius: ReplyContent .borderRadius, child: InkWell( borderRadius: ReplyContent .borderRadius, onTap: () => scrollToEventId( replyEvent .eventId, ), child: AbsorbPointer( child: ReplyContent( replyEvent, ownMessage: ownMessage, timeline: timeline, ), ), ), ), ); }, ), MessageContent( displayEvent, textColor: textColor, linkColor: linkColor, onInfoTab: onInfoTab, borderRadius: borderRadius, timeline: timeline, selected: selected, ), if (event .hasAggregatedEvents( timeline, RelationshipTypes .edit, )) Padding( padding: const EdgeInsets .only( bottom: 8.0, left: 16.0, right: 16.0, ), child: Row( mainAxisSize: MainAxisSize .min, spacing: 4.0, children: [ Icon( Icons .edit_outlined, color: textColor .withAlpha( 164, ), size: 14, ), Text( displayEvent .originServerTs .localizedTimeShort( context, ), style: TextStyle( color: textColor .withAlpha( 164, ), fontSize: 11, ), ), ], ), ), ], ), ), ), ), ), ), ), Align( alignment: ownMessage ? Alignment.bottomRight : Alignment.bottomLeft, child: AnimatedSize( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, child: showReactionPicker ? Padding( padding: const EdgeInsets.all( 4.0, ), child: Material( elevation: 4, borderRadius: BorderRadius.circular( AppConfig.borderRadius, ), shadowColor: theme .colorScheme.surface .withAlpha(128), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ ...AppConfig .defaultReactions .map( (emoji) => IconButton( padding: EdgeInsets .zero, icon: Center( child: Opacity( opacity: sentReactions .contains( emoji, ) ? 0.33 : 1, child: Text( emoji, style: const TextStyle( fontSize: 20, ), textAlign: TextAlign .center, ), ), ), onPressed: sentReactions .contains( emoji, ) ? null : () { onSelect( event, ); event.room.sendReaction( event.eventId, emoji, ); }, ), ), IconButton( icon: const Icon( Icons .add_reaction_outlined, ), tooltip: L10n.of( context, ).customReaction, onPressed: () async { final emoji = await showAdaptiveBottomSheet< String>( context: context, builder: (context) => Scaffold( appBar: AppBar( title: Text( L10n.of(context) .customReaction, ), leading: CloseButton( onPressed: () => Navigator.of( context, ).pop( null, ), ), ), body: SizedBox( height: double .infinity, child: EmojiPicker( onEmojiSelected: ( _, emoji, ) => Navigator.of( context, ).pop( emoji .emoji, ), config: Config( emojiViewConfig: const EmojiViewConfig( backgroundColor: Colors.transparent, ), bottomActionBarConfig: const BottomActionBarConfig( enabled: false, ), categoryViewConfig: CategoryViewConfig( initCategory: Category.SMILEYS, backspaceColor: theme.colorScheme.primary, iconColor: theme.colorScheme.primary.withAlpha( 128, ), iconColorSelected: theme.colorScheme.primary, indicatorColor: theme.colorScheme.primary, backgroundColor: theme.colorScheme.surface, ), skinToneConfig: SkinToneConfig( dialogBackgroundColor: Color.lerp( theme.colorScheme.surface, theme.colorScheme.primaryContainer, 0.75, )!, indicatorColor: theme.colorScheme.onSurface, ), ), ), ), ), ); if (emoji == null) { return; } if (sentReactions .contains( emoji, )) { return; } onSelect(event); await event.room .sendReaction( event.eventId, emoji, ); }, ), ], ), ), ), ) : const SizedBox.shrink(), ), ), ], ), ), ], ), ], ), ); }, ), AnimatedSize( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, alignment: Alignment.bottomCenter, child: !showReceiptsRow ? const SizedBox.shrink() : Padding( padding: EdgeInsets.only( top: 4.0, left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0, right: ownMessage ? 0 : 12.0, ), child: MessageReactions(event, timeline), ), ), if (displayReadMarker) Row( children: [ Expanded( child: Divider( color: theme.colorScheme.surfaceContainerHighest, ), ), Container( margin: const EdgeInsets.symmetric( horizontal: 4, vertical: 16.0, ), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3), color: theme.colorScheme.surface.withAlpha(128), ), child: Text( L10n.of(context).readUpToHere, style: TextStyle( fontSize: 12 * AppConfig.fontSizeFactor, ), ), ), Expanded( child: Divider( color: theme.colorScheme.surfaceContainerHighest, ), ), ], ), ], ), ), ), ); } } class BubbleBackground extends StatelessWidget { const BubbleBackground({ super.key, required this.scrollController, required this.colors, required this.ignore, required this.child, }); final ScrollController scrollController; final List colors; final bool ignore; final Widget child; @override Widget build(BuildContext context) { if (ignore) return child; return CustomPaint( painter: BubblePainter( repaint: scrollController, colors: colors, context: context, ), child: child, ); } } class BubblePainter extends CustomPainter { BubblePainter({ required this.context, required this.colors, required super.repaint, }); final BuildContext context; final List colors; ScrollableState? _scrollable; @override void paint(Canvas canvas, Size size) { final scrollable = _scrollable ??= Scrollable.of(context); final scrollableBox = scrollable.context.findRenderObject() as RenderBox; final scrollableRect = Offset.zero & scrollableBox.size; final bubbleBox = context.findRenderObject() as RenderBox; final origin = bubbleBox.localToGlobal(Offset.zero, ancestor: scrollableBox); final paint = Paint() ..shader = ui.Gradient.linear( scrollableRect.topCenter, scrollableRect.bottomCenter, colors, [0.0, 1.0], TileMode.clamp, Matrix4.translationValues(-origin.dx, -origin.dy, 0.0).storage, ); canvas.drawRect(Offset.zero & size, paint); } @override bool shouldRepaint(BubblePainter oldDelegate) { final scrollable = Scrollable.of(context); final oldScrollable = _scrollable; _scrollable = scrollable; return scrollable.position != oldScrollable?.position; } }