diff --git a/core/lib/data/constants/constant.dart b/core/lib/data/constants/constant.dart index 79d26de33..525655ecc 100644 --- a/core/lib/data/constants/constant.dart +++ b/core/lib/data/constants/constant.dart @@ -13,4 +13,5 @@ class Constant { static const mailtoScheme = 'mailto'; static const attachmentScheme = 'attachment'; static const emlPreviewerScheme = 'eml-previewer'; + static const htmlExtension = '.html'; } \ No newline at end of file diff --git a/core/lib/domain/exceptions/string_exception.dart b/core/lib/domain/exceptions/string_exception.dart new file mode 100644 index 000000000..e3d8d10ea --- /dev/null +++ b/core/lib/domain/exceptions/string_exception.dart @@ -0,0 +1,7 @@ +class UnsupportedCharsetException implements Exception { + const UnsupportedCharsetException(); +} + +class NullCharsetException implements Exception { + const NullCharsetException(); +} \ No newline at end of file diff --git a/core/lib/presentation/extensions/media_type_extension.dart b/core/lib/presentation/extensions/media_type_extension.dart index dde1016fd..d92f4c6d1 100644 --- a/core/lib/presentation/extensions/media_type_extension.dart +++ b/core/lib/presentation/extensions/media_type_extension.dart @@ -47,4 +47,9 @@ extension MediaTypeExtension on MediaType { fileName?.endsWith(Constant.pdfExtension) == true); bool get isEMLFile => mimeType == Constant.emlMimeType; + + bool isHTMLFile({required String? fileName}) => + mimeType == Constant.textHtmlMimeType || + (mimeType == Constant.octetStreamMimeType && + fileName?.endsWith(Constant.htmlExtension) == true); } diff --git a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart index e9e5b8c76..0f6ebe092 100644 --- a/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart +++ b/core/lib/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart @@ -20,6 +20,9 @@ class StandardizeHtmlSanitizingTransformers extends TextTransformer { 'style', 'body', 'section', + 'nav', + 'main', + 'footer', ]; const StandardizeHtmlSanitizingTransformers(); diff --git a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart index 99e67db9d..ffdc52ca8 100644 --- a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart +++ b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart @@ -12,6 +12,7 @@ import 'package:flutter/cupertino.dart'; import 'package:universal_html/html.dart' as html; typedef OnClickHyperLinkAction = Function(Uri?); +typedef OnMailtoClicked = void Function(Uri? uri); class HtmlContentViewerOnWeb extends StatefulWidget { @@ -21,13 +22,15 @@ class HtmlContentViewerOnWeb extends StatefulWidget { final TextDirection? direction; /// Handler for mailto: links - final Function(Uri?)? mailtoDelegate; + final OnMailtoClicked? mailtoDelegate; final OnClickHyperLinkAction? onClickHyperLinkAction; - // if widthContent is bigger than width of htmlContent, set this to true let widget able to resize to width of htmlContent + // if widthContent is bigger than width of htmlContent, set this to true let widget able to resize to width of htmlContent final bool allowResizeToDocumentSize; - + + final bool keepWidthWhileLoading; + const HtmlContentViewerOnWeb({ Key? key, required this.contentHtml, @@ -37,6 +40,7 @@ class HtmlContentViewerOnWeb extends StatefulWidget { this.mailtoDelegate, this.direction, this.onClickHyperLinkAction, + this.keepWidthWhileLoading = false, }) : super(key: key); @override @@ -99,7 +103,7 @@ class _HtmlContentViewerOnWebState extends State { } } - if (data['type'] != null && data['type'].contains('toDart: htmlWidth')) { + if (data['type'] != null && data['type'].contains('toDart: htmlWidth') && !widget.keepWidthWhileLoading) { final docWidth = data['width'] ?? _actualWidth; if (docWidth != null && mounted) { if (docWidth > _minWidth && widget.allowResizeToDocumentSize) { diff --git a/core/lib/utils/string_convert.dart b/core/lib/utils/string_convert.dart index c3fefb45a..7f9a6de9d 100644 --- a/core/lib/utils/string_convert.dart +++ b/core/lib/utils/string_convert.dart @@ -1,7 +1,10 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'app_logger.dart'; +import 'package:core/domain/exceptions/string_exception.dart'; + class StringConvert { static const String separatorPattern = r'[ ,;]+'; @@ -38,4 +41,24 @@ class StringConvert { return []; } } + + static String decodeFromBytes( + Uint8List bytes, { + required String? charset, + bool isHtml = false, + }) { + if (isHtml) { + return utf8.decode(bytes); + } else if (charset == null) { + throw const NullCharsetException(); + } else if (charset.toLowerCase().contains('utf-8')) { + return utf8.decode(bytes); + } else if (charset.toLowerCase().contains('latin')) { + return latin1.decode(bytes); + } else if (charset.toLowerCase().contains('ascii')) { + return ascii.decode(bytes); + } else { + throw const UnsupportedCharsetException(); + } + } } diff --git a/core/test/utils/standardize_html_sanitizing_transformers_test.dart b/core/test/utils/standardize_html_sanitizing_transformers_test.dart index 5910914dc..8d6dc2471 100644 --- a/core/test/utils/standardize_html_sanitizing_transformers_test.dart +++ b/core/test/utils/standardize_html_sanitizing_transformers_test.dart @@ -143,5 +143,35 @@ void main() { expect(result, equals('')); }); + + test( + 'SHOULD persist nav tag and remove href attribute of A tag ' + 'WHEN href is invalid', + () { + const inputHtml = ''; + final result = transformer.process(inputHtml, htmlEscape); + + expect(result, equals('')); + }); + + test( + 'SHOULD persist main tag and remove href attribute of A tag ' + 'WHEN href is invalid', + () { + const inputHtml = '
'; + final result = transformer.process(inputHtml, htmlEscape); + + expect(result, equals('
')); + }); + + test( + 'SHOULD persist footer tag and remove href attribute of A tag ' + 'WHEN href is invalid', + () { + const inputHtml = ''; + final result = transformer.process(inputHtml, htmlEscape); + + expect(result, equals('')); + }); }); } diff --git a/core/test/utils/string_convert_test.dart b/core/test/utils/string_convert_test.dart index cecd1a271..abd0c3afc 100644 --- a/core/test/utils/string_convert_test.dart +++ b/core/test/utils/string_convert_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:core/domain/exceptions/string_exception.dart'; import 'package:core/utils/string_convert.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -159,4 +160,91 @@ void main() { }); }); }); + + group('string convert test:', () { + const testText = 'Hello'; + test( + 'should use utf8 decoder ' + 'when isHtml is true', + () { + // arrange + final bytes = utf8.encode(testText); + + // act + final result = StringConvert.decodeFromBytes(bytes, charset: null, isHtml: true); + + // assert + expect(result, testText); + }); + + test( + 'should use utf8 decoder ' + 'when charset contains utf-8', + () { + // arrange + final bytes = utf8.encode(testText); + + // act + final result = StringConvert.decodeFromBytes(bytes, charset: 'utf-8'); + + // assert + expect(result, testText); + }); + + test( + 'should use latin1 decoder ' + 'when charset contains latin-1', + () { + // arrange + final bytes = latin1.encode(testText); + + // act + final result = StringConvert.decodeFromBytes(bytes, charset: 'latin-1'); + + // assert + expect(result, testText); + }); + + test( + 'should use ascii decoder ' + 'when charset contains ascii', + () { + // arrange + final bytes = ascii.encode(testText); + + // act + final result = StringConvert.decodeFromBytes(bytes, charset: 'ascii'); + + // assert + expect(result, testText); + }); + + test( + 'should throw NullCharsetException ' + 'when charset is null', + () { + // arrange + final bytes = utf8.encode(testText); + + // assert + expect( + () => StringConvert.decodeFromBytes(bytes, charset: null), + throwsA(isA()), + ); + }); + + test( + 'should throw UnsupportedCharsetException ' + 'when charset is unsupported', + () { + // arrange + final bytes = utf8.encode(testText); + + // assert + expect( + () => StringConvert.decodeFromBytes(bytes, charset: 'unsupported'), + throwsA(isA()), + ); + }); + }); } diff --git a/lib/features/email/data/repository/email_repository_impl.dart b/lib/features/email/data/repository/email_repository_impl.dart index d6d09d4a1..5cf29d8d7 100644 --- a/lib/features/email/data/repository/email_repository_impl.dart +++ b/lib/features/email/data/repository/email_repository_impl.dart @@ -520,4 +520,14 @@ class EmailRepositoryImpl extends EmailRepository { Future getPreviewEMLContentInMemory(String keyStored) { return emailDataSource[DataSourceType.session]!.getPreviewEMLContentInMemory(keyStored); } + + @override + Future sanitizeHtmlContent( + String htmlContent, + TransformConfiguration configuration + ) { + return _htmlDataSource.transformHtmlEmailContent( + htmlContent, + configuration); + } } \ No newline at end of file diff --git a/lib/features/email/domain/repository/email_repository.dart b/lib/features/email/domain/repository/email_repository.dart index 83dff5b13..64824ffc8 100644 --- a/lib/features/email/domain/repository/email_repository.dart +++ b/lib/features/email/domain/repository/email_repository.dart @@ -195,4 +195,8 @@ abstract class EmailRepository { Future storePreviewEMLContentToSessionStorage(EMLPreviewer emlPreviewer); Future getPreviewEMLContentInMemory(String keyStored); + + Future sanitizeHtmlContent( + String htmlContent, + TransformConfiguration configuration); } \ No newline at end of file diff --git a/lib/features/email/domain/state/get_html_content_from_attachment_state.dart b/lib/features/email/domain/state/get_html_content_from_attachment_state.dart new file mode 100644 index 000000000..632550ef8 --- /dev/null +++ b/lib/features/email/domain/state/get_html_content_from_attachment_state.dart @@ -0,0 +1,40 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:model/email/attachment.dart'; + +class GettingHtmlContentFromAttachment extends LoadingState { + GettingHtmlContentFromAttachment({required this.attachment}); + + final Attachment attachment; + + @override + List get props => [attachment]; +} + +class GetHtmlContentFromAttachmentSuccess extends UIState { + GetHtmlContentFromAttachmentSuccess({ + required this.sanitizedHtmlContent, + required this.htmlAttachmentTitle, + required this.attachment, + }); + + final String sanitizedHtmlContent; + final String htmlAttachmentTitle; + final Attachment attachment; + + @override + List get props => [ + sanitizedHtmlContent, + htmlAttachmentTitle, + attachment, + ]; +} + +class GetHtmlContentFromAttachmentFailure extends FeatureFailure { + GetHtmlContentFromAttachmentFailure({super.exception, required this.attachment}); + + final Attachment attachment; + + @override + List get props => [attachment]; +} \ No newline at end of file diff --git a/lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart b/lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart new file mode 100644 index 000000000..8f3efc3eb --- /dev/null +++ b/lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/string_convert.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:model/download/download_task_id.dart'; +import 'package:model/email/attachment.dart'; +import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; + +class GetHtmlContentFromAttachmentInteractor { + GetHtmlContentFromAttachmentInteractor(this._downloadAttachmentForWebInteractor); + + final DownloadAttachmentForWebInteractor _downloadAttachmentForWebInteractor; + + Stream> execute( + AccountId accountId, + Attachment attachment, + DownloadTaskId taskId, + String baseDownloadUrl, + TransformConfiguration transformConfiguration, + ) async* { + final onReceiveController = StreamController>(); + try { + yield Right(GettingHtmlContentFromAttachment(attachment: attachment)); + final downloadState = await _downloadAttachmentForWebInteractor.execute( + taskId, + attachment, + accountId, + baseDownloadUrl, + onReceiveController, + ).last; + + Either? sanitizeState; + await downloadState.fold( + (failure) { + sanitizeState = Left(GetHtmlContentFromAttachmentFailure( + exception: failure is FeatureFailure ? failure.exception : null, + attachment: attachment, + )); + }, + (success) async { + if (success is! DownloadAttachmentForWebSuccess) { + sanitizeState = Right(GettingHtmlContentFromAttachment(attachment: attachment)); + } else { + final htmlContent = StringConvert.decodeFromBytes( + success.bytes, + charset: success.attachment.charset, + isHtml: true, + ); + sanitizeState = await _sanitizeHtmlContent( + htmlContent, + transformConfiguration, + attachment, + ); + } + }, + ); + + onReceiveController.close(); + if (sanitizeState != null) { + yield sanitizeState!; + } else { + yield Left(GetHtmlContentFromAttachmentFailure( + exception: null, + attachment: attachment, + )); + } + + } catch (e) { + logError('GetHtmlContentFromAttachmentInteractor:exception: $e'); + onReceiveController.close(); + yield Left(GetHtmlContentFromAttachmentFailure( + exception: e, + attachment: attachment, + )); + } + } + + Future?> _sanitizeHtmlContent( + String htmlContent, + TransformConfiguration transformConfiguration, + Attachment attachment, + ) async { + try { + final sanitizedHtmlContent = await _downloadAttachmentForWebInteractor + .emailRepository + .sanitizeHtmlContent( + htmlContent, + transformConfiguration, + ); + return Right(GetHtmlContentFromAttachmentSuccess( + sanitizedHtmlContent: sanitizedHtmlContent, + htmlAttachmentTitle: attachment.generateFileName(), + attachment: attachment, + )); + } catch (e) { + return Left(GetHtmlContentFromAttachmentFailure( + exception: e, + attachment: attachment, + )); + } + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/bindings/email_bindings.dart b/lib/features/email/presentation/bindings/email_bindings.dart index 384407ed0..063398edb 100644 --- a/lib/features/email/presentation/bindings/email_bindings.dart +++ b/lib/features/email/presentation/bindings/email_bindings.dart @@ -27,6 +27,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_int import 'package:tmail_ui_user/features/email/domain/usecases/parse_email_by_blob_id_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/preview_email_from_eml_file_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_event_attendance_status_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/email_supervisor_controller.dart'; @@ -78,6 +79,7 @@ class EmailBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } @@ -165,6 +167,7 @@ class EmailBindings extends BaseBindings { Get.lazyPut(() => ParseEmailByBlobIdInteractor(Get.find())); Get.lazyPut(() => PreviewEmailFromEmlFileInteractor(Get.find())); IdentityInteractorsBindings().dependencies(); + Get.lazyPut(() => GetHtmlContentFromAttachmentInteractor(Get.find())); } @override diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index e73a9ca9c..321711fef 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -8,6 +8,7 @@ import 'package:core/presentation/utils/html_transformer/text/sanitize_autolink_ import 'package:core/presentation/utils/html_transformer/text/new_line_transformer.dart'; import 'package:core/presentation/utils/html_transformer/text/standardize_html_sanitizing_transformers.dart'; import 'package:core/utils/html/html_utils.dart'; +import 'package:core/presentation/utils/html_transformer/dom/sanitize_hyper_link_tag_in_html_transformers.dart'; import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; import 'package:flutter/cupertino.dart'; @@ -58,6 +59,7 @@ import 'package:tmail_ui_user/features/email/domain/state/download_attachment_fo import 'package:tmail_ui_user/features/email/domain/state/download_attachments_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/export_attachment_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_read_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/move_to_mailbox_state.dart'; @@ -82,6 +84,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_even import 'package:tmail_ui_user/features/email/domain/usecases/parse_email_by_blob_id_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/preview_email_from_eml_file_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/send_receipt_to_sender_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_event_attendance_status_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; @@ -99,6 +102,7 @@ import 'package:tmail_ui_user/features/email/presentation/widgets/attachment_lis import 'package:tmail_ui_user/features/email/presentation/widgets/attachment_list/attachment_list_dialog_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_bottom_sheet_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_dialog_builder.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/html_attachment_previewer.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/pdf_viewer.dart'; import 'package:tmail_ui_user/features/email_previewer/email_previewer_dialog_view.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; @@ -149,6 +153,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final StoreEventAttendanceStatusInteractor _storeEventAttendanceStatusInteractor; final ParseEmailByBlobIdInteractor _parseEmailByBlobIdInteractor; final PreviewEmailFromEmlFileInteractor _previewEmailFromEmlFileInteractor; + final GetHtmlContentFromAttachmentInteractor _getHtmlContentFromAttachmentInteractor; CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor; SendReceiptToSenderInteractor? _sendReceiptToSenderInteractor; @@ -204,6 +209,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { this._storeEventAttendanceStatusInteractor, this._parseEmailByBlobIdInteractor, this._previewEmailFromEmlFileInteractor, + this._getHtmlContentFromAttachmentInteractor, ); @override @@ -263,6 +269,19 @@ class SingleEmailController extends BaseController with AppLoaderMixin { _handleParseEmailByBlobIdSuccess(success); } else if (success is PreviewEmailFromEmlFileSuccess) { _handlePreviewEmailFromEMLFileSuccess(success); + } else if (success is GetHtmlContentFromAttachmentSuccess) { + _updateAttachmentsViewState(success.attachment.blobId, Right(success)); + Get.dialog(HtmlAttachmentPreviewer( + title: success.htmlAttachmentTitle, + htmlContent: success.sanitizedHtmlContent, + mailToClicked: openMailToLink, + downloadAttachmentClicked: () { + downloadAttachmentForWeb(success.attachment); + }, + responsiveUtils: responsiveUtils, + )); + } else if (success is GettingHtmlContentFromAttachment) { + _updateAttachmentsViewState(success.attachment.blobId, Right(success)); } } @@ -290,6 +309,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { _handleParseEmailByBlobIdFailure(failure); } else if (failure is PreviewEmailFromEmlFileFailure) { _handlePreviewEmailFromEMLFileFailure(failure); + } else if (failure is GetHtmlContentFromAttachmentFailure) { + _handleGetHtmlContentFromAttachmentFailure(failure); } } @@ -1155,7 +1176,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { KeyWordIdentifier.emailFlagged: success.markStarAction == MarkStarAction.markStar, }); mailboxDashBoardController.setSelectedEmail(newEmail); - + final emailId = newEmail?.id; if (emailId == null) return; mailboxDashBoardController.updateEmailFlagByEmailIds( @@ -1932,11 +1953,61 @@ class SingleEmailController extends BaseController with AppLoaderMixin { previewPDFFileAction(context, attachment); } else if (attachment.isEMLFile) { previewEMLFileAction(attachment.blobId, AppLocalizations.of(context)); - } else { + } else if (attachment.isHTMLFile) { + final attachmentEvaluation = evaluateAttachment(attachment); + if (!attachmentEvaluation.canDownloadAttachment) { + consumeState(Stream.value(Left(GetHtmlContentFromAttachmentFailure( + exception: null, + attachment: attachment, + )))); + return; + } + + consumeState(_getHtmlContentFromAttachmentInteractor.execute( + attachmentEvaluation.accountId!, + attachment, + DownloadTaskId(attachmentEvaluation.blobId!.value), + attachmentEvaluation.downloadUrl!, + TransformConfiguration.create( + customDomTransformers: [SanitizeHyperLinkTagInHtmlTransformer()], + customTextTransformers: [const StandardizeHtmlSanitizingTransformers()], + ), + )); + }else { handleDownloadAttachmentAction(context, attachment); } } + ({ + bool canDownloadAttachment, + AccountId? accountId, + String? downloadUrl, + Id? blobId, + }) evaluateAttachment( + Attachment attachment, + ) { + final accountId = mailboxDashBoardController.accountId.value; + final downloadUrl = mailboxDashBoardController.sessionCurrent + ?.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl); + final blobId = attachment.blobId; + + if (accountId == null || downloadUrl == null || blobId == null) { + return ( + canDownloadAttachment: false, + accountId: accountId, + downloadUrl: downloadUrl, + blobId: blobId, + ); + } + + return ( + canDownloadAttachment: true, + accountId: accountId, + downloadUrl: downloadUrl, + blobId: blobId, + ); + } + Future previewPDFFileAction(BuildContext context, Attachment attachment) async { if (accountId == null || session == null) { appToast.showToastErrorMessage( @@ -2121,4 +2192,13 @@ class SingleEmailController extends BaseController with AppLoaderMixin { ComposerArguments.fromMailtoUri(listEmailAddress: listEmailAddressMailTo) ); } + + void _handleGetHtmlContentFromAttachmentFailure(GetHtmlContentFromAttachmentFailure failure) { + _updateAttachmentsViewState(failure.attachment.blobId, Left(failure)); + if (currentOverlayContext != null && currentContext != null) { + appToast.showToastErrorMessage( + currentOverlayContext!, + AppLocalizations.of(currentContext!).thisHtmlAttachmentCannotBePreviewed); + } + } } \ No newline at end of file diff --git a/lib/features/email/presentation/extensions/attachment_extension.dart b/lib/features/email/presentation/extensions/attachment_extension.dart index 784c04b01..3ec192c2c 100644 --- a/lib/features/email/presentation/extensions/attachment_extension.dart +++ b/lib/features/email/presentation/extensions/attachment_extension.dart @@ -38,4 +38,6 @@ extension AttachmentExtension on Attachment { } DownloadTaskId get downloadTaskId => DownloadTaskId(blobId!.value); + + bool get isHTMLFile => type?.isPDFFile(fileName: name) ?? false; } \ No newline at end of file diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index e4eedf8ef..ef36927c8 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -15,6 +15,7 @@ import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/attachment.dart'; import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart'; import 'package:tmail_ui_user/features/email/presentation/model/email_unsubscribe.dart'; import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; @@ -64,10 +65,12 @@ class EmailUtils { static bool checkingIfAttachmentActionIsEnabled(Either? state) { return state?.fold( (failure) { - return failure is DownloadAttachmentForWebFailure; + return failure is DownloadAttachmentForWebFailure + || failure is GetHtmlContentFromAttachmentFailure; }, (success) { return success is DownloadAttachmentForWebSuccess + || success is GetHtmlContentFromAttachmentSuccess || success is IdleDownloadAttachmentForWeb; }) ?? false; } diff --git a/lib/features/email/presentation/widgets/html_attachment_previewer.dart b/lib/features/email/presentation/widgets/html_attachment_previewer.dart new file mode 100644 index 000000000..737ba82a0 --- /dev/null +++ b/lib/features/email/presentation/widgets/html_attachment_previewer.dart @@ -0,0 +1,99 @@ + +import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart'; +import 'package:core/presentation/views/responsive/responsive_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/top_bar_attachment_viewer.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +import 'package:tmail_ui_user/main/utils/app_utils.dart'; + +class HtmlAttachmentPreviewer extends StatelessWidget { + const HtmlAttachmentPreviewer({ + super.key, + required this.htmlContent, + required this.title, + required this.mailToClicked, + required this.downloadAttachmentClicked, + required this.responsiveUtils, + }); + + final String title; + final String htmlContent; + final OnMailtoClicked mailToClicked; + final VoidCallback downloadAttachmentClicked; + final ResponsiveUtils responsiveUtils; + + static const double _verticalMargin = 16; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TopBarAttachmentViewer( + title: title, + closeAction: popBack, + downloadAction: downloadAttachmentClicked, + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: GestureDetector( + onTap: popBack, + child: PointerInterceptor( + child: ColoredBox( + color: Colors.transparent, + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: _verticalMargin), + color: Colors.white, + child: ResponsiveWidget( + responsiveUtils: responsiveUtils, + desktop: _buildHtmlViewerWith( + context, + width: constraints.maxWidth * 0.4, + height: constraints.maxHeight - _verticalMargin * 2 + ), + tablet: _buildHtmlViewerWith( + context, + width: constraints.maxWidth * 0.8, + height: constraints.maxHeight - _verticalMargin * 2 + ), + mobile: _buildHtmlViewerWith( + context, + width: constraints.maxWidth, + height: constraints.maxHeight - _verticalMargin * 2 + ), + ), + ), + ), + ), + ), + ), + ); + } + ), + ), + ], + ); + } + + HtmlContentViewerOnWeb _buildHtmlViewerWith( + BuildContext context, { + required double width, + required double height, + }) { + return HtmlContentViewerOnWeb( + contentHtml: htmlContent, + widthContent: width, + heightContent: height, + direction: AppUtils.getCurrentDirection(context), + mailtoDelegate: (uri) { + popBack(); + mailToClicked(uri); + }, + keepWidthWhileLoading: true, + ); + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/pdf_viewer/pdf_viewer.dart b/lib/features/email/presentation/widgets/pdf_viewer/pdf_viewer.dart index ec93093bc..d7428da38 100644 --- a/lib/features/email/presentation/widgets/pdf_viewer/pdf_viewer.dart +++ b/lib/features/email/presentation/widgets/pdf_viewer/pdf_viewer.dart @@ -21,7 +21,7 @@ import 'package:tmail_ui_user/features/email/domain/exceptions/download_attachme import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/pagination_pdf_viewer.dart'; -import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/top_bar_pdf_viewer.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/top_bar_attachment_viewer.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; @@ -221,8 +221,8 @@ class _PDFViewerState extends State { if (deviceInfo.hasData && deviceInfo.data is WebBrowserInfo) { browserName = (deviceInfo.data as WebBrowserInfo).browserName; } - return TopBarPDFViewer( - attachment: widget.attachment, + return TopBarAttachmentViewer( + title: widget.attachment.generateFileName(), downloadAction: () => widget.downloadAction?.call( viewState.bytes, widget.attachment.generateFileName() @@ -235,8 +235,8 @@ class _PDFViewerState extends State { } return child ?? const SizedBox.shrink(); }, - child: TopBarPDFViewer( - attachment: widget.attachment, + child: TopBarAttachmentViewer( + title: widget.attachment.generateFileName(), closeAction: () { _downloadAttachmentCancelToken?.cancel(); Navigator.maybeOf(context)?.pop(); diff --git a/lib/features/email/presentation/widgets/pdf_viewer/top_bar_pdf_viewer.dart b/lib/features/email/presentation/widgets/pdf_viewer/top_bar_attachment_viewer.dart similarity index 92% rename from lib/features/email/presentation/widgets/pdf_viewer/top_bar_pdf_viewer.dart rename to lib/features/email/presentation/widgets/pdf_viewer/top_bar_attachment_viewer.dart index 550be6407..f77cd8c2d 100644 --- a/lib/features/email/presentation/widgets/pdf_viewer/top_bar_pdf_viewer.dart +++ b/lib/features/email/presentation/widgets/pdf_viewer/top_bar_attachment_viewer.dart @@ -1,16 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:model/email/attachment.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; -class TopBarPDFViewer extends StatelessWidget { - final Attachment attachment; +class TopBarAttachmentViewer extends StatelessWidget { + final String title; final VoidCallback? downloadAction; final VoidCallback? printAction; final VoidCallback? closeAction; - const TopBarPDFViewer({ + const TopBarAttachmentViewer({ super.key, - required this.attachment, + required this.title, this.downloadAction, this.printAction, this.closeAction, @@ -40,7 +39,7 @@ class TopBarPDFViewer extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - attachment.generateFileName(), + title, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: Colors.white, fontSize: 17, diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index d058cda48..ceb581def 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -4119,5 +4119,17 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "saveAndRefresh": "Save & refresh", + "@saveAndRefresh": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "thisHtmlAttachmentCannotBePreviewed": "This html attachment cannot be previewed", + "@thisHtmlAttachmentCannotBePreviewed": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index b18771c0d..7e9459cf0 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4327,4 +4327,18 @@ class AppLocalizations { name: 'editAsNewEmail', ); } + + String get saveAndRefresh { + return Intl.message( + 'Save & refresh', + name: 'saveAndRefresh', + ); + } + + String get thisHtmlAttachmentCannotBePreviewed { + return Intl.message( + 'This html attachment cannot be previewed', + name: 'thisHtmlAttachmentCannotBePreviewed', + ); + } } diff --git a/test/features/email/presentation/controller/single_email_controller_test.dart b/test/features/email/presentation/controller/single_email_controller_test.dart index fed4c41d3..093013f05 100644 --- a/test/features/email/presentation/controller/single_email_controller_test.dart +++ b/test/features/email/presentation/controller/single_email_controller_test.dart @@ -29,6 +29,7 @@ import 'package:tmail_ui_user/features/email/domain/model/event_action.dart'; import 'package:tmail_ui_user/features/email/domain/state/calendar_event_accept_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/parse_calendar_event_state.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_accept_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/maybe_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_reject_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; @@ -107,6 +108,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -143,6 +145,7 @@ void main() { final printUtils = MockPrintUtils(); final applicationManager = MockApplicationManager(); final mockToastManager = MockToastManager(); + final getHtmlContentFromAttachmentInteractor = MockGetHtmlContentFromAttachmentInteractor(); late SingleEmailController singleEmailController; @@ -199,6 +202,7 @@ void main() { storeEventAttendanceStatusInteractor, parseEmailByBlobIdInteractor, previewEmailFromEmlFileInteractor, + getHtmlContentFromAttachmentInteractor, ); });