TF-4009 Display a attachment reminder warning modal
This commit is contained in:
@@ -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 {
|
||||
});
|
||||
});
|
||||
</script>''';
|
||||
|
||||
static String extractPlainText(String html) {
|
||||
var cleaned = html;
|
||||
// Delete the blockquote and the content inside
|
||||
final blockquoteRegex = RegExp(
|
||||
r'<blockquote[\s\S]*?</blockquote>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
cleaned = html.replaceAll(blockquoteRegex, '');
|
||||
|
||||
// Decode HTML entities up to 5 times (& → &, → space, <div> → <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(
|
||||
'</?(${validTags.join('|')})(\\s+[^>]*)?>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
cleaned = cleaned.replaceAll(tagRegex, ' ');
|
||||
|
||||
// Normalize whitespace
|
||||
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,4 +140,186 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('HtmlUtils.extractPlainText', () {
|
||||
test('removes blockquote and keeps other text', () {
|
||||
const html = '''
|
||||
<div>Hello <b>world</b></div>
|
||||
<blockquote>
|
||||
<p>should be removed</p>
|
||||
</blockquote>
|
||||
<div>Final</div>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Hello world Final');
|
||||
});
|
||||
|
||||
test('removes nested blockquotes', () {
|
||||
const html = '''
|
||||
<p>Keep this</p>
|
||||
<blockquote>
|
||||
<p>remove me</p>
|
||||
<blockquote><p>nested remove</p></blockquote>
|
||||
</blockquote>
|
||||
<span>After</span>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Keep this After');
|
||||
});
|
||||
|
||||
test('removes multiple blockquotes at same level', () {
|
||||
const html = '''
|
||||
<p>Start</p>
|
||||
<blockquote><p>first</p></blockquote>
|
||||
<blockquote><p>second</p></blockquote>
|
||||
<p>End</p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Start End');
|
||||
});
|
||||
|
||||
test('removes all html tags outside blockquotes', () {
|
||||
const html = '<div>Hello <b>bold</b> <i>italic</i></div>';
|
||||
expect(HtmlUtils.extractPlainText(html), 'Hello bold italic');
|
||||
});
|
||||
|
||||
test('normalizes whitespaces', () {
|
||||
const html = '''
|
||||
<p>Hello</p>
|
||||
<blockquote><p>remove</p></blockquote>
|
||||
<p> World </p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Hello World');
|
||||
});
|
||||
|
||||
test('returns empty string if only blockquote present', () {
|
||||
const html = '<blockquote><p>everything removed</p></blockquote>';
|
||||
expect(HtmlUtils.extractPlainText(html), '');
|
||||
});
|
||||
|
||||
test('case insensitive blockquote tag', () {
|
||||
const html = '''
|
||||
<BLOCKQUOTE><p>Remove me</p></BLOCKQUOTE>
|
||||
<p>Keep me</p>
|
||||
''';
|
||||
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 = '''
|
||||
<p>A & B</p>
|
||||
<p>5 < 10 > 3</p>
|
||||
<p>Hello World</p>
|
||||
<blockquote><p>remove ©</p></blockquote>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'A & B 5 < 10 > 3 Hello World');
|
||||
});
|
||||
|
||||
test('handles double-encoded html entities with full decode', () {
|
||||
const html = '''
|
||||
<p>Before</p>
|
||||
&lt;div&gt;Hello&lt;/div&gt;
|
||||
<p>After</p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Before Hello After');
|
||||
});
|
||||
|
||||
|
||||
test('handles emoji correctly', () {
|
||||
const html = '''
|
||||
<p>Hello 🌍🚀</p>
|
||||
<blockquote><p>😅 should be removed</p></blockquote>
|
||||
<p>Done ✅</p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Hello 🌍🚀 Done ✅');
|
||||
});
|
||||
|
||||
test('handles Cyrillic text', () {
|
||||
const html = '''
|
||||
<p>Привет мир</p>
|
||||
<blockquote><p>Удалить это</p></blockquote>
|
||||
<p>Отчёт завершён</p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'Привет мир Отчёт завершён');
|
||||
});
|
||||
|
||||
test('handles Japanese text', () {
|
||||
const html = '''
|
||||
<p>こんにちは 世界</p>
|
||||
<blockquote><p>これは削除される</p></blockquote>
|
||||
<p>完了しました</p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), 'こんにちは 世界 完了しました');
|
||||
});
|
||||
|
||||
test('handles Chinese text', () {
|
||||
const html = '''
|
||||
<p>你好,世界</p>
|
||||
<blockquote><p>这部分要删除</p></blockquote>
|
||||
<p>完成</p>
|
||||
''';
|
||||
|
||||
expect(HtmlUtils.extractPlainText(html), '你好,世界 完成');
|
||||
});
|
||||
|
||||
test('returns empty string when input is empty', () {
|
||||
expect(HtmlUtils.extractPlainText(''), '');
|
||||
});
|
||||
|
||||
test('handles html without closing tags', () {
|
||||
const html = '<p>Hello <b>World';
|
||||
expect(HtmlUtils.extractPlainText(html), 'Hello World');
|
||||
});
|
||||
|
||||
test('ignores numeric-like tags (e.g. <123>) as text', () {
|
||||
const html = '<p>value is <123> not a tag</p>';
|
||||
// <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 = '<p>custom &unknown; entity</p>';
|
||||
// if the entity cannot be decoded, keep it as is
|
||||
expect(HtmlUtils.extractPlainText(html), 'custom &unknown; entity');
|
||||
});
|
||||
|
||||
test('removes nested blockquote completely', () {
|
||||
const html = '''
|
||||
<p>Intro</p>
|
||||
<blockquote>
|
||||
<p>nested <blockquote>deep</blockquote></p>
|
||||
</blockquote>
|
||||
<p>Outro</p>
|
||||
''';
|
||||
expect(HtmlUtils.extractPlainText(html), 'Intro Outro');
|
||||
});
|
||||
|
||||
test('preserves spacing when multiple tags removed', () {
|
||||
const html = '<div>Hello</div><span>World</span>';
|
||||
expect(HtmlUtils.extractPlainText(html), 'Hello World');
|
||||
});
|
||||
|
||||
test('decodes multiple levels of entities', () {
|
||||
// &lt; = < → <
|
||||
const html = '<p>&lt;b&gt;Bold&lt;/b&gt;</p>';
|
||||
// Because <b> 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 = '<heloloasdadadadadsad>dab';
|
||||
expect(HtmlUtils.extractPlainText(html), '<heloloasdadadadadsad>dab');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<String> getContentInEditor() async {
|
||||
@@ -888,7 +889,7 @@ class ComposerController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSendMessages(BuildContext context) async {
|
||||
Future<void> _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<void> _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();
|
||||
|
||||
@@ -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<String> 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<String> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user