// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:async/async.dart' show Result; import 'package:cross_file/cross_file.dart'; import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:html/parser.dart' show parseFragment; import 'package:markdown/markdown.dart' show ExtensionSet, markdownToHtml; import 'package:tatchat/config/app_config.dart'; import 'package:tatchat/config/setting_keys.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat/trust_user_key_dialog.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:tatchat/utils/other_party_can_receive.dart'; import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/size_string.dart'; import 'package:tatchat/utils/start_push_foreground_service.dart'; import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart' hide Result; import 'package:mime/mime.dart'; import '../../utils/resize_video.dart'; class SendFileDialog extends StatefulWidget { final Room room; final List files; final BuildContext outerContext; final String? threadLastEventId, threadRootEventId; const SendFileDialog({ required this.room, required this.files, required this.outerContext, required this.threadLastEventId, required this.threadRootEventId, super.key, }); @override SendFileDialogState createState() => SendFileDialogState(); } class SendFileDialogState extends State { bool compress = true; /// Images smaller than 20kb don't need compression. static const int minSizeToCompress = 20 * 1000; final TextEditingController _labelTextController = TextEditingController(); final FocusNode _labelFocusNode = FocusNode(); /// Mutable working copy of [SendFileDialog.files]. late final List _files = List.of(widget.files); void _editLabel() { _labelFocusNode.requestFocus(); } Future _send(String? uniqueFileType) async { final l10n = L10n.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); final proceed = await showTrustUserInRoomDialog(context, widget.room); if (!context.mounted || !proceed) return; if (ForegroundServices.platformSupported) { await ForegroundServices.startService('send_files'); } else { scaffoldMessenger.showLoadingSnackBar( l10n.sendingFilesDoNotExit(widget.files.length), ); } try { if (!widget.room.otherPartyCanReceiveMessages) { throw OtherPartyCanNotReceiveMessages(); } if (!mounted) return; Navigator.of(context, rootNavigator: false).pop(); final clientConfig = await Result.capture(widget.room.client.getConfig()); final maxUploadSize = clientConfig.asValue?.value.mUploadSize ?? 100 * 1000 * 1000; for (var i = 0; i < _files.length; i++) { final xfile = _files[i]; final MatrixFile file; MatrixImageFile? thumbnail; final mimeType = xfile.mimeType ?? lookupMimeType(xfile.path); // Generate video thumbnail if (PlatformInfos.isMobile && mimeType != null && mimeType.startsWith('video')) { thumbnail = await xfile.getVideoThumbnail(); } // If file is a video, shrink it! if (PlatformInfos.isMobile && mimeType != null && mimeType.startsWith('video')) { final lengthResult = await Result.capture(xfile.length()); final length = lengthResult.asValue?.value; scaffoldMessenger.clearSnackBars(); scaffoldMessenger.showLoadingSnackBar(l10n.compressingVideo); file = await xfile.getVideoInfo( compress: length != null && length > minSizeToCompress && compress, ); scaffoldMessenger.clearSnackBars(); if (!ForegroundServices.platformSupported) { scaffoldMessenger.showLoadingSnackBar( l10n.sendingFilesDoNotExit(widget.files.length), ); } } else { // Else we just create a MatrixFile file = MatrixFile( bytes: await _files[i].readAsBytes(), name: xfile.name, mimeType: mimeType, ).detectFileType; } if (file.bytes.length > maxUploadSize) { throw FileTooBigMatrixException(file.bytes.length, maxUploadSize); } final label = _labelTextController.text.trim(); final extraContent = label.isEmpty ? null : () { final extra = {'body': label}; final html = markdownToHtml( label, extensionSet: ExtensionSet.gitHubFlavored, ); if (parseFragment(html).text != label) { extra['format'] = 'org.matrix.custom.html'; extra['formatted_body'] = html; } return extra; }(); try { await widget.room.sendFileEvent( file, thumbnail: thumbnail, shrinkImageMaxDimension: compress ? 1600 : null, extraContent: extraContent, threadRootEventId: widget.threadRootEventId, threadLastEventId: widget.threadLastEventId, ); } on MatrixException catch (e) { final retryAfterMs = e.retryAfterMs; if (e.error != MatrixError.M_LIMIT_EXCEEDED || retryAfterMs == null) { rethrow; } final retryAfterDuration = Duration( milliseconds: retryAfterMs + 1000, ); await Future.delayed(retryAfterDuration); await widget.room.sendFileEvent( file, thumbnail: thumbnail, shrinkImageMaxDimension: compress ? 1600 : null, extraContent: extraContent, ); } } } finally { if (ForegroundServices.platformSupported) { await ForegroundServices.stopService('send_files'); } else { scaffoldMessenger.clearSnackBars(); } } return; } Future _calcCombinedFileSize() async { final lengths = await Future.wait(_files.map((file) => file.length())); return lengths.fold(0, (p, length) => p + length).sizeString; } @override void dispose() { _labelFocusNode.dispose(); _labelTextController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isMobile = PlatformInfos.isMobile; var sendStr = L10n.of(context).sendFile; final uniqueFileType = _files .map((file) => file.mimeType ?? lookupMimeType(file.name)) .map((mimeType) => mimeType?.split('/').first) .toSet() .singleOrNull; final fileName = _files.length == 1 ? _files.single.name : L10n.of(context).countFiles(_files.length); final fileTypes = _files .map((file) => file.name.split('.').last) .toSet() .join(', ') .toUpperCase(); if (uniqueFileType == 'image') { if (_files.length == 1) { sendStr = L10n.of(context).sendImage; } else { sendStr = L10n.of(context).sendImages(_files.length); } } else if (uniqueFileType == 'audio') { sendStr = L10n.of(context).sendAudio; } else if (uniqueFileType == 'video') { sendStr = L10n.of(context).sendVideo; } final compressionSupported = uniqueFileType != 'video' || PlatformInfos.isMobile; return FutureBuilder( future: _calcCombinedFileSize(), builder: (context, snapshot) { final sizeString = snapshot.data ?? L10n.of(context).calculatingFileSize; return AlertDialog.adaptive( title: Text(sendStr), insetPadding: isMobile ? const EdgeInsets.all(8.0) : const EdgeInsets.symmetric(horizontal: 40, vertical: 24), constraints: isMobile ? const BoxConstraints( maxWidth: double.infinity, maxHeight: double.infinity, ) : null, content: SizedBox( width: 512, height: isMobile ? MediaQuery.sizeOf(context).height * 0.85 : null, child: SingleChildScrollView( child: Column( mainAxisSize: .min, children: [ const SizedBox(height: 12), if (uniqueFileType == 'image') Padding( padding: const EdgeInsets.only(bottom: 16.0), child: SizedBox( height: 256, child: Center( child: ListView.builder( shrinkWrap: true, itemCount: _files.length, scrollDirection: Axis.horizontal, itemBuilder: (context, i) => Padding( padding: const EdgeInsets.only(right: 8.0), child: Stack( children: [ Material( borderRadius: BorderRadius.circular( AppConfig.borderRadius / 2, ), color: Colors.black, clipBehavior: Clip.hardEdge, child: FutureBuilder( future: _files[i].readAsBytes(), builder: (context, snapshot) { final bytes = snapshot.data; if (bytes == null) { return const Center( child: CircularProgressIndicator.adaptive(), ); } if (snapshot.error != null) { Logs().w( 'Unable to preview image', snapshot.error, snapshot.stackTrace, ); return const Center( child: SizedBox( width: 256, height: 256, child: Icon( Icons.broken_image_outlined, size: 64, ), ), ); } return Image.memory( bytes, height: 256, width: _files.length == 1 ? 256 - 36 : null, fit: BoxFit.contain, errorBuilder: (context, e, s) { Logs().w( 'Unable to preview image', e, s, ); return const Center( child: SizedBox( width: 256, height: 256, child: Icon( Icons.broken_image_outlined, size: 64, ), ), ); }, ); }, ), ), Positioned( top: 4, right: 4, child: Material( color: Colors.black54, shape: const CircleBorder(), child: IconButton( tooltip: L10n.of( context, ).optionalMessage, icon: const Icon( Icons.edit_outlined, color: Colors.white, size: 20, ), onPressed: _editLabel, ), ), ), ], ), ), ), ), ), ), if (uniqueFileType != 'image') Padding( padding: const EdgeInsets.only(bottom: 16.0), child: Row( children: [ Icon( uniqueFileType == null ? Icons.description_outlined : uniqueFileType == 'video' ? Icons.video_file_outlined : uniqueFileType == 'audio' ? Icons.audio_file_outlined : Icons.description_outlined, size: 32, ), const SizedBox(width: 8), Expanded( child: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Text( fileName, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( '$sizeString - $fileTypes', style: theme.textTheme.labelSmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), if (_files.length == 1) Padding( padding: const EdgeInsets.only(bottom: 8.0), child: DialogTextField( controller: _labelTextController, focusNode: _labelFocusNode, labelText: L10n.of(context).optionalMessage, keyboardType: TextInputType.multiline, minLines: isMobile ? 12 : 3, maxLines: isMobile ? null : 8, maxLength: AppSettings.textMessageMaxLength.value, counterText: '', ), ), // Workaround for SwitchListTile.adaptive crashes in CupertinoDialog if ({'image', 'video'}.contains(uniqueFileType)) Row( crossAxisAlignment: .center, children: [ if ({ TargetPlatform.iOS, TargetPlatform.macOS, }.contains(theme.platform)) CupertinoSwitch( value: compressionSupported && compress, onChanged: compressionSupported ? (v) => setState(() => compress = v) : null, ) else Switch.adaptive( value: compressionSupported && compress, onChanged: compressionSupported ? (v) => setState(() => compress = v) : null, ), const SizedBox(width: 16), Expanded( child: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Row( mainAxisSize: .min, children: [ Text( L10n.of(context).compress, style: theme.textTheme.titleMedium, textAlign: TextAlign.left, ), ], ), if (!compress) Text( ' ($sizeString)', style: theme.textTheme.labelSmall, ), if (!compressionSupported) Text( L10n.of(context).notSupportedOnThisDevice, style: theme.textTheme.labelSmall, ), ], ), ), ], ), ], ), ), ), actions: [ AdaptiveDialogAction( onPressed: () => Navigator.of(context, rootNavigator: false).pop(), child: Text(L10n.of(context).cancel), ), AdaptiveDialogAction( onPressed: () => _send(uniqueFileType), child: Text(L10n.of(context).send), ), ], ); }, ); } } extension on ScaffoldMessengerState { void showLoadingSnackBar(String title) => showSnackBar( SnackBar( persist: true, content: Row( spacing: 8, children: [ Expanded(child: Text(title, overflow: .ellipsis, maxLines: 1)), SizedBox.square( dimension: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Theme.of(context).colorScheme.onInverseSurface, ), ), ], ), ), ); }