From b4c17e7e54e80784b7c4c01003892c5c4898acb2 Mon Sep 17 00:00:00 2001 From: Dang Dat Date: Mon, 24 Nov 2025 15:22:49 +0700 Subject: [PATCH 1/4] TW-2716 Allow retry sending media on mobile --- lib/data/network/media/media_api.dart | 2 +- .../sending_image_info_widget.dart | 100 +++++++++---- lib/pages/chat/events/message_content.dart | 7 + .../chat/events/message_upload_content.dart | 109 +++++++++------ .../events/message_video_upload_content.dart | 43 ++++-- .../chat/events/sending_video_widget.dart | 19 ++- .../extensions/send_file_extension.dart | 2 + .../extensions/send_file_web_extension.dart | 16 ++- .../models/upload_file_info.dart | 27 +++- .../upload_manager/upload_manager.dart | 131 ++++++++++++++++-- .../manager/upload_manager/upload_state.dart | 4 +- 11 files changed, 359 insertions(+), 101 deletions(-) diff --git a/lib/data/network/media/media_api.dart b/lib/data/network/media/media_api.dart index c37df143ba..c3b18dd77e 100644 --- a/lib/data/network/media/media_api.dart +++ b/lib/data/network/media/media_api.dart @@ -62,7 +62,7 @@ class MediaAPI { .postToGetBody( HomeserverEndpoint.uploadMediaServicePath .generateHomeserverMediaEndpoint(), - data: file.readStream ?? file.bytes, + data: file.readStream ?? Stream.value(file.bytes ?? List.empty()), queryParameters: { 'fileName': file.name, }, diff --git a/lib/pages/chat/events/images_builder/sending_image_info_widget.dart b/lib/pages/chat/events/images_builder/sending_image_info_widget.dart index 54a9d6e5d9..4a3a4c7e82 100644 --- a/lib/pages/chat/events/images_builder/sending_image_info_widget.dart +++ b/lib/pages/chat/events/images_builder/sending_image_info_widget.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/widgets/mixins/upload_file_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:linagora_design_flutter/colors/linagora_ref_colors.dart'; +import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; import 'package:matrix/matrix.dart' hide Visibility; class SendingImageInfoWidget extends StatefulWidget { @@ -71,6 +72,7 @@ class _SendingImageInfoWidgetState extends State @override Widget build(BuildContext context) { + final sysColor = LinagoraSysColors.material(); if (widget.event.status == EventStatus.sent || widget.event.status == EventStatus.synced) { sendingFileProgressNotifier.value = 1; @@ -78,37 +80,47 @@ class _SendingImageInfoWidgetState extends State return Hero( tag: widget.event.eventId, - child: ValueListenableBuilder( - key: ValueKey(widget.event.eventId), - valueListenable: sendingFileProgressNotifier, - builder: (context, value, child) { + child: _SendingImageInfoOverlay( + sendingFileProgressNotifier: sendingFileProgressNotifier, + uploadFileStateNotifier: uploadFileStateNotifier, + builder: (progress, uploadState, child) { + final hasError = uploadState is UploadFileFailedUIState; return Stack( alignment: Alignment.center, children: [ child!, - if (sendingFileProgressNotifier.value != 1) ...[ - CircularProgressIndicator( - strokeWidth: 2, - color: LinagoraRefColors.material().primary[100], - ), - ValueListenableBuilder( - valueListenable: uploadFileStateNotifier, - builder: (context, state, child) { - if (state is UploadFileSuccessUIState) { - return child!; - } - return InkWell( - child: Icon( - Icons.close, - color: LinagoraRefColors.material().primary[100], - ), - onTap: () { - uploadManager.cancelUpload(widget.event); - }, - ); - }, - child: const SizedBox.shrink(), - ), + if (progress != 1) ...[ + if (!hasError) + CircularProgressIndicator( + strokeWidth: 2, + color: LinagoraRefColors.material().primary[100], + ), + if (hasError) + IconButton( + onPressed: () { + uploadManager.retryUpload(widget.event.eventId); + }, + icon: Icon( + Icons.refresh, + color: sysColor.primary, + size: 24, + ), + padding: const EdgeInsets.all(4), + style: IconButton.styleFrom( + backgroundColor: sysColor.onPrimary, + shape: const CircleBorder(), + ), + ) + else if (uploadState is! UploadFileSuccessUIState) + InkWell( + child: Icon( + Icons.close, + color: LinagoraRefColors.material().primary[100], + ), + onTap: () { + uploadManager.cancelUpload(widget.event); + }, + ), ], ], ); @@ -173,3 +185,37 @@ class _SendingImageInfoWidgetState extends State ); } } + +class _SendingImageInfoOverlay extends StatelessWidget { + const _SendingImageInfoOverlay({ + required this.sendingFileProgressNotifier, + required this.uploadFileStateNotifier, + required this.builder, + required this.child, + }); + + final ValueNotifier sendingFileProgressNotifier; + final ValueNotifier uploadFileStateNotifier; + final Widget Function( + double progress, + UploadFileUIState uploadState, + Widget? child, + ) builder; + final Widget? child; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: sendingFileProgressNotifier, + builder: (context, progress, child) { + return ValueListenableBuilder( + valueListenable: uploadFileStateNotifier, + builder: (context, uploadState, child) { + return builder(progress, uploadState, child); + }, + child: this.child, + ); + }, + ); + } +} diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index dbcc12e275..fced3380aa 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -458,6 +458,13 @@ class _MessageVideoBuilder extends StatelessWidget { height: displayImageInfo.size.height, ); } + if (event.status == EventStatus.error) { + return MessageVideoUploadContentWeb( + event: event, + width: displayImageInfo.size.width, + height: displayImageInfo.size.height, + ); + } return MessageVideoDownloadContent( event: event, width: displayImageInfo.size.width, diff --git a/lib/pages/chat/events/message_upload_content.dart b/lib/pages/chat/events/message_upload_content.dart index e596ddcfc2..294d8397da 100644 --- a/lib/pages/chat/events/message_upload_content.dart +++ b/lib/pages/chat/events/message_upload_content.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/widgets/mixins/upload_file_mixin.dart'; import 'package:fluffychat/widgets/twake_components/twake_preview_link/twake_link_preview.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; import 'package:matrix/matrix.dart'; class MessageUploadingContent extends StatefulWidget { @@ -32,6 +33,7 @@ class _MessageUploadingContentState extends State with UploadFileMixin { @override Widget build(BuildContext context) { + final sysColor = LinagoraSysColors.material(); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -64,66 +66,83 @@ class _MessageUploadingContentState extends State } else if (uploadFileState is UploadFileUISateInitial) { uploadProgress = 0; } else if (uploadFileState is UploadFileSuccessUIState) { - return Row( - crossAxisAlignment: widget.style.crossAxisAlignment, - children: [ - SvgPicture.asset( - widget.event.mimeType.getIcon( - fileType: widget.event.fileType, - ), - width: widget.style.iconSize, - height: widget.style.iconSize, - ), - ], + return SvgPicture.asset( + widget.event.mimeType.getIcon( + fileType: widget.event.fileType, + ), + width: widget.style.iconSize, + height: widget.style.iconSize, ); } return Stack( alignment: Alignment.center, children: [ - Container( - margin: widget.style.marginDownloadIcon, - width: widget.style.iconSize, - height: widget.style.iconSize, - decoration: BoxDecoration( - color: widget.style.iconBackgroundColor( - hasError: hasError, - context: context, + if (hasError) + IconButton( + onPressed: () { + uploadManager.retryUpload(widget.event.eventId); + }, + icon: Icon( + Icons.refresh, + color: sysColor.primary, + size: 24, ), - shape: BoxShape.circle, - ), - ), - if (uploadProgress != 0 && !hasError) - SizedBox( - width: widget.style.circularProgressLoadingSize, - height: widget.style.circularProgressLoadingSize, - child: CircularLoadingDownloadWidget( - style: widget.style, - downloadProgress: - uploadProgress != 1 ? uploadProgress : null, + padding: const EdgeInsets.all(4), + style: IconButton.styleFrom( + backgroundColor: sysColor.onPrimary, + shape: const CircleBorder(), ), - ), - Container( - width: widget.style.downloadIconSize, - decoration: BoxDecoration( - color: widget.style.iconBackgroundColor( - hasError: hasError, - context: context, + ) + else ...[ + Container( + margin: widget.style.marginDownloadIcon, + width: widget.style.iconSize, + height: widget.style.iconSize, + decoration: BoxDecoration( + color: widget.style.iconBackgroundColor( + hasError: false, + context: context, + ), + shape: BoxShape.circle, ), - shape: BoxShape.circle, ), - child: Icon( - hasError ? Icons.error_outline : Icons.close, - key: ValueKey(uploadProgress), - color: Theme.of(context).colorScheme.surface, - size: widget.style.downloadIconSize, + if (uploadProgress != 0) + SizedBox( + width: widget.style.circularProgressLoadingSize, + height: widget.style.circularProgressLoadingSize, + child: CircularLoadingDownloadWidget( + style: widget.style, + downloadProgress: + uploadProgress != 1 ? uploadProgress : null, + ), + ), + Container( + width: widget.style.downloadIconSize, + decoration: BoxDecoration( + color: widget.style.iconBackgroundColor( + hasError: false, + context: context, + ), + shape: BoxShape.circle, + ), + child: Icon( + Icons.close, + key: ValueKey(uploadProgress), + color: Theme.of(context).colorScheme.surface, + size: widget.style.downloadIconSize, + ), ), - ), + ], InkWell( onTap: () { if (uploadFileState is UploadFileSuccessUIState) { return; } - uploadManager.cancelUpload(event); + if (hasError) { + uploadManager.retryUpload(event.eventId); + } else { + uploadManager.cancelUpload(event); + } }, mouseCursor: SystemMouseCursors.click, child: SizedBox( diff --git a/lib/pages/chat/events/message_video_upload_content.dart b/lib/pages/chat/events/message_video_upload_content.dart index 0c6fdaa434..b0df039113 100644 --- a/lib/pages/chat/events/message_video_upload_content.dart +++ b/lib/pages/chat/events/message_video_upload_content.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/presentation/model/chat/upload_file_ui_state.dart'; import 'package:fluffychat/widgets/mixins/upload_file_mixin.dart'; import 'package:flutter/material.dart'; import 'package:linagora_design_flutter/colors/linagora_ref_colors.dart'; +import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; import 'package:matrix/matrix.dart'; class MessageVideoUploadContentWeb extends StatefulWidget { @@ -32,10 +33,13 @@ class _MessageVideoUploadContentWebState @override Widget build(BuildContext context) { + final sysColor = LinagoraSysColors.material(); + return ValueListenableBuilder( valueListenable: uploadFileStateNotifier, builder: ((context, uploadState, child) { double? progress; + final hasError = uploadState is UploadFileFailedUIState; if (uploadState is UploadingFileUIState) { if (uploadState.total != null && @@ -52,7 +56,11 @@ class _MessageVideoUploadContentWebState if (uploadState is UploadFileSuccessUIState) { return; } - uploadManager.cancelUpload(event); + if (hasError) { + uploadManager.retryUpload(event.eventId); + } else { + uploadManager.cancelUpload(event); + } }, centerWidget: Stack( alignment: Alignment.center, @@ -63,20 +71,35 @@ class _MessageVideoUploadContentWebState ), if (uploadState is UploadFileSuccessUIState) ...[ const SizedBox.shrink(), - ] else + ] else if (hasError) + Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: Icon( + Icons.refresh, + color: sysColor.primary, + size: 24, + ), + ) + else const CenterVideoButton( icon: Icons.close, iconSize: MessageContentStyle.cancelButtonSize, ), - SizedBox( - width: MessageContentStyle.iconInsideVideoButtonSize, - height: MessageContentStyle.iconInsideVideoButtonSize, - child: CircularProgressIndicator( - value: uploadState is UploadingFileUIState ? progress : null, - color: LinagoraRefColors.material().primary[100], - strokeWidth: MessageContentStyle.strokeVideoWidth, + if (!hasError) + SizedBox( + width: MessageContentStyle.iconInsideVideoButtonSize, + height: MessageContentStyle.iconInsideVideoButtonSize, + child: CircularProgressIndicator( + value: + uploadState is UploadingFileUIState ? progress : null, + color: LinagoraRefColors.material().primary[100], + strokeWidth: MessageContentStyle.strokeVideoWidth, + ), ), - ), ], ), ); diff --git a/lib/pages/chat/events/sending_video_widget.dart b/lib/pages/chat/events/sending_video_widget.dart index 11efd6a969..e77479f4f9 100644 --- a/lib/pages/chat/events/sending_video_widget.dart +++ b/lib/pages/chat/events/sending_video_widget.dart @@ -33,6 +33,7 @@ class _SendingVideoWidgetState extends State Event get event => widget.event; @override Widget build(BuildContext context) { + final sysColor = LinagoraSysColors.material(); _checkSendingFileStatus(); return ValueListenableBuilder( @@ -91,10 +92,20 @@ class _SendingVideoWidgetState extends State child: const _PlayVideoButton(), ), ] else if (value == SendingVideoStatus.error) ...[ - const SizedBox( - width: MessageContentStyle.videoCenterButtonSize, - height: MessageContentStyle.videoCenterButtonSize, - child: Icon(Icons.error), + IconButton( + onPressed: () { + uploadManager.retryUpload(widget.event.eventId); + }, + icon: Icon( + Icons.refresh, + color: sysColor.primary, + size: 24, + ), + padding: const EdgeInsets.all(4), + style: IconButton.styleFrom( + backgroundColor: sysColor.onPrimary, + shape: const CircleBorder(), + ), ), ], ], diff --git a/lib/presentation/extensions/send_file_extension.dart b/lib/presentation/extensions/send_file_extension.dart index 612080b95f..6b1fd19150 100644 --- a/lib/presentation/extensions/send_file_extension.dart +++ b/lib/presentation/extensions/send_file_extension.dart @@ -74,6 +74,7 @@ extension SendFileExtension on Room { fileInfo.fileSize, maxMediaSize, ), + txid: txid, ), ), ); @@ -367,6 +368,7 @@ extension SendFileExtension on Room { Left( UploadFileFailedState( exception: CancelUploadException(), + txid: txid, ), ), ); diff --git a/lib/presentation/extensions/send_file_web_extension.dart b/lib/presentation/extensions/send_file_web_extension.dart index 45585fe3b4..e040bd8682 100644 --- a/lib/presentation/extensions/send_file_web_extension.dart +++ b/lib/presentation/extensions/send_file_web_extension.dart @@ -65,16 +65,22 @@ extension SendFileWebExtension on Room { // Check media config of the server before sending the file. Stop if the // Media config is unreachable or the file is bigger than the given maxsize. try { - final mediaConfig = await client.getConfig(); - final maxMediaSize = mediaConfig.mUploadSize; + int maxMediaSize = 0; + try { + final mediaConfig = await client.getConfig(); + maxMediaSize = mediaConfig.mUploadSize ?? 0; + } catch (e) { + Logs().e('Cannot get media config', e); + } Logs().d( 'SendImage::sendImageFileEvent(): FileSized ${file.size} || maxMediaSize $maxMediaSize', ); - if (maxMediaSize != null && maxMediaSize < file.size) { + if (maxMediaSize > 0 && maxMediaSize < file.size) { uploadStreamController?.add( Left( UploadFileFailedState( exception: FileTooBigMatrixException(file.size, maxMediaSize), + txid: txid, ), ), ); @@ -86,6 +92,7 @@ extension SendFileWebExtension on Room { Left( UploadFileFailedState( exception: e, + txid: txid, ), ), ); @@ -207,6 +214,7 @@ extension SendFileWebExtension on Room { Left( UploadFileFailedState( exception: e, + txid: txid, ), ), ); @@ -219,6 +227,7 @@ extension SendFileWebExtension on Room { Left( UploadFileFailedState( exception: CancelUploadException(), + txid: txid, ), ), ); @@ -231,6 +240,7 @@ extension SendFileWebExtension on Room { Left( UploadFileFailedState( exception: e, + txid: txid, ), ), ); diff --git a/lib/utils/manager/upload_manager/models/upload_file_info.dart b/lib/utils/manager/upload_manager/models/upload_file_info.dart index f8049a15a3..193a602879 100644 --- a/lib/utils/manager/upload_manager/models/upload_file_info.dart +++ b/lib/utils/manager/upload_manager/models/upload_file_info.dart @@ -5,13 +5,22 @@ import 'package:fluffychat/app_state/failure.dart'; import 'package:fluffychat/app_state/success.dart'; import 'package:fluffychat/utils/manager/upload_manager/models/upload_caption_info.dart'; import 'package:fluffychat/utils/manager/upload_manager/models/upload_info.dart'; +import 'package:matrix/matrix.dart'; class UploadFileInfo extends UploadInfo { final StreamController> uploadStateStreamController; final Stream> uploadStream; - final CancelToken cancelToken; + CancelToken cancelToken; final DateTime createdAt; final UploadCaptionInfo? captionInfo; + bool isFailed; + dynamic lastError; + Room? room; + FileInfo? fileInfo; + MatrixFile? matrixFile; + MatrixImageFile? thumbnail; + int? shrinkImageMaxDimension; + SyncUpdate? fakeImageEvent; UploadFileInfo({ required super.txid, @@ -20,6 +29,14 @@ class UploadFileInfo extends UploadInfo { required this.uploadStream, required this.cancelToken, this.captionInfo, + this.isFailed = false, + this.lastError, + this.room, + this.fileInfo, + this.matrixFile, + this.thumbnail, + this.shrinkImageMaxDimension, + this.fakeImageEvent, }); @override @@ -30,5 +47,13 @@ class UploadFileInfo extends UploadInfo { cancelToken, createdAt, captionInfo, + isFailed, + lastError, + room, + fileInfo, + matrixFile, + thumbnail, + shrinkImageMaxDimension, + fakeImageEvent, ]; } diff --git a/lib/utils/manager/upload_manager/upload_manager.dart b/lib/utils/manager/upload_manager/upload_manager.dart index e2f923ad5c..d98c0a87a8 100644 --- a/lib/utils/manager/upload_manager/upload_manager.dart +++ b/lib/utils/manager/upload_manager/upload_manager.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/utils/manager/upload_manager/upload_state.dart'; import 'package:fluffychat/utils/manager/upload_manager/upload_worker_queue.dart'; import 'package:fluffychat/utils/task_queue/task.dart'; import 'package:matrix/matrix.dart'; +import 'package:rxdart/rxdart.dart'; class UploadManager { UploadManager._(); @@ -40,6 +41,79 @@ class UploadManager { } } + /// Retries a failed upload + Future retryUpload(String txid) async { + final uploadInfo = _eventIdMapUploadFileInfo[txid]; + + if (uploadInfo == null) { + throw Exception('Upload with txid $txid not found'); + } + + if (!uploadInfo.isFailed) { + throw Exception('Upload with txid $txid is not in failed state'); + } + + final room = uploadInfo.room; + final fileInfo = uploadInfo.fileInfo; + final matrixFile = uploadInfo.matrixFile; + final caption = uploadInfo.captionInfo?.caption; + SyncUpdate? fakeImageEvent; + if (fileInfo != null) { + fakeImageEvent = await room?.sendFakeFileInfoEvent( + fileInfo, + txid: txid, + captionInfo: caption, + ); + } else if (matrixFile != null) { + fakeImageEvent = await room?.sendFakeFileEvent( + matrixFile, + txid: txid, + captionInfo: caption, + ); + } + + if (room == null || fakeImageEvent == null) { + throw Exception('Missing required retry data for txid $txid'); + } + + uploadInfo.cancelToken = CancelToken(); + uploadInfo.isFailed = false; + uploadInfo.lastError = null; + + final streamController = uploadInfo.uploadStateStreamController; + final cancelToken = uploadInfo.cancelToken; + + streamController.add(const Right(UploadFileInitial())); + + if (fileInfo != null) { + _addFileTaskToWorkerQueueMobile( + txid: txid, + fakeImageEvent: fakeImageEvent, + room: room, + fileInfo: fileInfo, + streamController: streamController, + cancelToken: cancelToken, + sentDate: uploadInfo.createdAt, + shrinkImageMaxDimension: uploadInfo.shrinkImageMaxDimension, + captionInfo: caption, + ); + } else if (matrixFile != null) { + await _addFileTaskToWorkerQueueWeb( + txid: txid, + fakeImageEvent: fakeImageEvent, + room: room, + matrixFile: matrixFile, + streamController: streamController, + cancelToken: cancelToken, + thumbnail: uploadInfo.thumbnail, + sentDate: uploadInfo.createdAt, + captionInfo: caption, + ); + } else { + throw Exception('No file data found for retry with txid $txid'); + } + } + Future _clearFileTask(String eventId) async { try { uploadWorkerQueue.clearTaskInQueue(eventId); @@ -63,12 +137,12 @@ class UploadManager { required Room room, String? captionInfo, }) { - final uploadController = StreamController>(); + final uploadController = BehaviorSubject>(); _eventIdMapUploadFileInfo[txid] = UploadFileInfo( txid: txid, uploadStateStreamController: uploadController, - uploadStream: uploadController.stream.asBroadcastStream(), + uploadStream: uploadController.stream, cancelToken: CancelToken(), createdAt: DateTime.now(), captionInfo: captionInfo != null && captionInfo.isNotEmpty @@ -134,6 +208,7 @@ class UploadManager { exception: Exception( 'streamController or cancelToken is null', ), + txid: txidKey, ), ), ); @@ -201,6 +276,7 @@ class UploadManager { exception: Exception( 'streamController or cancelToken is null', ), + txid: txid, ), ), ); @@ -277,6 +353,7 @@ class UploadManager { exception: Exception( 'streamController or cancelToken is null', ), + txid: txid, ), ), ); @@ -313,12 +390,20 @@ class UploadManager { int? shrinkImageMaxDimension, String? captionInfo, }) { + final uploadInfo = _eventIdMapUploadFileInfo[txid]; + if (uploadInfo != null) { + uploadInfo.room = room; + uploadInfo.fileInfo = fileInfo; + uploadInfo.shrinkImageMaxDimension = shrinkImageMaxDimension; + uploadInfo.fakeImageEvent = fakeImageEvent; + } + uploadWorkerQueue.addTask( Task( id: txid, runnable: () async { try { - await room.sendFileEventMobile( + final eventId = await room.sendFileEventMobile( fileInfo, msgType: fileInfo.msgType, txid: txid, @@ -329,15 +414,26 @@ class UploadManager { sentDate: sentDate, captionInfo: captionInfo, ); + if (eventId == null) { + throw Exception('Failed to send file event'); + } } catch (e) { + if (uploadInfo != null) { + uploadInfo.isFailed = true; + uploadInfo.lastError = e; + } streamController.add( Left( - UploadFileFailedState(exception: e), + UploadFileFailedState(exception: e, txid: txid), ), ); } }, - onTaskCompleted: () => _clearFileTask(txid), + onTaskCompleted: () { + if (uploadInfo?.isFailed != true) { + _clearFileTask(txid); + } + }, ), ); } @@ -353,12 +449,20 @@ class UploadManager { DateTime? sentDate, String? captionInfo, }) { + final uploadInfo = _eventIdMapUploadFileInfo[txid]; + if (uploadInfo != null) { + uploadInfo.room = room; + uploadInfo.matrixFile = matrixFile; + uploadInfo.thumbnail = thumbnail; + uploadInfo.fakeImageEvent = fakeImageEvent; + } + return uploadWorkerQueue.addTask( Task( id: txid, runnable: () async { try { - await room.sendFileOnWebEvent( + final eventId = await room.sendFileOnWebEvent( matrixFile, fakeImageEvent: fakeImageEvent, txid: txid, @@ -368,17 +472,26 @@ class UploadManager { sentDate: sentDate, captionInfo: captionInfo, ); + if (eventId == null) { + throw Exception('File upload failed'); + } } catch (e) { + if (uploadInfo != null) { + uploadInfo.isFailed = true; + uploadInfo.lastError = e; + } streamController.add( Left( - UploadFileFailedState(exception: e), + UploadFileFailedState(exception: e, txid: txid), ), ); } }, onTaskCompleted: () { - room.sendingFilePlaceholders.remove(txid); - _clearFileTask(txid); + if (uploadInfo?.isFailed != true) { + room.sendingFilePlaceholders.remove(txid); + _clearFileTask(txid); + } }, ), ); diff --git a/lib/utils/manager/upload_manager/upload_state.dart b/lib/utils/manager/upload_manager/upload_state.dart index cc9fd23753..9199dcfe62 100644 --- a/lib/utils/manager/upload_manager/upload_state.dart +++ b/lib/utils/manager/upload_manager/upload_state.dart @@ -113,12 +113,14 @@ class UploadFileSuccessState extends Success { class UploadFileFailedState extends Failure { final dynamic exception; final bool isThumbnail; + final String? txid; const UploadFileFailedState({ required this.exception, this.isThumbnail = false, + this.txid, }); @override - List get props => [exception, isThumbnail]; + List get props => [exception, isThumbnail, txid]; } From 1d7f5b57d0c186c74861e5cb18c94979d3e779d9 Mon Sep 17 00:00:00 2001 From: Dang Dat Date: Tue, 25 Nov 2025 11:49:53 +0700 Subject: [PATCH 2/4] TW-2716 Allow retry sending text on mobile --- assets/l10n/intl_en.arb | 3 +- lib/pages/chat/chat.dart | 2 + lib/pages/chat/chat_event_list_item.dart | 1 + .../chat_pinned_events/pinned_messages.dart | 4 +- .../pinned_messages_screen.dart | 2 + lib/pages/chat/events/message/message.dart | 3 ++ .../message/message_content_builder.dart | 8 ++++ ...essage_content_with_timestamp_builder.dart | 32 +++++++------ lib/pages/chat/events/message_time.dart | 7 +++ .../events/text_message_retry_button.dart | 38 +++++++++++++++ .../mixins/retry_text_message_mixin.dart | 48 +++++++++++++++++++ 11 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 lib/pages/chat/events/text_message_retry_button.dart create mode 100644 lib/presentation/mixins/retry_text_message_mixin.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 27a90b2812..de3e3462d1 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3504,5 +3504,6 @@ "personalQrDescription": "Start chatting with your contacts. Send invitation link to Twake", "shareQrCode": "Share QR code", "copyLink": "Copy link", - "downloadQrCode": "Download QR code" + "downloadQrCode": "Download QR code", + "tapToRetry": "Tap to retry" } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index fe3c5a7732..d865600298 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -44,6 +44,7 @@ import 'package:fluffychat/presentation/mixins/handle_clipboard_action_mixin.dar import 'package:fluffychat/presentation/mixins/leave_chat_mixin.dart'; import 'package:fluffychat/presentation/mixins/media_picker_mixin.dart'; import 'package:fluffychat/presentation/mixins/paste_image_mixin.dart'; +import 'package:fluffychat/presentation/mixins/retry_text_message_mixin.dart'; import 'package:fluffychat/presentation/mixins/save_file_to_twake_downloads_folder_mixin.dart'; import 'package:fluffychat/presentation/mixins/save_media_to_gallery_android_mixin.dart'; import 'package:fluffychat/presentation/mixins/send_files_mixin.dart'; @@ -145,6 +146,7 @@ class ChatController extends State LeaveChatMixin, DeleteEventMixin, UnblockUserMixin, + RetryTextMessageMixin, AudioMixin, AutoMarkAsReadMixin { final NetworkConnectionService networkConnectionService = diff --git a/lib/pages/chat/chat_event_list_item.dart b/lib/pages/chat/chat_event_list_item.dart index d8d190ad03..b5fc9b2818 100644 --- a/lib/pages/chat/chat_event_list_item.dart +++ b/lib/pages/chat/chat_event_list_item.dart @@ -111,6 +111,7 @@ class ChatEventListItem extends StatelessWidget { }, recentEmojiFuture: controller.getRecentReactionsInteractor.execute(), onReport: controller.reportEventAction, + onRetryTextMessage: controller.retryTextMessage, ), ); } diff --git a/lib/pages/chat/chat_pinned_events/pinned_messages.dart b/lib/pages/chat/chat_pinned_events/pinned_messages.dart index 8044aec98d..9be5a8a399 100644 --- a/lib/pages/chat/chat_pinned_events/pinned_messages.dart +++ b/lib/pages/chat/chat_pinned_events/pinned_messages.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/pages/chat/chat_pinned_events/pinned_messages_style.d import 'package:fluffychat/pages/chat/context_item_chat_action.dart'; import 'package:fluffychat/presentation/extensions/event_update_extension.dart'; import 'package:fluffychat/presentation/mixins/delete_event_mixin.dart'; +import 'package:fluffychat/presentation/mixins/retry_text_message_mixin.dart'; import 'package:fluffychat/presentation/model/forward/forward_argument.dart'; import 'package:fluffychat/resource/image_paths.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; @@ -53,7 +54,8 @@ class PinnedMessagesController extends State PopupContextMenuActionMixin, PopupMenuWidgetMixin, TwakeContextMenuMixin, - DeleteEventMixin { + DeleteEventMixin, + RetryTextMessageMixin { ValueNotifier> eventsNotifier = ValueNotifier([]); final ValueNotifier isHoverNotifier = ValueNotifier(null); diff --git a/lib/pages/chat/chat_pinned_events/pinned_messages_screen.dart b/lib/pages/chat/chat_pinned_events/pinned_messages_screen.dart index 47bd0d95af..ca0e65e9f7 100644 --- a/lib/pages/chat/chat_pinned_events/pinned_messages_screen.dart +++ b/lib/pages/chat/chat_pinned_events/pinned_messages_screen.dart @@ -119,6 +119,8 @@ class PinnedMessagesScreen extends StatelessWidget { context, event, ), + onRetryTextMessage: + controller.retryTextMessage, ); }, ); diff --git a/lib/pages/chat/events/message/message.dart b/lib/pages/chat/events/message/message.dart index 5ce9e439fa..b684fa7dd9 100644 --- a/lib/pages/chat/events/message/message.dart +++ b/lib/pages/chat/events/message/message.dart @@ -101,6 +101,7 @@ class Message extends StatefulWidget { final void Function(BuildContext context, Event, TapDownDetails, double)? onTapMoreButton; final Future? recentEmojiFuture; + final Future Function(Event)? onRetryTextMessage; const Message( this.event, { @@ -143,6 +144,7 @@ class Message extends StatefulWidget { this.onSaveToGallery, this.onTapMoreButton, this.recentEmojiFuture, + this.onRetryTextMessage, }); /// Indicates wheither the user may use a mouse instead @@ -306,6 +308,7 @@ class _MessageState extends State saveToGallery: widget.onSaveToGallery, onTapMoreButton: widget.onTapMoreButton, recentEmojiFuture: widget.recentEmojiFuture, + onRetryTextMessage: widget.onRetryTextMessage, ), ), ]; diff --git a/lib/pages/chat/events/message/message_content_builder.dart b/lib/pages/chat/events/message/message_content_builder.dart index b96b51b232..85303d4625 100644 --- a/lib/pages/chat/events/message/message_content_builder.dart +++ b/lib/pages/chat/events/message/message_content_builder.dart @@ -20,6 +20,7 @@ class MessageContentBuilder extends StatelessWidget final void Function(Event)? onSelect; final Event? nextEvent; final bool selectMode; + final Future Function(Event)? onRetryTextMessage; const MessageContentBuilder({ super.key, @@ -29,6 +30,7 @@ class MessageContentBuilder extends StatelessWidget this.nextEvent, this.scrollToEventId, this.selectMode = true, + this.onRetryTextMessage, }); @override @@ -59,6 +61,8 @@ class MessageContentBuilder extends StatelessWidget ); final stepWidth = sizeMessageBubble?.totalMessageWidth; final isNeedAddNewLine = sizeMessageBubble?.isNeedAddNewLine ?? false; + final isTextMessageError = + event.status.isError && event.messageType == MessageTypes.Text; return OptionalPadding( padding: const EdgeInsets.only(bottom: 8), @@ -95,6 +99,7 @@ class MessageContentBuilder extends StatelessWidget showSeenIcon: event.isOwnMessage, timeline: timeline, room: event.room, + onRetryTextMessage: onRetryTextMessage, ), ), onTapSelectMode: () => selectMode @@ -120,6 +125,7 @@ class MessageContentBuilder extends StatelessWidget showSeenIcon: event.isOwnMessage, timeline: timeline, room: event.room, + onRetryTextMessage: onRetryTextMessage, ), ), ), @@ -128,6 +134,7 @@ class MessageContentBuilder extends StatelessWidget ], ), if (isNeedAddNewLine || + isTextMessageError || isContainsTagName(event) || isContainsSpecialHTMLTag(event)) OptionalSelectionContainerDisabled( @@ -146,6 +153,7 @@ class MessageContentBuilder extends StatelessWidget showSeenIcon: event.isOwnMessage, timeline: timeline, room: event.room, + onRetryTextMessage: onRetryTextMessage, ), ), ), diff --git a/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart b/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart index 7369d510b2..acc25b97b3 100644 --- a/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart +++ b/lib/pages/chat/events/message/message_content_with_timestamp_builder.dart @@ -69,6 +69,7 @@ class MessageContentWithTimestampBuilder extends StatefulWidget { final void Function(BuildContext context, Event, TapDownDetails, double)? onTapMoreButton; final Future? recentEmojiFuture; + final Future Function(Event)? onRetryTextMessage; const MessageContentWithTimestampBuilder({ super.key, @@ -103,6 +104,7 @@ class MessageContentWithTimestampBuilder extends StatefulWidget { this.saveToGallery, this.onTapMoreButton, this.recentEmojiFuture, + this.onRetryTextMessage, }); @override @@ -627,22 +629,22 @@ class _MessageContentWithTimestampBuilderState nextEvent: widget.nextEvent, scrollToEventId: widget.scrollToEventId, selectMode: widget.selectMode, + onRetryTextMessage: widget.onRetryTextMessage, ), - Positioned( - child: OptionalSelectionContainerDisabled( - isEnabled: PlatformInfos.isWeb, - child: Padding( - padding: MessageStyle.paddingMessageTime, - child: Text.rich( - WidgetSpan( - child: MessageTime( - timelineOverlayMessage: - widget.event.timelineOverlayMessage, - room: widget.event.room, - event: widget.event, - showSeenIcon: widget.event.isOwnMessage, - timeline: widget.timeline, - ), + OptionalSelectionContainerDisabled( + isEnabled: PlatformInfos.isWeb, + child: Padding( + padding: MessageStyle.paddingMessageTime, + child: Text.rich( + WidgetSpan( + child: MessageTime( + timelineOverlayMessage: + widget.event.timelineOverlayMessage, + room: widget.event.room, + event: widget.event, + showSeenIcon: widget.event.isOwnMessage, + timeline: widget.timeline, + onRetryTextMessage: widget.onRetryTextMessage, ), ), ), diff --git a/lib/pages/chat/events/message_time.dart b/lib/pages/chat/events/message_time.dart index 7d9708c446..7b34fee7b5 100644 --- a/lib/pages/chat/events/message_time.dart +++ b/lib/pages/chat/events/message_time.dart @@ -1,5 +1,6 @@ import 'package:fluffychat/pages/chat/events/message/message_style.dart'; import 'package:fluffychat/pages/chat/events/message_time_style.dart'; +import 'package:fluffychat/pages/chat/events/text_message_retry_button.dart'; import 'package:fluffychat/pages/chat/seen_by_row.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; @@ -19,6 +20,7 @@ class MessageTime extends StatelessWidget { required this.timeline, required this.timelineOverlayMessage, required this.room, + this.onRetryTextMessage, }); final Event event; @@ -26,6 +28,7 @@ class MessageTime extends StatelessWidget { final bool timelineOverlayMessage; final Timeline timeline; final Room room; + final Future Function(Event)? onRetryTextMessage; @override Widget build(BuildContext context) { @@ -88,6 +91,10 @@ class MessageTime extends StatelessWidget { ), ), ), + TextMessageRetryButton( + event: event, + onRetry: onRetryTextMessage, + ), if (showSeenIcon) ...[ SizedBox(width: MessageTimeStyle.paddingTimeAndIcon), SeenByRow( diff --git a/lib/pages/chat/events/text_message_retry_button.dart b/lib/pages/chat/events/text_message_retry_button.dart new file mode 100644 index 0000000000..9409861326 --- /dev/null +++ b/lib/pages/chat/events/text_message_retry_button.dart @@ -0,0 +1,38 @@ +import 'package:fluffychat/generated/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; +import 'package:matrix/matrix.dart'; + +class TextMessageRetryButton extends StatelessWidget { + const TextMessageRetryButton({ + super.key, + required this.event, + this.onRetry, + }); + + final Event event; + final Future Function(Event)? onRetry; + + @override + Widget build(BuildContext context) { + if (event.status != EventStatus.error) return const SizedBox(); + if (event.messageType != MessageTypes.Text) return const SizedBox(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + height: 16, + child: TextButton( + style: TextButton.styleFrom(padding: EdgeInsets.zero), + onPressed: onRetry != null ? () => onRetry!(event) : null, + child: Text( + L10n.of(context)!.tapToRetry, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontSize: 12, + height: 16 / 12, + color: LinagoraSysColors.material().primary, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/mixins/retry_text_message_mixin.dart b/lib/presentation/mixins/retry_text_message_mixin.dart new file mode 100644 index 0000000000..8070ba5934 --- /dev/null +++ b/lib/presentation/mixins/retry_text_message_mixin.dart @@ -0,0 +1,48 @@ +import 'package:matrix/matrix.dart'; + +mixin RetryTextMessageMixin { + Future retryTextMessage(Event event) async { + if (event.status != EventStatus.error) { + Logs().w('RetryTextMessageMixin: Event is not in error state'); + return; + } + + if (event.messageType != MessageTypes.Text) { + Logs().w('RetryTextMessageMixin: Event is not a text message'); + return; + } + + final room = event.room; + final messageBody = event.body; + + if (messageBody.isEmpty) { + Logs().w('RetryTextMessageMixin: Message body is empty'); + return; + } + + Event? replyEvent; + String? editEventId; + + if (event.relationshipType == RelationshipTypes.reply && + event.relationshipEventId != null) { + replyEvent = await room.getEventById(event.relationshipEventId!); + } else if (event.relationshipType == RelationshipTypes.edit && + event.relationshipEventId != null) { + editEventId = event.relationshipEventId; + } + + try { + await event.remove(); + await room.sendTextEvent( + messageBody, + inReplyTo: replyEvent, + editEventId: editEventId, + ); + + Logs().i('RetryTextMessageMixin: Text message retry successful'); + } catch (e) { + Logs().e('RetryTextMessageMixin: Failed to retry text message', e); + rethrow; + } + } +} From 6f4bd138bab8023af6eb072158d322c4236f6f54 Mon Sep 17 00:00:00 2001 From: Dang Dat Date: Mon, 15 Dec 2025 15:03:03 +0700 Subject: [PATCH 3/4] fixup! TW-2716 Allow retry sending media on mobile --- .../sending_image_info_widget.dart | 12 +- .../extensions/send_file_web_extension.dart | 27 +--- .../upload_manager/upload_manager.dart | 135 ++++++++++-------- 3 files changed, 81 insertions(+), 93 deletions(-) diff --git a/lib/pages/chat/events/images_builder/sending_image_info_widget.dart b/lib/pages/chat/events/images_builder/sending_image_info_widget.dart index 4a3a4c7e82..977b6f1893 100644 --- a/lib/pages/chat/events/images_builder/sending_image_info_widget.dart +++ b/lib/pages/chat/events/images_builder/sending_image_info_widget.dart @@ -12,7 +12,6 @@ import 'package:fluffychat/widgets/hero_page_route.dart'; import 'package:fluffychat/widgets/mixins/upload_file_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; -import 'package:linagora_design_flutter/colors/linagora_ref_colors.dart'; import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; import 'package:matrix/matrix.dart' hide Visibility; @@ -89,11 +88,11 @@ class _SendingImageInfoWidgetState extends State alignment: Alignment.center, children: [ child!, - if (progress != 1) ...[ - if (!hasError) + if (progress != 1 || hasError) ...[ + if (!hasError && progress != 1) CircularProgressIndicator( strokeWidth: 2, - color: LinagoraRefColors.material().primary[100], + color: sysColor.onPrimary, ), if (hasError) IconButton( @@ -111,11 +110,12 @@ class _SendingImageInfoWidgetState extends State shape: const CircleBorder(), ), ) - else if (uploadState is! UploadFileSuccessUIState) + else if (uploadState is! UploadFileSuccessUIState && + progress != 1) InkWell( child: Icon( Icons.close, - color: LinagoraRefColors.material().primary[100], + color: sysColor.onPrimary, ), onTap: () { uploadManager.cancelUpload(widget.event); diff --git a/lib/presentation/extensions/send_file_web_extension.dart b/lib/presentation/extensions/send_file_web_extension.dart index e040bd8682..37d6b7300a 100644 --- a/lib/presentation/extensions/send_file_web_extension.dart +++ b/lib/presentation/extensions/send_file_web_extension.dart @@ -76,26 +76,11 @@ extension SendFileWebExtension on Room { 'SendImage::sendImageFileEvent(): FileSized ${file.size} || maxMediaSize $maxMediaSize', ); if (maxMediaSize > 0 && maxMediaSize < file.size) { - uploadStreamController?.add( - Left( - UploadFileFailedState( - exception: FileTooBigMatrixException(file.size, maxMediaSize), - txid: txid, - ), - ), - ); throw FileTooBigMatrixException(file.size, maxMediaSize); } } catch (e) { Logs().d('Config error while sending file', e); - uploadStreamController?.add( - Left( - UploadFileFailedState( - exception: e, - txid: txid, - ), - ), - ); + fakeImageEvent.rooms!.join!.values.first.timeline!.events!.first .unsigned![messageSendingStatusKey] = EventStatus.error.intValue; await handleImageFakeSync(fakeImageEvent); @@ -210,15 +195,7 @@ extension SendFileWebExtension on Room { fakeImageEvent.rooms!.join!.values.first.timeline!.events!.first .unsigned![messageSendingStatusKey] = EventStatus.error.intValue; await handleImageFakeSync(fakeImageEvent); - uploadStreamController?.add( - Left( - UploadFileFailedState( - exception: e, - txid: txid, - ), - ), - ); - Logs().v('Error: $e'); + Logs().e('Error: $e'); rethrow; } catch (e) { if (e is CancelRequestException) { diff --git a/lib/utils/manager/upload_manager/upload_manager.dart b/lib/utils/manager/upload_manager/upload_manager.dart index d98c0a87a8..f9ab0ccb8b 100644 --- a/lib/utils/manager/upload_manager/upload_manager.dart +++ b/lib/utils/manager/upload_manager/upload_manager.dart @@ -29,6 +29,8 @@ class UploadManager { final Map _eventIdMapUploadFileInfo = {}; + final Set _retriesInProgress = {}; + static const int _shrinkImageMaxDimension = 1600; Future cancelUpload(Event event) async { @@ -43,74 +45,83 @@ class UploadManager { /// Retries a failed upload Future retryUpload(String txid) async { - final uploadInfo = _eventIdMapUploadFileInfo[txid]; - - if (uploadInfo == null) { - throw Exception('Upload with txid $txid not found'); - } - - if (!uploadInfo.isFailed) { - throw Exception('Upload with txid $txid is not in failed state'); - } - - final room = uploadInfo.room; - final fileInfo = uploadInfo.fileInfo; - final matrixFile = uploadInfo.matrixFile; - final caption = uploadInfo.captionInfo?.caption; - SyncUpdate? fakeImageEvent; - if (fileInfo != null) { - fakeImageEvent = await room?.sendFakeFileInfoEvent( - fileInfo, - txid: txid, - captionInfo: caption, - ); - } else if (matrixFile != null) { - fakeImageEvent = await room?.sendFakeFileEvent( - matrixFile, - txid: txid, - captionInfo: caption, - ); + if (_retriesInProgress.contains(txid)) { + Logs().w('Retry already in progress for txid $txid'); + return; } + _retriesInProgress.add(txid); + try { + final uploadInfo = _eventIdMapUploadFileInfo[txid]; - if (room == null || fakeImageEvent == null) { - throw Exception('Missing required retry data for txid $txid'); - } + if (uploadInfo == null) { + throw Exception('Upload with txid $txid not found'); + } - uploadInfo.cancelToken = CancelToken(); - uploadInfo.isFailed = false; - uploadInfo.lastError = null; + if (!uploadInfo.isFailed) { + throw Exception('Upload with txid $txid is not in failed state'); + } - final streamController = uploadInfo.uploadStateStreamController; - final cancelToken = uploadInfo.cancelToken; + final room = uploadInfo.room; + final fileInfo = uploadInfo.fileInfo; + final matrixFile = uploadInfo.matrixFile; + final caption = uploadInfo.captionInfo?.caption; + SyncUpdate? fakeImageEvent; + if (fileInfo != null) { + fakeImageEvent = await room?.sendFakeFileInfoEvent( + fileInfo, + txid: txid, + captionInfo: caption, + ); + } else if (matrixFile != null) { + fakeImageEvent = await room?.sendFakeFileEvent( + matrixFile, + txid: txid, + captionInfo: caption, + ); + } - streamController.add(const Right(UploadFileInitial())); + if (room == null || fakeImageEvent == null) { + throw Exception('Missing required retry data for txid $txid'); + } - if (fileInfo != null) { - _addFileTaskToWorkerQueueMobile( - txid: txid, - fakeImageEvent: fakeImageEvent, - room: room, - fileInfo: fileInfo, - streamController: streamController, - cancelToken: cancelToken, - sentDate: uploadInfo.createdAt, - shrinkImageMaxDimension: uploadInfo.shrinkImageMaxDimension, - captionInfo: caption, - ); - } else if (matrixFile != null) { - await _addFileTaskToWorkerQueueWeb( - txid: txid, - fakeImageEvent: fakeImageEvent, - room: room, - matrixFile: matrixFile, - streamController: streamController, - cancelToken: cancelToken, - thumbnail: uploadInfo.thumbnail, - sentDate: uploadInfo.createdAt, - captionInfo: caption, - ); - } else { - throw Exception('No file data found for retry with txid $txid'); + uploadInfo.cancelToken = CancelToken(); + uploadInfo.isFailed = false; + uploadInfo.lastError = null; + + final streamController = uploadInfo.uploadStateStreamController; + final cancelToken = uploadInfo.cancelToken; + + streamController.add(const Right(UploadFileInitial())); + + if (fileInfo != null) { + _addFileTaskToWorkerQueueMobile( + txid: txid, + fakeImageEvent: fakeImageEvent, + room: room, + fileInfo: fileInfo, + streamController: streamController, + cancelToken: cancelToken, + sentDate: uploadInfo.createdAt, + shrinkImageMaxDimension: uploadInfo.shrinkImageMaxDimension, + captionInfo: caption, + ); + } else if (matrixFile != null) { + await _addFileTaskToWorkerQueueWeb( + txid: txid, + fakeImageEvent: fakeImageEvent, + room: room, + matrixFile: matrixFile, + streamController: streamController, + cancelToken: cancelToken, + thumbnail: uploadInfo.thumbnail, + sentDate: uploadInfo.createdAt, + captionInfo: caption, + ); + } else { + throw Exception('No file data found for retry with txid $txid'); + } + } finally { + _retriesInProgress.remove(txid); } } From 4d5531a5e8ccd2a9e3a0a788a6b990d15bed6d3f Mon Sep 17 00:00:00 2001 From: Dang Dat Date: Mon, 15 Dec 2025 15:03:17 +0700 Subject: [PATCH 4/4] fixup! TW-2716 Allow retry sending text on mobile --- lib/presentation/mixins/retry_text_message_mixin.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/presentation/mixins/retry_text_message_mixin.dart b/lib/presentation/mixins/retry_text_message_mixin.dart index 8070ba5934..05be1d6ab4 100644 --- a/lib/presentation/mixins/retry_text_message_mixin.dart +++ b/lib/presentation/mixins/retry_text_message_mixin.dart @@ -32,12 +32,12 @@ mixin RetryTextMessageMixin { } try { - await event.remove(); await room.sendTextEvent( messageBody, inReplyTo: replyEvent, editEventId: editEventId, ); + await event.remove(); Logs().i('RetryTextMessageMixin: Text message retry successful'); } catch (e) {