import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../../config/app_config.dart'; import '../../../utils/platform_infos.dart'; import '../../../utils/url_launcher.dart'; import '../../bootstrap/bootstrap_dialog.dart'; import 'audio_player.dart'; import 'cute_events.dart'; import 'html_message.dart'; import 'image_bubble.dart'; import 'map_bubble.dart'; import 'message_download_content.dart'; import 'sticker.dart'; class MessageContent extends StatelessWidget { final Event event; final Color textColor; final void Function(Event)? onInfoTab; const MessageContent( this.event, { this.onInfoTab, super.key, required this.textColor, }); void _verifyOrRequestKey(BuildContext context) async { final l10n = L10n.of(context)!; if (event.content['can_request_session'] != true) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( event.type == EventTypes.Encrypted ? l10n.needPantalaimonWarning : event.calcLocalizedBodyFallback( MatrixLocals(l10n), ), ), ), ); return; } final client = Matrix.of(context).client; if (client.isUnknownSession && client.encryption!.crossSigning.enabled) { final success = await BootstrapDialog( client: Matrix.of(context).client, ).show(context); if (success != true) return; } event.requestKey(); final sender = event.senderFromMemoryOrFallback; await showAdaptiveBottomSheet( context: context, builder: (context) => Scaffold( appBar: AppBar( leading: CloseButton(onPressed: Navigator.of(context).pop), title: Text( l10n.whyIsThisMessageEncrypted, style: const TextStyle(fontSize: 16), ), ), body: SafeArea( child: ListView( padding: const EdgeInsets.all(16), children: [ ListTile( contentPadding: EdgeInsets.zero, leading: Avatar( mxContent: sender.avatarUrl, name: sender.calcDisplayname(), ), title: Text(sender.calcDisplayname()), subtitle: Text(event.originServerTs.localizedTime(context)), trailing: const Icon(Icons.lock_outlined), ), const Divider(), Text( event.calcLocalizedBodyFallback( MatrixLocals(l10n), ), ), ], ), ), ), ); } @override Widget build(BuildContext context) { final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; final buttonTextColor = textColor; switch (event.type) { case EventTypes.Message: case EventTypes.Encrypted: case EventTypes.Sticker: switch (event.messageType) { case MessageTypes.Image: return ImageBubble( event, width: 400, height: 300, fit: BoxFit.cover, ); case MessageTypes.Sticker: if (event.redacted) continue textmessage; return Sticker(event); case CuteEventContent.eventType: return CuteContent(event); case MessageTypes.Audio: if (PlatformInfos.isMobile || PlatformInfos.isMacOS || PlatformInfos.isWeb // Disabled until https://github.com/bleonard252/just_audio_mpv/issues/3 // is fixed // || PlatformInfos.isLinux ) { return AudioPlayerWidget( event, color: textColor, ); } return MessageDownloadContent(event, textColor); case MessageTypes.Video: if (PlatformInfos.isMobile || PlatformInfos.isWeb) { return EventVideoPlayer(event); } return MessageDownloadContent(event, textColor); case MessageTypes.File: return MessageDownloadContent(event, textColor); case MessageTypes.Text: case MessageTypes.Notice: case MessageTypes.Emote: if (AppConfig.renderHtml && !event.redacted && event.isRichMessage) { var html = event.formattedText; if (event.messageType == MessageTypes.Emote) { html = '* $html'; } return HtmlMessage( html: html, textColor: textColor, room: event.room, ); } // else we fall through to the normal message rendering continue textmessage; case MessageTypes.BadEncrypted: case EventTypes.Encrypted: return _ButtonContent( textColor: buttonTextColor, onPressed: () => _verifyOrRequestKey(context), icon: '🔒', label: L10n.of(context)!.encrypted, fontSize: fontSize, ); case MessageTypes.Location: final geoUri = Uri.tryParse(event.content.tryGet('geo_uri')!); if (geoUri != null && geoUri.scheme == 'geo') { final latlong = geoUri.path .split(';') .first .split(',') .map((s) => double.tryParse(s)) .toList(); if (latlong.length == 2 && latlong.first != null && latlong.last != null) { return Column( mainAxisSize: MainAxisSize.min, children: [ MapBubble( latitude: latlong.first!, longitude: latlong.last!, ), const SizedBox(height: 6), OutlinedButton.icon( icon: Icon(Icons.location_on_outlined, color: textColor), onPressed: UrlLauncher(context, geoUri.toString()).launchUrl, label: Text( L10n.of(context)!.openInMaps, style: TextStyle(color: textColor), ), ), ], ); } } continue textmessage; case MessageTypes.None: textmessage: default: if (event.redacted) { return FutureBuilder( future: event.redactedBecause?.fetchSenderUser(), builder: (context, snapshot) { final reason = event.redactedBecause?.content.tryGet('reason'); final redactedBy = snapshot.data?.calcDisplayname() ?? event.redactedBecause?.senderId.localpart ?? L10n.of(context)!.user; return _ButtonContent( label: reason == null ? L10n.of(context)!.redactedBy(redactedBy) : L10n.of(context)!.redactedByBecause( redactedBy, reason, ), icon: '🗑ī¸', textColor: buttonTextColor, onPressed: () => onInfoTab!(event), fontSize: fontSize, ); }, ); } final bigEmotes = event.onlyEmotes && event.numberEmotes > 0 && event.numberEmotes <= 10; return FutureBuilder( future: event.calcLocalizedBody( MatrixLocals(L10n.of(context)!), hideReply: true, ), builder: (context, snapshot) { return Linkify( text: snapshot.data ?? event.calcLocalizedBodyFallback( MatrixLocals(L10n.of(context)!), hideReply: true, ), style: TextStyle( color: textColor, fontSize: bigEmotes ? fontSize * 3 : fontSize, decoration: event.redacted ? TextDecoration.lineThrough : null, ), options: const LinkifyOptions(humanize: false), linkStyle: TextStyle( color: textColor.withAlpha(150), fontSize: bigEmotes ? fontSize * 3 : fontSize, decoration: TextDecoration.underline, decorationColor: textColor.withAlpha(150), ), onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ); }, ); } case EventTypes.CallInvite: return FutureBuilder( future: event.fetchSenderUser(), builder: (context, snapshot) { return _ButtonContent( label: L10n.of(context)!.startedACall( snapshot.data?.calcDisplayname() ?? event.senderFromMemoryOrFallback.calcDisplayname(), ), icon: '📞', textColor: buttonTextColor, onPressed: () => onInfoTab!(event), fontSize: fontSize, ); }, ); default: return FutureBuilder( future: event.fetchSenderUser(), builder: (context, snapshot) { return _ButtonContent( label: L10n.of(context)!.userSentUnknownEvent( snapshot.data?.calcDisplayname() ?? event.senderFromMemoryOrFallback.calcDisplayname(), event.type, ), icon: 'ℹī¸', textColor: buttonTextColor, onPressed: () => onInfoTab!(event), fontSize: fontSize, ); }, ); } } } class _ButtonContent extends StatelessWidget { final void Function() onPressed; final String label; final String icon; final Color? textColor; final double fontSize; const _ButtonContent({ required this.label, required this.icon, required this.textColor, required this.onPressed, required this.fontSize, }); @override Widget build(BuildContext context) { return InkWell( onTap: onPressed, child: Text( '$icon $label', style: TextStyle( color: textColor, fontSize: fontSize, ), ), ); } }