TF-3911 Support mail keyboard shortcut actions in email detailed view on web

This commit is contained in:
dab246
2025-07-29 02:00:32 +07:00
committed by Dat H. Pham
parent 1c7778d6cc
commit b2989521d9
36 changed files with 763 additions and 270 deletions
@@ -155,4 +155,6 @@ class SynchronizeEmailSortOrderAction extends DashBoardAction {
@override
List<Object?> get props => [emailSortOrderType];
}
}
class ReclaimMailListKeyboardShortcutFocusAction extends DashBoardAction {}
@@ -121,6 +121,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_clear_mailbox_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_create_new_rule_filter.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_preferences_setting_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_reactive_obx_variable_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_save_email_as_draft_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_store_email_sort_order_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/initialize_app_language.dart';
@@ -310,6 +311,8 @@ class MailboxDashBoardController extends ReloadableController
int minInputLengthAutocomplete = AppConfig.defaultMinInputLengthAutocomplete;
EmailSortOrderType currentSortOrder = SearchEmailFilter.defaultSortOrder;
PaywallController? paywallController;
Worker? advancedSearchVisibleWorker;
Worker? searchInputFocusWorker;
final StreamController<Either<Failure, Success>> _progressStateController =
StreamController<Either<Failure, Success>>.broadcast();
@@ -376,6 +379,7 @@ class MailboxDashBoardController extends ReloadableController
if (PlatformInfo.isWeb) {
listSearchFilterScrollController = ScrollController();
twakeAppManager.setExecutingBeforeReconnect(false);
registerReactiveObxVariableListener();
}
if (PlatformInfo.isIOS) {
_registerPendingCurrentEmailIdInNotification();
@@ -3039,18 +3043,17 @@ class MailboxDashBoardController extends ReloadableController
return false;
}
void archiveMessage(BuildContext context, PresentationEmail email) {
void archiveMessage(PresentationEmail email) {
final mailboxContain = email.findMailboxContain(mapMailboxById);
if (mailboxContain != null) {
final archiveMailboxId = getMailboxIdByRole(PresentationMailbox.roleArchive);
final archiveMailboxPath = mapMailboxById[archiveMailboxId]?.getDisplayName(context);
if (archiveMailboxId != null) {
final moveToArchiveMailboxRequest = MoveToMailboxRequest(
{mailboxContain.id: [email.id!]},
archiveMailboxId,
MoveAction.moving,
EmailActionType.moveToMailbox,
destinationPath: archiveMailboxPath
destinationPath: getMailboxNameById(archiveMailboxId),
);
moveToMailbox(
sessionCurrent!,
@@ -3062,6 +3065,15 @@ class MailboxDashBoardController extends ReloadableController
}
}
String getMailboxNameById(MailboxId mailboxId) {
final mailbox = mapMailboxById[mailboxId];
if (currentContext != null) {
return mailbox?.getDisplayName(currentContext!) ?? '';
} else {
return mailbox?.name?.name ?? '';
}
}
void _handleRestoreDeletedMessageSuccess(EmailRecoveryActionId emailRecoveryActionId) async {
log('MailboxDashBoardController::_handleRestoreDeletedMessageSuccess():emailRecoveryActionId: $emailRecoveryActionId');
_getRestoredDeletedMessage(emailRecoveryActionId);
@@ -3271,10 +3283,14 @@ class MailboxDashBoardController extends ReloadableController
}
}
bool get isEmailOpened =>
dashboardRoute.value == DashboardRoutes.threadDetailed;
@override
void onClose() {
if (PlatformInfo.isWeb) {
listSearchFilterScrollController?.dispose();
disposeReactiveObxVariableListener();
}
if (PlatformInfo.isIOS) {
_iosNotificationManager?.dispose();
@@ -47,6 +47,7 @@ class SearchController extends BaseController with DateRangePickerMixin {
final listFilterOnSuggestionForm = RxList<QuickSearchFilter>();
final simpleSearchIsActivated = RxBool(false);
final advancedSearchIsActivated = RxBool(false);
final isSearchInputFocused = RxBool(false);
SearchQuery? get searchQuery => searchEmailFilter.value.text;
@@ -59,6 +60,17 @@ class SearchController extends BaseController with DateRangePickerMixin {
this._getAllRecentSearchLatestInteractor,
);
@override
void onInit() {
super.onInit();
searchFocus.addListener(_onSearchFocusChanged);
}
void _onSearchFocusChanged() {
log('SearchController::_onSearchFocusChanged: ${searchFocus.hasFocus}');
isSearchInputFocused.value = searchFocus.hasFocus;
}
void openAdvanceSearch() {
isAdvancedSearchViewOpen.value = true;
}
@@ -322,6 +334,7 @@ class SearchController extends BaseController with DateRangePickerMixin {
@override
void onClose() {
searchInputController.dispose();
searchFocus.removeListener(_onSearchFocusChanged);
searchFocus.dispose();
super.onClose();
}
@@ -0,0 +1,49 @@
import 'package:core/utils/app_logger.dart';
import 'package:get/get_rx/src/rx_workers/rx_workers.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/action/thread_detail_ui_action.dart';
extension HandleReactiveObxVariableExtension on MailboxDashBoardController {
void registerReactiveObxVariableListener() {
advancedSearchVisibleWorker = ever(
searchController.isAdvancedSearchViewOpen,
_onAdvancedSearchVisibleChanged
);
searchInputFocusWorker = ever(
searchController.isSearchInputFocused,
_onSearchInputFocusChanged
);
}
void _onAdvancedSearchVisibleChanged(bool visible) {
log('$runtimeType::_onAdvancedSearchVisibleChanged: visible is $visible | isEmailOpened = $isEmailOpened');
if (isEmailOpened) {
_performThreadDetailUIActionWhenEmailOpened(visible);
}
}
void _onSearchInputFocusChanged(bool focused) {
log('$runtimeType::_onSearchInputFocusChanged: focused is $focused | isEmailOpened = $isEmailOpened');
if (isEmailOpened) {
_performThreadDetailUIActionWhenEmailOpened(focused);
}
}
void _performThreadDetailUIActionWhenEmailOpened(bool focused) {
final emailAction = focused
? ClearMailViewKeyboardShortcutFocusAction()
: ReclaimMailViewKeyboardShortcutFocusAction();
dispatchThreadDetailUIAction(emailAction);
}
void disposeReactiveObxVariableListener() {
advancedSearchVisibleWorker?.dispose();
advancedSearchVisibleWorker = null;
searchInputFocusWorker?.dispose();
searchInputFocusWorker = null;
}
}
@@ -11,8 +11,10 @@ import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart'
import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart';
import 'package:tmail_ui_user/features/email/presentation/extensions/composer_arguments_extension.dart';
import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart';
import 'package:tmail_ui_user/features/sending_queue/presentation/model/sending_email_arguments.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/action/thread_detail_ui_action.dart';
import 'package:tmail_ui_user/main/routes/app_routes.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
@@ -111,6 +113,9 @@ extension OpenAndCloseComposerExtension on MailboxDashBoardController {
twakeAppManager.setHasComposer(false);
}
dispatchThreadDetailUIAction(ReclaimMailViewKeyboardShortcutFocusAction());
dispatchAction(ReclaimMailListKeyboardShortcutFocusAction());
_handleResultAfterCloseComposer(result);
if (composerId != null) {
@@ -68,137 +68,141 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView {
ResponsiveWidget(
responsiveUtils: controller.responsiveUtils,
desktop: Scaffold(
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
color: AppColor.colorBgDesktop,
child: Column(children: [
FutureBuilder(
future: CozyConfigManager().isInsideCozy,
builder: (context, snapshot) {
if (snapshot.data == true) {
return const SizedBox.shrink();
backgroundColor: AppColor.colorBgDesktop,
body: Column(
children: [
FutureBuilder(
future: CozyConfigManager().isInsideCozy,
builder: (context, snapshot) {
if (snapshot.data == true) {
return const SizedBox.shrink();
}
return Obx(() {
final accountId = controller.accountId.value;
String accountDisplayName = controller.ownEmailAddress.value;
final contactSupportCapability = accountId != null
? controller.sessionCurrent?.getContactSupportCapability(accountId)
: null;
if (accountDisplayName.trim().isEmpty) {
accountDisplayName = controller
.sessionCurrent
?.getOwnEmailAddressOrUsername() ?? '';
}
return Obx(() {
final accountId = controller.accountId.value;
String accountDisplayName = controller.ownEmailAddress.value;
final contactSupportCapability = accountId != null
? controller.sessionCurrent?.getContactSupportCapability(accountId)
: null;
if (accountDisplayName.trim().isEmpty) {
accountDisplayName = controller
.sessionCurrent
?.getOwnEmailAddressOrUsername() ?? '';
}
return NavigationBarWidget(
imagePaths: controller.imagePaths,
accountId: accountId,
ownEmailAddress: accountDisplayName,
contactSupportCapability: contactSupportCapability,
searchForm: SearchInputFormWidget(),
appGridController:
controller.appGridDashboardController,
settingActionTypes: ProfileSettingActionType.values,
onTapApplicationLogoAction:
controller.redirectToInboxAction,
onTapContactSupportAction: (contactSupport) =>
controller.onGetHelpOrReportBug(contactSupport),
onProfileSettingActionTypeClick: (actionType) =>
controller.handleProfileSettingActionTypeClick(
context: context,
actionType: actionType,
),
);
});
},
),
Expanded(child: Row(children: [
Column(children: [
ComposeButtonWidget(
return NavigationBarWidget(
imagePaths: controller.imagePaths,
onTapAction: () =>
controller.openComposer(ComposerArguments()),
accountId: accountId,
ownEmailAddress: accountDisplayName,
contactSupportCapability: contactSupportCapability,
searchForm: SearchInputFormWidget(),
appGridController:
controller.appGridDashboardController,
settingActionTypes: ProfileSettingActionType.values,
onTapApplicationLogoAction:
controller.redirectToInboxAction,
onTapContactSupportAction: (contactSupport) =>
controller.onGetHelpOrReportBug(contactSupport),
onProfileSettingActionTypeClick: (actionType) =>
controller.handleProfileSettingActionTypeClick(
context: context,
actionType: actionType,
),
);
});
},
),
Expanded(
child: Row(
children: [
Column(
children: [
ComposeButtonWidget(
imagePaths: controller.imagePaths,
onTapAction: () =>
controller.openComposer(ComposerArguments()),
),
Expanded(child: SizedBox(
width: ResponsiveUtils.defaultSizeMenu,
child: Obx(() {
if (controller.searchMailboxActivated.isTrue) {
return const SearchMailboxView(
backgroundColor: AppColor.colorBgDesktop
);
} else {
return MailboxView();
}
})
))
],
),
Expanded(child: SizedBox(
width: ResponsiveUtils.defaultSizeMenu,
child: Obx(() {
if (controller.searchMailboxActivated.isTrue) {
return const SearchMailboxView(
backgroundColor: AppColor.colorBgDesktop
);
} else {
return MailboxView();
}
})
))
]),
Expanded(child: Column(children: [
const SizedBox(height: 16),
FutureBuilder(
future: CozyConfigManager().isInsideCozy,
builder: (context, snapshot) {
if (snapshot.data != true) {
return const SizedBox.shrink();
}
Expanded(
child: Column(
children: [
const SizedBox(height: 16),
FutureBuilder(
future: CozyConfigManager().isInsideCozy,
builder: (context, snapshot) {
if (snapshot.data != true) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.4,
height: 44,
child: SearchInputFormWidget(
fontSize: 15,
contentPadding: const EdgeInsets.only(bottom: 4),
),
),
const Spacer(),
Obx(() {
String accountDisplayName =
controller.ownEmailAddress.value;
if (accountDisplayName.trim().isEmpty) {
accountDisplayName = controller
.sessionCurrent
?.getOwnEmailAddressOrUsername() ?? '';
}
return ProfileSettingIcon(
ownEmailAddress: accountDisplayName,
settingActionTypes: ProfileSettingActionType.values,
onProfileSettingActionTypeClick: (actionType) =>
controller.handleProfileSettingActionTypeClick(
context: context,
actionType: actionType,
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.4,
height: 44,
child: SearchInputFormWidget(
fontSize: 15,
contentPadding: const EdgeInsets.only(bottom: 4),
),
),
isInsideCozy: true,
);
}),
],
const Spacer(),
Obx(() {
String accountDisplayName =
controller.ownEmailAddress.value;
if (accountDisplayName.trim().isEmpty) {
accountDisplayName = controller
.sessionCurrent
?.getOwnEmailAddressOrUsername() ?? '';
}
return ProfileSettingIcon(
ownEmailAddress: accountDisplayName,
settingActionTypes: ProfileSettingActionType.values,
onProfileSettingActionTypeClick: (actionType) =>
controller.handleProfileSettingActionTypeClick(
context: context,
actionType: actionType,
),
isInsideCozy: true,
);
}),
],
),
);
}
),
);
}
),
Obx(() => RecoverDeletedMessageLoadingBannerWidget(
isLoading: controller.isRecoveringDeletedMessage.value,
horizontalLoadingWidget: horizontalLoadingWidget,
responsiveUtils: controller.responsiveUtils,
)),
Obx(() => MarkMailboxAsReadLoadingBanner(
viewState: controller.viewStateMailboxActionProgress.value,
)),
Obx(() {
final spamController = controller.spamReportController;
Obx(() => RecoverDeletedMessageLoadingBannerWidget(
isLoading: controller.isRecoveringDeletedMessage.value,
horizontalLoadingWidget: horizontalLoadingWidget,
responsiveUtils: controller.responsiveUtils,
)),
Obx(() => MarkMailboxAsReadLoadingBanner(
viewState: controller.viewStateMailboxActionProgress.value,
)),
Obx(() {
final spamController = controller.spamReportController;
final isSpamReportDisabled = spamController.spamReportState.value == SpamReportState.disabled;
final isSpamReportDisabled = spamController.spamReportState.value == SpamReportState.disabled;
final isSpamFolderSelected = controller
.selectedMailbox
.value
?.isSpam == true;
final isSpamFolderSelected = controller
.selectedMailbox
.value
?.isSpam == true;
final isPresentationSpamMailboxIsNull = spamController.presentationSpamMailbox.value == null;
final isPresentationSpamMailboxIsNull = spamController.presentationSpamMailbox.value == null;
final isEmailOpened = controller.dashboardRoute.value == DashboardRoutes.threadDetailed;
@@ -209,91 +213,96 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView {
return const SizedBox.shrink();
}
return ReportMessageBanner(
imagePaths: controller.imagePaths,
message: AppLocalizations
.of(context)
.countMessageInSpam(
spamController.numberOfUnreadSpamEmails,
),
positiveName: AppLocalizations.of(context).view,
isDesktop: controller
.responsiveUtils
.isDesktop(context),
margin: SpamReportBannerWebStyles.bannerMargin,
onPositiveAction: spamController.openMailbox,
onNegativeAction: () =>
spamController.dismissSpamReportAction(context),
);
}),
QuotasBannerWidget(),
_buildVacationNotificationMessage(context),
Obx(() {
final selectedMailbox = controller
.selectedMailbox
.value;
return ReportMessageBanner(
imagePaths: controller.imagePaths,
message: AppLocalizations
.of(context)
.countMessageInSpam(
spamController.numberOfUnreadSpamEmails,
),
positiveName: AppLocalizations.of(context).view,
isDesktop: controller
.responsiveUtils
.isDesktop(context),
margin: SpamReportBannerWebStyles.bannerMargin,
onPositiveAction: spamController.openMailbox,
onNegativeAction: () =>
spamController.dismissSpamReportAction(context),
);
}),
QuotasBannerWidget(),
_buildVacationNotificationMessage(context),
Obx(() {
final selectedMailbox = controller
.selectedMailbox
.value;
bool showTrashBanner = controller
.isEmptyTrashBannerEnabledOnWeb(
context,
selectedMailbox,
);
bool showSpamBanner = controller
.isEmptySpamBannerEnabledOnWeb(
context,
selectedMailbox,
);
bool showTrashBanner = controller
.isEmptyTrashBannerEnabledOnWeb(
context,
selectedMailbox,
);
bool showSpamBanner = controller
.isEmptySpamBannerEnabledOnWeb(
context,
selectedMailbox,
);
if (showTrashBanner) {
return CleanMessagesBanner(
responsiveUtils: controller.responsiveUtils,
message: AppLocalizations
.of(context)
.message_delete_all_email_in_trash_button,
positiveAction: AppLocalizations
.of(context)
.empty_trash_now,
onPositiveAction: controller.emptyTrashAction,
margin: const EdgeInsetsDirectional.only(
bottom: 8,
end: 16,
),
);
} else if (showSpamBanner) {
return CleanMessagesBanner(
responsiveUtils: controller.responsiveUtils,
message: AppLocalizations
.of(context)
.bannerDeleteAllSpamEmailsMessage,
positiveAction: AppLocalizations
.of(context)
.deleteAllSpamEmailsNow,
onPositiveAction: () =>
controller.openDialogEmptySpamFolder(context),
margin: const EdgeInsetsDirectional.only(
bottom: 8,
end: 16,
),
);
} else {
return const SizedBox.shrink();
}
}),
_buildListButtonQuickSearchFilter(context),
Expanded(child: Obx(() {
switch(controller.dashboardRoute.value) {
case DashboardRoutes.thread:
return _buildThreadViewForWebDesktop(context);
case DashboardRoutes.threadDetailed:
return const ThreadDetailView();
default:
return const SizedBox.shrink();
}
}))
]))
]))
]),
),
if (showTrashBanner) {
return CleanMessagesBanner(
responsiveUtils: controller.responsiveUtils,
message: AppLocalizations
.of(context)
.message_delete_all_email_in_trash_button,
positiveAction: AppLocalizations
.of(context)
.empty_trash_now,
onPositiveAction: controller.emptyTrashAction,
margin: const EdgeInsetsDirectional.only(
bottom: 8,
end: 16,
),
);
} else if (showSpamBanner) {
return CleanMessagesBanner(
responsiveUtils: controller.responsiveUtils,
message: AppLocalizations
.of(context)
.bannerDeleteAllSpamEmailsMessage,
positiveAction: AppLocalizations
.of(context)
.deleteAllSpamEmailsNow,
onPositiveAction: () =>
controller.openDialogEmptySpamFolder(context),
margin: const EdgeInsetsDirectional.only(
bottom: 8,
end: 16,
),
);
} else {
return const SizedBox.shrink();
}
}),
_buildListButtonQuickSearchFilter(context),
Expanded(
child: Obx(() {
switch(controller.dashboardRoute.value) {
case DashboardRoutes.thread:
return _buildThreadViewForWebDesktop(context);
case DashboardRoutes.threadDetailed:
return const ThreadDetailView();
default:
return const SizedBox.shrink();
}
}),
)
],
),
)
],
),
)
],
),
),
tabletLarge: Obx(() {