From b83503585fa77c4951a50e46725fb9a0b2620e57 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 22 Sep 2024 19:18:08 +0200 Subject: [PATCH] feat: Sending multiple files at once --- assets/l10n/intl_de.arb | 2 - assets/l10n/intl_en.arb | 30 +++- lib/pages/chat/chat.dart | 10 +- lib/pages/chat/send_file_dialog.dart | 157 +++++++++++++------ lib/pages/chat_list/chat_list.dart | 1 + lib/utils/localized_exception_extension.dart | 23 ++- 6 files changed, 165 insertions(+), 58 deletions(-) diff --git a/assets/l10n/intl_de.arb b/assets/l10n/intl_de.arb index 6bf23b00b..021f77046 100644 --- a/assets/l10n/intl_de.arb +++ b/assets/l10n/intl_de.arb @@ -2271,8 +2271,6 @@ "@disableEncryptionWarning": {}, "reopenChat": "Chat wieder eröffnen", "@reopenChat": {}, - "fileIsTooBigForServer": "Der Server meldet, dass die Datei zu groß ist für eine Übermittlung ist.", - "@fileIsTooBigForServer": {}, "noBackupWarning": "Achtung! Ohne Aktivierung des Chat-Backups verlierst du den Zugriff auf deine verschlüsselten Nachrichten. Vor dem Ausloggen wird dringend empfohlen, das Chat-Backup zu aktivieren.", "@noBackupWarning": {}, "noOtherDevicesFound": "Keine anderen Geräte anwesend", diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index b8ef839d5..b25e1977d 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2422,8 +2422,13 @@ "@noBackupWarning": {}, "noOtherDevicesFound": "No other devices found", "@noOtherDevicesFound": {}, - "fileIsTooBigForServer": "The server reports that the file is too large to be sent.", - "@fileIsTooBigForServer": {}, + "fileIsTooBigForServer": "Unable to send! The server only supports attachments up to {max}.", + "@fileIsTooBigForServer": { + "type": "text", + "placeholders": { + "max": {} + } + }, "fileHasBeenSavedAt": "File has been saved at {path}", "@fileHasBeenSavedAt": { "type": "text", @@ -2762,5 +2767,24 @@ "whatIsAHomeserver": "What is a homeserver?", "homeserverDescription": "All your data is stored on the homeserver, just like an email provider. You can choose which homeserver you want to use, while you can still communicate with everyone. Learn more at at https://matrix.org.", "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?", - "calculatingFileSize": "Calculating file size..." + "calculatingFileSize": "Calculating file size...", + "prepareSendingAttachment": "Prepare sending attachment...", + "sendingAttachment": "Sending attachment...", + "generatingVideoThumbnail": "Generating video thumbnail...", + "compressVideo": "Compressing video...", + "sendingAttachmentCountOfCount": "Sending attachment {index} of {length}...", + "@sendingAttachmentCountOfCount": { + "type": "integer", + "placeholders": { + "index": {}, + "length": {} + } + }, + "serverLimitReached": "Server limit reached! Waiting {seconds} seconds...", + "@serverLimitReached": { + "type": "integer", + "placeholders": { + "seconds": {} + } + } } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 9b934e05b..c47de7a83 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -128,6 +128,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: details.files, room: room, + outerContext: context, ), ); } @@ -483,7 +484,7 @@ class ChatController extends State final result = await AppLock.of(context).pauseWhile( FilePicker.platform.pickFiles( compressionQuality: 0, - allowMultiple: false, + allowMultiple: true, ), ); if (result == null || result.files.isEmpty) return; @@ -492,6 +493,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: result.xFiles, room: room, + outerContext: context, ), ); } @@ -503,6 +505,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: [XFile.fromData(image)], room: room, + outerContext: context, ), ); } @@ -512,7 +515,7 @@ class ChatController extends State FilePicker.platform.pickFiles( compressionQuality: 0, type: FileType.image, - allowMultiple: false, + allowMultiple: true, ), ); if (result == null || result.files.isEmpty) return; @@ -522,6 +525,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: result.xFiles, room: room, + outerContext: context, ), ); } @@ -537,6 +541,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: [file], room: room, + outerContext: context, ), ); } @@ -555,6 +560,7 @@ class ChatController extends State builder: (c) => SendFileDialog( files: [file], room: room, + outerContext: context, ), ); } diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index 5109a1d40..78856bc03 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -6,12 +6,11 @@ import 'package:flutter/material.dart'; import 'package:cross_file/cross_file.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; import 'package:mime/mime.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/error_reporter.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/size_string.dart'; @@ -20,10 +19,12 @@ import '../../utils/resize_video.dart'; class SendFileDialog extends StatefulWidget { final Room room; final List files; + final BuildContext outerContext; const SendFileDialog({ required this.room, required this.files, + required this.outerContext, super.key, }); @@ -38,65 +39,98 @@ class SendFileDialogState extends State { static const int minSizeToCompress = 20 * 1024; Future _send() async { - final scaffoldMessenger = ScaffoldMessenger.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(widget.outerContext); final l10n = L10n.of(context)!; - Navigator.of(context, rootNavigator: false).pop(); + try { + scaffoldMessenger.showLoadingSnackBar(l10n.prepareSendingAttachment); + Navigator.of(context, rootNavigator: false).pop(); + final clientConfig = await widget.room.client.getConfig(); + final maxUploadSize = clientConfig.mUploadSize ?? 100 * 1024 * 1024; - showFutureLoadingDialog( - context: context, - future: () async { - final clientConfig = await widget.room.client.getConfig(); - final maxUploadSize = clientConfig.mUploadSize ?? 100 * 1024 * 1024; + for (final xfile in widget.files) { + final MatrixFile file; + MatrixImageFile? thumbnail; + final length = await xfile.length(); + final mimeType = xfile.mimeType ?? lookupMimeType(xfile.path); - for (final xfile in widget.files) { - final MatrixFile file; - MatrixImageFile? thumbnail; - final length = await xfile.length(); - final mimeType = xfile.mimeType ?? lookupMimeType(xfile.path); + // If file is a video, shrink it! + if (PlatformInfos.isMobile && + mimeType != null && + mimeType.startsWith('video') && + length > minSizeToCompress && + !origImage) { + scaffoldMessenger.showLoadingSnackBar(l10n.compressVideo); + file = await xfile.resizeVideo(); + scaffoldMessenger.showLoadingSnackBar(l10n.generatingVideoThumbnail); + thumbnail = await xfile.getVideoThumbnail(); + } else { + // Else we just create a MatrixFile + file = MatrixFile( + bytes: await xfile.readAsBytes(), + name: xfile.name, + mimeType: xfile.mimeType, + ).detectFileType; + } - // If file is a video, shrink it! - if (mimeType != null && - mimeType.startsWith('video') && - length > minSizeToCompress && - !origImage) { - file = await xfile.resizeVideo(); - thumbnail = await xfile.getVideoThumbnail(); - } else { - // Else we just create a MatrixFile - file = MatrixFile( - bytes: await xfile.readAsBytes(), - name: xfile.name, - mimeType: xfile.mimeType, - ).detectFileType; - } + if (file.bytes.length > maxUploadSize) { + throw FileTooBigMatrixException(length, maxUploadSize); + } + + if (widget.files.length > 1) { + scaffoldMessenger.showLoadingSnackBar( + l10n.sendingAttachmentCountOfCount( + widget.files.indexOf(xfile) + 1, + widget.files.length, + ), + ); + } else { + scaffoldMessenger.showLoadingSnackBar(l10n.sendingAttachment); + } - if (file.bytes.length > maxUploadSize) { - throw FileTooBigMatrixException(length, maxUploadSize); + try { + await widget.room.sendFileEvent( + file, + thumbnail: thumbnail, + shrinkImageMaxDimension: origImage ? null : 1600, + ); + } on MatrixException catch (e) { + final retryAfterMs = e.retryAfterMs; + if (e.error != MatrixError.M_LIMIT_EXCEEDED || retryAfterMs == null) { + rethrow; } + final retryAfterDuration = + Duration(milliseconds: retryAfterMs + 1000); + + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + l10n.serverLimitReached(retryAfterDuration.inSeconds), + ), + ), + ); + await Future.delayed(retryAfterDuration); + + scaffoldMessenger.showLoadingSnackBar(l10n.sendingAttachment); - widget.room - .sendFileEvent( + await widget.room.sendFileEvent( file, thumbnail: thumbnail, shrinkImageMaxDimension: origImage ? null : 1600, - ) - .catchError( - (e, s) { - if (e is FileTooBigMatrixException) { - scaffoldMessenger.showSnackBar( - SnackBar(content: Text(l10n.fileIsTooBigForServer)), - ); - return null; - } - ErrorReporter(context, 'Unable to send file') - .onErrorCallback(e, s); - return null; - }, ); } - }, - ); + } + } catch (e) { + scaffoldMessenger.clearSnackBars(); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(e.toLocalizedString(widget.outerContext)), + duration: const Duration(seconds: 30), + showCloseIcon: true, + ), + ); + rethrow; + } return; } @@ -270,3 +304,30 @@ class SendFileDialogState extends State { ); } } + +extension on ScaffoldMessengerState { + ScaffoldFeatureController showLoadingSnackBar( + String title, + ) { + clearSnackBars(); + return showSnackBar( + SnackBar( + duration: const Duration(minutes: 5), + dismissDirection: DismissDirection.none, + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + const SizedBox(width: 16), + Text(title), + ], + ), + ), + ); + } +} diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index bc9b3fa02..5c631eb0e 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -214,6 +214,7 @@ class ChatListController extends State ), ], room: room, + outerContext: context, ), ); Matrix.of(context).shareContent = null; diff --git a/lib/utils/localized_exception_extension.dart b/lib/utils/localized_exception_extension.dart index 0dfbe3dce..c501bcbcb 100644 --- a/lib/utils/localized_exception_extension.dart +++ b/lib/utils/localized_exception_extension.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; @@ -10,10 +11,29 @@ import 'package:matrix/matrix.dart'; import 'uia_request_manager.dart'; extension LocalizedExceptionExtension on Object { + static String _formatFileSize(int size) { + if (size < 1024) return '$size B'; + final i = (log(size) / log(1024)).floor(); + final num = (size / pow(1024, i)); + final round = num.round(); + final numString = round < 10 + ? num.toStringAsFixed(2) + : round < 100 + ? num.toStringAsFixed(1) + : round.toString(); + return '$numString ${'kMGTPEZY'[i - 1]}B'; + } + String toLocalizedString( BuildContext context, [ ExceptionContext? exceptionContext, ]) { + if (this is FileTooBigMatrixException) { + final exception = this as FileTooBigMatrixException; + return L10n.of(context)!.fileIsTooBigForServer( + _formatFileSize(exception.maxFileSize), + ); + } if (this is MatrixException) { switch ((this as MatrixException).error) { case MatrixError.M_FORBIDDEN: @@ -30,9 +50,6 @@ extension LocalizedExceptionExtension on Object { if (this is InvalidPassphraseException) { return L10n.of(context)!.wrongRecoveryKey; } - if (this is FileTooBigMatrixException) { - return L10n.of(context)!.fileIsTooBigForServer; - } if (this is BadServerVersionsException) { final serverVersions = (this as BadServerVersionsException) .serverVersions