From 1f8348499845d1388cde59ec033ce9ad386b398e Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 11 Sep 2025 12:42:53 +0700 Subject: [PATCH] TF-4009 Display a attachment reminder warning modal --- core/lib/utils/html/html_utils.dart | 38 +++- core/test/utils/html_utils_test.dart | 182 ++++++++++++++++++ .../presentation/composer_controller.dart | 66 ++++++- .../attachment_detection_extension.dart | 52 +++++ lib/l10n/intl_messages.arb | 24 ++- lib/main/localizations/app_localizations.dart | 22 +++ 6 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 lib/features/composer/presentation/extensions/attachment_detection_extension.dart diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 68b553af2..3c2eb21be 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -14,6 +14,11 @@ import 'package:flutter/material.dart'; import 'package:universal_html/html.dart' as html; class HtmlUtils { + static const validTags = [ + 'html','head','body','div','span','p','b','i','u','strong','em','a', + 'img','blockquote','ul','ol','li','table','tr','td','th','thead','tbody', + 'br','hr','h1','h2','h3','h4','h5','h6','pre','code' + ]; static final random = Random(); static final htmlUnescape = HtmlUnescape(); @@ -524,7 +529,6 @@ class HtmlUtils { .replaceAll('\t', ''); } - /// Returns true if the browser is Safari and its major version is less than 17. static bool isSafariBelow17() { try { @@ -658,4 +662,36 @@ class HtmlUtils { }); }); '''; + + static String extractPlainText(String html) { + var cleaned = html; + // Delete the blockquote and the content inside + final blockquoteRegex = RegExp( + r'', + caseSensitive: false, + ); + cleaned = html.replaceAll(blockquoteRegex, ''); + + // Decode HTML entities up to 5 times (& → &,   → space, <div> →
, ...) + int iterations = 0; + const maxIterations = 5; + String decoded; + do { + decoded = cleaned; + cleaned = htmlUnescape.convert(cleaned); + iterations++; + } while (decoded != cleaned && iterations < maxIterations); + + // Delete all remaining HTML tags → replace tag with space to avoid text sticking + final tagRegex = RegExp( + ']*)?>', + caseSensitive: false, + ); + cleaned = cleaned.replaceAll(tagRegex, ' '); + + // Normalize whitespace + cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim(); + + return cleaned; + } } diff --git a/core/test/utils/html_utils_test.dart b/core/test/utils/html_utils_test.dart index bfb172dfc..8943604b4 100644 --- a/core/test/utils/html_utils_test.dart +++ b/core/test/utils/html_utils_test.dart @@ -140,4 +140,186 @@ void main() { ); }); }); + + group('HtmlUtils.extractPlainText', () { + test('removes blockquote and keeps other text', () { + const html = ''' +
Hello world
+
+

should be removed

+
+
Final
+ '''; + + expect(HtmlUtils.extractPlainText(html), 'Hello world Final'); + }); + + test('removes nested blockquotes', () { + const html = ''' +

Keep this

+
+

remove me

+

nested remove

+
+ After + '''; + + expect(HtmlUtils.extractPlainText(html), 'Keep this After'); + }); + + test('removes multiple blockquotes at same level', () { + const html = ''' +

Start

+

first

+

second

+

End

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'Start End'); + }); + + test('removes all html tags outside blockquotes', () { + const html = '
Hello bold italic
'; + expect(HtmlUtils.extractPlainText(html), 'Hello bold italic'); + }); + + test('normalizes whitespaces', () { + const html = ''' +

Hello

+

remove

+

World

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'Hello World'); + }); + + test('returns empty string if only blockquote present', () { + const html = '

everything removed

'; + expect(HtmlUtils.extractPlainText(html), ''); + }); + + test('case insensitive blockquote tag', () { + const html = ''' +

Remove me

+

Keep me

+ '''; + expect(HtmlUtils.extractPlainText(html), 'Keep me'); + }); + + test('handles text without any html', () { + const html = 'Just plain text already'; + expect(HtmlUtils.extractPlainText(html), 'Just plain text already'); + }); + + test('decodes HTML entities correctly', () { + const html = ''' +

A & B

+

5 < 10 > 3

+

Hello World

+

remove ©

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'A & B 5 < 10 > 3 Hello World'); + }); + + test('handles double-encoded html entities with full decode', () { + const html = ''' +

Before

+ &lt;div&gt;Hello&lt;/div&gt; +

After

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'Before Hello After'); + }); + + + test('handles emoji correctly', () { + const html = ''' +

Hello 🌍🚀

+

😅 should be removed

+

Done ✅

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'Hello 🌍🚀 Done ✅'); + }); + + test('handles Cyrillic text', () { + const html = ''' +

Привет мир

+

Удалить это

+

Отчёт завершён

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'Привет мир Отчёт завершён'); + }); + + test('handles Japanese text', () { + const html = ''' +

こんにちは 世界

+

これは削除される

+

完了しました

+ '''; + + expect(HtmlUtils.extractPlainText(html), 'こんにちは 世界 完了しました'); + }); + + test('handles Chinese text', () { + const html = ''' +

你好,世界

+

这部分要删除

+

完成

+ '''; + + expect(HtmlUtils.extractPlainText(html), '你好,世界 完成'); + }); + + test('returns empty string when input is empty', () { + expect(HtmlUtils.extractPlainText(''), ''); + }); + + test('handles html without closing tags', () { + const html = '

Hello World'; + expect(HtmlUtils.extractPlainText(html), 'Hello World'); + }); + + test('ignores numeric-like tags (e.g. <123>) as text', () { + const html = '

value is <123> not a tag

'; + // <123> is not a valid HTML tag, so it should be kept + expect(HtmlUtils.extractPlainText(html), 'value is <123> not a tag'); + }); + + test('keeps unknown HTML entities as-is', () { + const html = '

custom &unknown; entity

'; + // if the entity cannot be decoded, keep it as is + expect(HtmlUtils.extractPlainText(html), 'custom &unknown; entity'); + }); + + test('removes nested blockquote completely', () { + const html = ''' +

Intro

+
+

nested

deep

+
+

Outro

+ '''; + expect(HtmlUtils.extractPlainText(html), 'Intro Outro'); + }); + + test('preserves spacing when multiple tags removed', () { + const html = '
Hello
World'; + expect(HtmlUtils.extractPlainText(html), 'Hello World'); + }); + + test('decodes multiple levels of entities', () { + // &lt; = < → < + const html = '

&lt;b&gt;Bold&lt;/b&gt;

'; + // Because is decoded into a real tag and removed, only Bold remains + expect(HtmlUtils.extractPlainText(html), 'Bold'); + }); + + test('keeps unknown tags as plain text', () { + const html = 'dab'; + expect(HtmlUtils.extractPlainText(html), 'dab'); + }); + }); } \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index d8a7adf8c..bfd9cfd63 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -56,6 +56,7 @@ import 'package:tmail_ui_user/features/composer/domain/usecases/restore_email_in import 'package:tmail_ui_user/features/composer/domain/usecases/save_composer_cache_on_web_interactor.dart'; import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_web_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/attachment_detection_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/auto_create_tag_for_recipients_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/get_draft_mailbox_id_for_composer_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/get_outbox_mailbox_id_for_composer_extension.dart'; @@ -835,7 +836,7 @@ class ComposerController extends BaseController appLocalizations.message_dialog_send_email_without_a_subject, appLocalizations.send_anyway, cancelTitle: appLocalizations.cancel, - onConfirmAction: () => _handleSendMessages(context), + onConfirmAction: () => _prepareToSendMessages(context), onCancelAction: popBack, autoPerformPopBack: false, title: appLocalizations.empty_subject, @@ -871,7 +872,7 @@ class ComposerController extends BaseController return; } - _handleSendMessages(context); + _prepareToSendMessages(context); } Future getContentInEditor() async { @@ -888,7 +889,7 @@ class ComposerController extends BaseController } } - void _handleSendMessages(BuildContext context) async { + Future _prepareToSendMessages(BuildContext context) async { final arguments = composerArguments.value; final session = mailboxDashBoardController.sessionCurrent; final accountId = mailboxDashBoardController.accountId.value; @@ -905,6 +906,51 @@ class ComposerController extends BaseController } final emailContent = await getContentInEditor(); + if (!context.mounted) { + _sendButtonState = ButtonState.enabled; + return; + } + final attachmentKeywords = validateAttachmentReminder( + emailSubject: subjectEmail.value ?? '', + emailContent: emailContent, + ); + if (attachmentKeywords.isNotEmpty && + uploadController.attachmentsUploaded.isEmpty) { + showAttachmentReminderModal( + context: context, + keywords: attachmentKeywords, + onConfirmAction: () { + _sendMessageToServer( + context: context, + session: session, + accountId: accountId, + arguments: arguments, + emailContent: emailContent, + ); + }, + onCancelAction: () { + _sendButtonState = ButtonState.enabled; + } + ); + return; + } + + _sendMessageToServer( + context: context, + session: session, + accountId: accountId, + arguments: arguments, + emailContent: emailContent, + ); + } + + Future _sendMessageToServer({ + required BuildContext context, + required Session session, + required AccountId accountId, + required ComposerArguments arguments, + required String emailContent, + }) async { final uploadUri = _getUploadUriFromSession(session, accountId); final cancelToken = CancelToken(); final resultState = await _showSendingMessageDialog( @@ -913,15 +959,19 @@ class ComposerController extends BaseController arguments: arguments, emailContent: emailContent, uploadUri: uploadUri, - cancelToken: cancelToken + cancelToken: cancelToken, ); - log('ComposerController::_handleSendMessages: resultState = $resultState'); - if (resultState is SendEmailSuccess || mailboxDashBoardController.validateSendingEmailFailedWhenNetworkIsLostOnMobile(resultState)) { + + if (resultState is SendEmailSuccess || + mailboxDashBoardController + .validateSendingEmailFailedWhenNetworkIsLostOnMobile(resultState)) { _sendButtonState = ButtonState.enabled; _closeComposerAction(result: resultState); - } else if (resultState is SendEmailFailure && resultState.exception is SendingEmailCanceledException) { + } else if (resultState is SendEmailFailure && + resultState.exception is SendingEmailCanceledException) { _sendButtonState = ButtonState.enabled; - } else if (resultState is SendEmailFailure || resultState is GenerateEmailFailure) { + } else if (resultState is SendEmailFailure || + resultState is GenerateEmailFailure) { if (resultState.exception is BadCredentialsException) { _sendButtonState = ButtonState.enabled; handleBadCredentialsException(); diff --git a/lib/features/composer/presentation/extensions/attachment_detection_extension.dart b/lib/features/composer/presentation/extensions/attachment_detection_extension.dart new file mode 100644 index 000000000..b42f1b548 --- /dev/null +++ b/lib/features/composer/presentation/extensions/attachment_detection_extension.dart @@ -0,0 +1,52 @@ +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/html/html_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_text_detector.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +extension AttachmentDetectionExtension on ComposerController { + + List validateAttachmentReminder({ + required String emailContent, + required String emailSubject, + }) { + try { + final fullContent = '$emailSubject $emailContent'; + final plainText = HtmlUtils.extractPlainText(fullContent); + final keywords = AttachmentTextDetector.matchedKeywordsUnique(plainText); + if (keywords.isEmpty) { + return []; + } else { + return keywords; + } + } catch (e) { + logError('$runtimeType::validateAttachmentReminder:Error $e'); + return []; + } + } + + void showAttachmentReminderModal({ + required BuildContext context, + required List keywords, + required VoidCallback onConfirmAction, + required VoidCallback onCancelAction, + }) { + final appLocalizations = AppLocalizations.of(context); + String formattedKeywords = keywords.map((k) => '"$k"').join(', '); + log('$runtimeType::showAttachmentReminderModal:formattedKeywords = $formattedKeywords'); + MessageDialogActionManager().showConfirmDialogAction( + key: const Key('attachment_reminder_modal'), + context, + title: appLocalizations.attachmentReminderModalTitle, + appLocalizations.attachmentReminderModalMessage(formattedKeywords), + appLocalizations.sendMessage, + cancelTitle: AppLocalizations.of(context).cancel, + onConfirmAction: onConfirmAction, + onCancelAction: onCancelAction, + onCloseButtonAction: popBack, + ); + } +} diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 1865c49d5..03f8e9f34 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2025-09-10T11:45:56.814190", + "@@last_modified": "2025-09-11T12:24:54.969188", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -4829,5 +4829,27 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "sendMessage": "Send message", + "@sendMessage": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "attachmentReminderModalTitle": "Forgot to attach a file?", + "@attachmentReminderModalTitle": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "attachmentReminderModalMessage": "You wrote {keyword} in your message but did not add any attachments. Do you still want to send ?", + "@attachmentReminderModalMessage": { + "type": "text", + "placeholders_order": [ + "keyword" + ], + "placeholders": { + "keyword": {} + } } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 4c1e55c30..d06012b68 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5102,4 +5102,26 @@ class AppLocalizations { name: 'createFolder', ); } + + String get sendMessage { + return Intl.message( + 'Send message', + name: 'sendMessage', + ); + } + + String get attachmentReminderModalTitle { + return Intl.message( + 'Forgot to attach a file?', + name: 'attachmentReminderModalTitle', + ); + } + + String attachmentReminderModalMessage(String keyword) { + return Intl.message( + 'You wrote $keyword in your message but did not add any attachments. Do you still want to send?', + name: 'attachmentReminderModalMessage', + args: [keyword], + ); + } }