TF-3864 Support pasting address with display name

Signed-off-by: dab246 <tdvu@linagora.com>
This commit is contained in:
dab246
2025-07-30 15:19:19 +07:00
committed by Dat H. Pham
parent 7f195a5422
commit f1ae5b58b2
7 changed files with 132 additions and 17 deletions
+1
View File
@@ -52,6 +52,7 @@ export 'utils/broadcast_channel/broadcast_channel.dart';
export 'utils/list_utils.dart';
export 'utils/mail/domain.dart';
export 'utils/mail/mail_address.dart';
export 'utils/mail/named_address.dart';
export 'utils/application_manager.dart';
export 'utils/preview_eml_file_utils.dart';
export 'utils/logger/log_tracking.dart';
+11
View File
@@ -0,0 +1,11 @@
import 'package:equatable/equatable.dart';
class NamedAddress with EquatableMixin {
final String name;
final String address;
NamedAddress({required this.name, required this.address});
@override
List<Object?> get props => [name, address];
}
+13
View File
@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:core/utils/app_logger.dart';
import 'package:core/domain/exceptions/string_exception.dart';
import 'package:core/utils/mail/named_address.dart';
import 'package:html/parser.dart';
import 'package:http_parser/http_parser.dart';
@@ -137,4 +138,16 @@ class StringConvert {
log('StringConvert::isTextTable:isMarkdown = $isMarkdown | isAscii = $isAsciiArt');
return isMarkdown || isAsciiArt;
}
static NamedAddress? parseNamedAddress(String input) {
final regex = RegExp(r'''^(['"])(.+?)\1\s*<([^>\s]+)>$''');
final match = regex.firstMatch(input.trim());
if (match != null) {
final name = match.group(2)!;
final address = match.group(3)!;
return NamedAddress(name: name, address: address);
}
return null;
}
}
+65 -5
View File
@@ -1,11 +1,12 @@
import 'dart:convert';
import 'package:core/domain/exceptions/string_exception.dart';
import 'package:core/utils/mail/named_address.dart';
import 'package:core/utils/string_convert.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('StringConvert::decodeBase64ToString::test', () {
group('StringConvert.decodeBase64ToString', () {
test('should decode a valid Base64 string to a normal string', () {
// Arrange
const base64Encoded = 'SGVsbG8gV29ybGQh';
@@ -41,7 +42,7 @@ void main() {
});
});
group('StringConvert::extractEmailAddress::', () {
group('StringConvert.extractEmailAddress', () {
group('Basic Functionality', () {
test('should not extract strings separated by spaces', () {
const input = 'user1@example.com user2@example.com';
@@ -197,7 +198,7 @@ void main() {
});
});
group('string convert test:', () {
group('StringConvert.decodeFromBytes', () {
const testText = 'Hello';
test(
'should use utf8 decoder '
@@ -284,7 +285,7 @@ void main() {
});
});
group('StringConvert::getMediaTypeFromBase64ImageTag::', () {
group('StringConvert.getMediaTypeFromBase64ImageTag', () {
test('should return correct MediaType for valid JPEG base64 tag', () {
const validJpegTag = 'data:image/jpeg;base64,/9j/4AAQSkZJRg==';
final result = StringConvert.getMediaTypeFromBase64ImageTag(validJpegTag);
@@ -331,7 +332,7 @@ void main() {
});
});
group('StringConvert::isTextTable::', () {
group('StringConvert.isTextTable', () {
// ---------------------------
// Test cases cho Markdown tables
// ---------------------------
@@ -516,4 +517,63 @@ void main() {
expect(StringConvert.isTextTable(text), isTrue);
});
});
group('StringConvert.parseNamedAddress', () {
test('Parses valid double-quoted input', () {
const input = '"John Doe" <john@example.com>';
final result = StringConvert.parseNamedAddress(input);
expect(
result,
NamedAddress(name: 'John Doe', address: 'john@example.com'),
);
});
test('Parses valid single-quoted input', () {
const input = "'Jane Smith' <jane123@abc.com>";
final result = StringConvert.parseNamedAddress(input);
expect(
result,
NamedAddress(name: 'Jane Smith', address: 'jane123@abc.com'),
);
});
test('Keeps encoded values as-is (no decode)', () {
const input = '"John%20Doe" <john%40example.com>';
final result = StringConvert.parseNamedAddress(input);
expect(
result,
NamedAddress(name: 'John%20Doe', address: 'john%40example.com'),
);
});
test('Accepts whitespace between name and <address>', () {
const input = '"Name" <value>';
final result = StringConvert.parseNamedAddress(input);
expect(result, NamedAddress(name: 'Name', address: 'value'));
});
test('Returns null for empty name', () {
const input = '"" <something>';
final result = StringConvert.parseNamedAddress(input);
expect(result, isNull);
});
test('Returns null for empty address', () {
const input = '"Valid" <>';
final result = StringConvert.parseNamedAddress(input);
expect(result, isNull);
});
test('Returns null for missing quotes', () {
const input = 'NoQuotes <abc>';
final result = StringConvert.parseNamedAddress(input);
expect(result, isNull);
});
test('Returns null for mismatched quotes', () {
const input = "'Mismatch\" <abc>";
final result = StringConvert.parseNamedAddress(input);
expect(result, isNull);
});
});
}
@@ -4,9 +4,11 @@ import 'package:core/utils/string_convert.dart';
import 'package:flutter/cupertino.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
import 'package:model/email/prefix_email_address.dart';
import 'package:model/extensions/email_address_extension.dart';
import 'package:super_tag_editor/tag_editor.dart';
import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/named_address_extension.dart';
extension AutoCreateTagForRecipientsExtension on ComposerController {
@@ -40,25 +42,34 @@ extension AutoCreateTagForRecipientsExtension on ComposerController {
};
void autoCreateEmailTagForType(PrefixEmailAddress type, String input) {
final addressSet = StringConvert.extractEmailAddress(input).toSet();
if (addressSet.isEmpty) return;
final namedAddress = StringConvert.parseNamedAddress(input);
Set<String> addressSet = {};
if (namedAddress == null) {
addressSet = StringConvert.extractEmailAddress(input).toSet();
if (addressSet.isEmpty) return;
}
final emailList = _emailLists[type]!;
final keyEditor = _emailEditors[type]!;
final listEmailAddressRecord = addressSet
.map((address) => _generateEmailAddressFromString(address))
List<EmailAddress> listEmailAddress = [];
final emailSet = emailList.map((email) => email.emailAddress).toSet();
if (namedAddress != null && !emailSet.contains(namedAddress.address)) {
listEmailAddress.add(namedAddress.toEmailAddress());
} else if (addressSet.isNotEmpty) {
final listEmailAddressRecord = addressSet
.map(_generateEmailAddressFromString)
.toList();
final emailSet = emailList.map((email) => email.email).toSet();
final newEmails = addressSet.difference(emailSet);
if (newEmails.isEmpty) return;
final listEmailAddress = listEmailAddressRecord
.where((emailRecord) => newEmails.contains(emailRecord.$1))
listEmailAddress = listEmailAddressRecord
.where((emailRecord) => !emailSet.contains(emailRecord.$1))
.map((emailRecord) => emailRecord.$2)
.toList();
}
if (listEmailAddress.isEmpty) return;
emailList.addAll(listEmailAddress);
@@ -0,0 +1,7 @@
import 'package:core/utils/mail/named_address.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
extension NamedAddressExtension on NamedAddress {
EmailAddress toEmailAddress() => EmailAddress(name, address);
}
@@ -18,6 +18,7 @@ import 'package:model/email/prefix_email_address.dart';
import 'package:model/extensions/email_address_extension.dart';
import 'package:model/mailbox/expand_mode.dart';
import 'package:super_tag_editor/tag_editor.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/named_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart';
@@ -557,6 +558,17 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
) => _createMailTag(value, stateSetter);
void _createMailTag(String value, StateSetter stateSetter) {
final namedAddress = StringConvert.parseNamedAddress(value.trim());
if (namedAddress != null) {
if (!_isDuplicatedRecipient(namedAddress.address)) {
stateSetter(
() => _currentListEmailAddress.add(namedAddress.toEmailAddress()),
);
_updateListEmailAddressAction();
}
return;
}
final listString = StringConvert.extractEmailAddress(value.trim()).toSet();
if (listString.isEmpty && !_isDuplicatedRecipient(value)) {