391 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			391 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Dart
		
	
	
	
| import 'dart:math';
 | ||
| 
 | ||
| import 'package:extera_next/pages/chat/events/poll_content.dart';
 | ||
| import 'package:extera_next/utils/poll_events.dart';
 | ||
| import 'package:flutter/material.dart';
 | ||
| 
 | ||
| import 'package:extera_next/generated/l10n/l10n.dart';
 | ||
| import 'package:flutter_linkify/flutter_linkify.dart';
 | ||
| import 'package:matrix/matrix.dart';
 | ||
| 
 | ||
| import 'package:extera_next/pages/chat/events/video_player.dart';
 | ||
| import 'package:extera_next/utils/adaptive_bottom_sheet.dart';
 | ||
| import 'package:extera_next/utils/date_time_extension.dart';
 | ||
| import 'package:extera_next/utils/matrix_sdk_extensions/matrix_locals.dart';
 | ||
| import 'package:extera_next/widgets/avatar.dart';
 | ||
| import 'package:extera_next/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';
 | ||
| 
 | ||
| class MessageContent extends StatelessWidget {
 | ||
|   final Event event;
 | ||
|   final Color textColor;
 | ||
|   final Color linkColor;
 | ||
|   final void Function(Event)? onInfoTab;
 | ||
|   final BorderRadius borderRadius;
 | ||
|   final Timeline timeline;
 | ||
| 
 | ||
|   const MessageContent(
 | ||
|     this.event, {
 | ||
|     this.onInfoTab,
 | ||
|     super.key,
 | ||
|     required this.timeline,
 | ||
|     required this.textColor,
 | ||
|     required this.linkColor,
 | ||
|     required this.borderRadius,
 | ||
|   });
 | ||
| 
 | ||
|   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.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(),
 | ||
|                   presenceUserId: sender.stateKey,
 | ||
|                   client: event.room.client,
 | ||
|                 ),
 | ||
|                 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 PollEvents.PollStart:
 | ||
|         Logs().v("Got poll event ${event.type}");
 | ||
|         return PollWidget(event, color: textColor, linkColor: linkColor, fontSize: fontSize);
 | ||
|       case EventTypes.Message:
 | ||
|       case EventTypes.Encrypted:
 | ||
|       case EventTypes.Sticker:
 | ||
|         switch (event.messageType) {
 | ||
|           case MessageTypes.Image:
 | ||
|           case MessageTypes.Sticker:
 | ||
|             if (event.redacted) continue textmessage;
 | ||
|             final maxSize = event.messageType == MessageTypes.Image ? 512.0 : 256.0;
 | ||
|             final w = event.content
 | ||
|                 .tryGetMap<String, Object?>('info')
 | ||
|                 ?.tryGet<int>('w');
 | ||
|             final h = event.content
 | ||
|                 .tryGetMap<String, Object?>('info')
 | ||
|                 ?.tryGet<int>('h');
 | ||
|             var width = maxSize;
 | ||
|             var height = maxSize;
 | ||
|             var fit = event.messageType == MessageTypes.Sticker
 | ||
|                 ? BoxFit.contain
 | ||
|                 : BoxFit.cover;
 | ||
|             if (w != null && h != null) {
 | ||
|               fit = BoxFit.contain;
 | ||
|               if (w > h) {
 | ||
|                 width = maxSize;
 | ||
|                 height = max(32, maxSize * (h / w));
 | ||
|               } else {
 | ||
|                 height = maxSize;
 | ||
|                 width = max(32, maxSize * (w / h));
 | ||
|               }
 | ||
|             }
 | ||
|             return ImageBubble(
 | ||
|               event,
 | ||
|               width: width,
 | ||
|               height: height,
 | ||
|               fit: fit,
 | ||
|               borderRadius: borderRadius,
 | ||
|               timeline: timeline,
 | ||
|               textColor: textColor,
 | ||
|             );
 | ||
|           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,
 | ||
|                 linkColor: linkColor,
 | ||
|                 fontSize: fontSize,
 | ||
|               );
 | ||
|             }
 | ||
|             return MessageDownloadContent(
 | ||
|               event,
 | ||
|               textColor: textColor,
 | ||
|               linkColor: linkColor,
 | ||
|             );
 | ||
|           case MessageTypes.Video:
 | ||
|             return EventVideoPlayer(event, textColor, linkColor, timeline: timeline,);
 | ||
|           case MessageTypes.File:
 | ||
|             return MessageDownloadContent(
 | ||
|               event,
 | ||
|               textColor: textColor,
 | ||
|               linkColor: linkColor,
 | ||
|             );
 | ||
| 
 | ||
|           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 Padding(
 | ||
|                 padding: const EdgeInsets.symmetric(
 | ||
|                   horizontal: 16,
 | ||
|                   vertical: 8,
 | ||
|                 ),
 | ||
|                 child: HtmlMessage(
 | ||
|                   html: html,
 | ||
|                   textColor: textColor,
 | ||
|                   room: event.room,
 | ||
|                   fontSize:
 | ||
|                       AppConfig.fontSizeFactor * AppConfig.messageFontSize,
 | ||
|                   linkStyle: TextStyle(
 | ||
|                     color: linkColor,
 | ||
|                     fontSize:
 | ||
|                         AppConfig.fontSizeFactor * AppConfig.messageFontSize,
 | ||
|                     decoration: TextDecoration.underline,
 | ||
|                     decorationColor: linkColor,
 | ||
|                   ),
 | ||
|                   onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
 | ||
|                 ),
 | ||
|               );
 | ||
|             }
 | ||
|             // 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<String>('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<User?>(
 | ||
|                 future: event.redactedBecause?.fetchSenderUser(),
 | ||
|                 builder: (context, snapshot) {
 | ||
|                   final reason =
 | ||
|                       event.redactedBecause?.content.tryGet<String>('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.withAlpha(128),
 | ||
|                     onPressed: () => onInfoTab!(event),
 | ||
|                     fontSize: fontSize,
 | ||
|                   );
 | ||
|                 },
 | ||
|               );
 | ||
|             }
 | ||
|             final bigEmotes = event.onlyEmotes &&
 | ||
|                 event.numberEmotes > 0 &&
 | ||
|                 event.numberEmotes <= 3;
 | ||
|             return Padding(
 | ||
|               padding: const EdgeInsets.symmetric(
 | ||
|                 horizontal: 16,
 | ||
|                 vertical: 8,
 | ||
|               ),
 | ||
|               child: Linkify(
 | ||
|                 text: event.calcLocalizedBodyFallback(
 | ||
|                   MatrixLocals(L10n.of(context)),
 | ||
|                   hideReply: true,
 | ||
|                 ),
 | ||
|                 textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
 | ||
|                 style: TextStyle(
 | ||
|                   color: textColor,
 | ||
|                   fontSize: bigEmotes ? fontSize * 5 : fontSize,
 | ||
|                   decoration:
 | ||
|                       event.redacted ? TextDecoration.lineThrough : null,
 | ||
|                 ),
 | ||
|                 options: const LinkifyOptions(humanize: false),
 | ||
|                 linkStyle: TextStyle(
 | ||
|                   color: linkColor,
 | ||
|                   fontSize: fontSize,
 | ||
|                   decoration: TextDecoration.underline,
 | ||
|                   decorationColor: linkColor,
 | ||
|                 ),
 | ||
|                 onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
 | ||
|               ),
 | ||
|             );
 | ||
|         }
 | ||
|       case EventTypes.CallInvite:
 | ||
|         return FutureBuilder<User?>(
 | ||
|           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<User?>(
 | ||
|           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 Padding(
 | ||
|       padding: const EdgeInsets.symmetric(
 | ||
|         horizontal: 16,
 | ||
|         vertical: 8,
 | ||
|       ),
 | ||
|       child: InkWell(
 | ||
|         onTap: onPressed,
 | ||
|         child: Text(
 | ||
|           '$icon  $label',
 | ||
|           style: TextStyle(
 | ||
|             color: textColor,
 | ||
|             fontSize: fontSize,
 | ||
|           ),
 | ||
|         ),
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| }
 |