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
@@ -51,4 +51,8 @@ class LoadThreadDetailAfterSelectedEmailAction extends ThreadDetailUIAction {
@override
List<Object?> get props => [threadId, isSentMailbox];
}
}
class ReclaimMailViewKeyboardShortcutFocusAction extends ThreadDetailUIAction {}
class ClearMailViewKeyboardShortcutFocusAction extends ThreadDetailUIAction {}
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
extension CloseThreadDetailAction on ThreadDetailController {
void closeThreadDetailAction(BuildContext? context) {
void closeThreadDetailAction() {
if (isSearchRunning) {
mailboxDashBoardController.dispatchRoute(DashboardRoutes.searchEmail);
} else {
@@ -1,12 +1,13 @@
import 'package:core/utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:model/email/email_in_thread_status.dart';
import 'package:model/extensions/presentation_email_extension.dart';
import 'package:tmail_ui_user/features/email/presentation/email_view.dart';
import 'package:model/email/email_in_thread_status.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/load_more_thread_detail_emails.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_load_more_segments.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_on_email_action_click.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_open_email_address_detail_action.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_load_more_segments.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/toggle_thread_detail_collapse_expand.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/widgets/thread_detail_collapsed_email.dart';
@@ -78,6 +79,9 @@ extension GetThreadDetailEmailViews on ThreadDetailController {
onToggleThreadDetailCollapseExpand: () {
toggleThreadDetailCollapseExpand(presentationEmail);
},
onIFrameKeyboardShortcutAction: keyboardShortcutFocusNode != null
? onIFrameKeyboardShortcutAction
: null,
scrollController: scrollController,
);
}
@@ -91,6 +95,9 @@ extension GetThreadDetailEmailViews on ThreadDetailController {
onToggleThreadDetailCollapseExpand: () {
toggleThreadDetailCollapseExpand(presentationEmail);
},
onIFrameKeyboardShortcutAction: keyboardShortcutFocusNode != null
? onIFrameKeyboardShortcutAction
: null,
scrollController: scrollController,
);
}).toList();
@@ -0,0 +1,104 @@
import 'dart:async';
import 'package:core/presentation/views/shortcut/key_shortcut.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:model/email/presentation_email.dart';
import 'package:rxdart/rxdart.dart';
import 'package:tmail_ui_user/features/base/shortcut/app_shortcut_manager.dart';
import 'package:tmail_ui_user/features/base/shortcut/mail/mail_view_action_shortcut_type.dart';
import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/key_shortcut_extension.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/model/mail_view_shortcut_action_view_event.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
extension HandleMailShortcutActionsExtension on ThreadDetailController {
void onKeyDownEventAction(KeyEvent event) {
final shortcutType = AppShortcutManager.getMailViewActionFromEvent(event);
log('$runtimeType::onKeyDownEventAction:🔥Shortcut triggered: $shortcutType');
if (shortcutType == null) return;
handleMailShortcutAction(shortcutType);
}
PresentationEmail? get currentExpandedEmail {
final expandedEmailId = currentExpandedEmailId.value;
if (expandedEmailId == null) return null;
final expandedPresentationEmail = emailIdsPresentation[expandedEmailId];
if (expandedPresentationEmail == null) return null;
return expandedPresentationEmail;
}
void onKeyboardShortcutInit() {
if (PlatformInfo.isWeb) {
keyboardShortcutFocusNode = FocusNode();
keyboardShortcutFocusNode?.addListener(() {
log('$runtimeType::onInit:keyboardShortcutFocusNode: ${keyboardShortcutFocusNode?.hasFocus}');
});
shortcutActionEventController =
StreamController<MailViewShortcutActionViewEvent>();
shortcutActionEventSubscription = shortcutActionEventController
?.stream
.debounceTime(const Duration(milliseconds: 300))
.listen(_onShortcutActionViewEvent);
}
}
void _onShortcutActionViewEvent(MailViewShortcutActionViewEvent event) {
mailboxDashBoardController.dispatchEmailUIAction(
TriggerMailViewKeyboardShortcutAction(
event.actionType,
event.presentationEmail,
),
);
}
void onKeyboardShortcutDispose() {
if (PlatformInfo.isWeb) {
keyboardShortcutFocusNode?.dispose();
shortcutActionEventSubscription?.cancel();
shortcutActionEventController?.close();
}
}
void handleMailShortcutAction(MailViewActionShortcutType shortcutType) {
log('$runtimeType::handleMailShortcutAction:🔥ShortcutType: $shortcutType');
final expandedEmail = currentExpandedEmail;
if (expandedEmail == null) return;
final emailActionType = shortcutType.getEmailActionType(
currentEmail: expandedEmail,
ownerEmailAddress: mailboxDashBoardController.ownEmailAddress.value,
);
log('$runtimeType::onKeyDownEventAction:🔥EmailActionType: $emailActionType');
if (emailActionType == null) return;
shortcutActionEventController?.add(
MailViewShortcutActionViewEvent(emailActionType, expandedEmail),
);
}
void clearMailShortcutFocus() {
if (keyboardShortcutFocusNode?.hasFocus == true) {
keyboardShortcutFocusNode?.unfocus();
}
}
void refocusMailShortcutFocus() {
if (keyboardShortcutFocusNode?.hasFocus == false) {
keyboardShortcutFocusNode?.requestFocus();
}
}
void onIFrameKeyboardShortcutAction(KeyShortcut keyShortcut) {
log('$runtimeType::onIFrameKeyboardShortcutAction: ${keyShortcut.toString()}');
final shortcutType = keyShortcut.mailViewActionShortcutType;
log('$runtimeType::onIFrameKeyboardShortcutAction:🔥MailViewActionShortcutType: $shortcutType');
if (shortcutType == null) return;
handleMailShortcutAction(shortcutType);
}
}
@@ -5,7 +5,6 @@ import 'package:model/email/read_actions.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/toggle_thread_detail_collapse_expand.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
extension HandleMarkMultipleEmailsReadSuccess on ThreadDetailController {
void handleMarkMultipleEmailsReadSuccess(
@@ -17,7 +16,7 @@ extension HandleMarkMultipleEmailsReadSuccess on ThreadDetailController {
if (readActions == ReadActions.markAsRead) return;
if (emailsInThreadDetailInfo.length == 1) {
closeThreadDetailAction(currentContext);
closeThreadDetailAction();
return;
}
@@ -14,7 +14,6 @@ import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
extension HandleRefreshThreadDetailAction on ThreadDetailController {
void handleRefreshThreadDetailAction(
@@ -86,7 +85,7 @@ extension HandleRefreshThreadDetailAction on ThreadDetailController {
);
if (afterRefreshedEmailIds.isEmpty) {
closeThreadDetailAction(currentContext);
closeThreadDetailAction();
return;
}
@@ -0,0 +1,22 @@
import 'package:core/presentation/views/shortcut/key_shortcut.dart';
import 'package:tmail_ui_user/features/base/shortcut/mail/mail_view_action_shortcut_type.dart';
extension KeyShortcutExtension on KeyShortcut {
MailViewActionShortcutType? get mailViewActionShortcutType {
if (matches('r')) {
return MailViewActionShortcutType.reply;
} else if (matches('r', shift: true)) {
return MailViewActionShortcutType.replyAll;
} else if (matches('f')) {
return MailViewActionShortcutType.forward;
} else if (matches('u')) {
return MailViewActionShortcutType.markAsUnread;
} else if (matches('n')) {
return MailViewActionShortcutType.newMessage;
} else if (matches('backspace') || matches('delete')) {
return MailViewActionShortcutType.delete;
}
return null;
}
}
@@ -5,7 +5,6 @@ import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_read_sta
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_current_emails_flags_extension.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
extension MarkCollapsedEmailReadSuccess on ThreadDetailController {
void markCollapsedEmailReadSuccess(MarkAsEmailReadSuccess success) {
@@ -41,7 +40,7 @@ extension MarkCollapsedEmailReadSuccess on ThreadDetailController {
) {
if (success.readActions == ReadActions.markAsRead) return;
if (emailIdsPresentation.length == 1) {
closeThreadDetailAction(currentContext);
closeThreadDetailAction();
} else {
emailIdsPresentation[success.emailId] = emailIdsPresentation
[success.emailId]
@@ -72,7 +72,7 @@ extension OnThreadDetailActionClick on ThreadDetailController {
case EmailActionType.deletePermanently:
mailboxDashBoardController.permanentDeleteMultipleEmailInThreadDetail(
emailsInThreadDetailInfo,
onConfirm: () => closeThreadDetailAction(currentContext),
onConfirm: closeThreadDetailAction,
);
break;
case EmailActionType.moveToMailbox:
@@ -183,7 +183,7 @@ extension OnThreadDetailActionClick on ThreadDetailController {
}
void _moveToMailbox(MailboxId mailboxId, EmailActionType emailActionType) {
closeThreadDetailAction(currentContext);
closeThreadDetailAction();
mailboxDashBoardController.moveMultipleEmailInThreadDetail(
emailsInThreadDetailInfo,
destinationMailboxId: mailboxId,
@@ -20,7 +20,7 @@ extension ThreadDetailOnSelectedEmailUpdated on ThreadDetailController {
BuildContext? context,
) {
if (selectedEmail?.id == null) {
closeThreadDetailAction(context);
closeThreadDetailAction();
return;
}
@@ -0,0 +1,13 @@
import 'package:core/presentation/state/success.dart';
import 'package:model/email/email_action_type.dart';
import 'package:model/email/presentation_email.dart';
class MailViewShortcutActionViewEvent extends ViewEvent {
final EmailActionType actionType;
final PresentationEmail presentationEmail;
MailViewShortcutActionViewEvent(this.actionType, this.presentationEmail);
@override
List<Object?> get props => [actionType, presentationEmail];
}
@@ -50,9 +50,11 @@ import 'package:tmail_ui_user/features/thread_detail/presentation/extension/hand
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_thread_by_id_failure.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mark_multiple_emails_read_success.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_refresh_thread_detail_action.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/thread_detail_on_selected_email_updated.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/model/mail_view_shortcut_action_view_event.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_manager.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
@@ -124,6 +126,9 @@ class ThreadDetailController extends BaseController {
CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor;
bool loadThreadOnThreadChanged = false;
bool isDisplayAllAttachments = false;
FocusNode? keyboardShortcutFocusNode;
StreamController<MailViewShortcutActionViewEvent>? shortcutActionEventController;
StreamSubscription<MailViewShortcutActionViewEvent>? shortcutActionEventSubscription;
AccountId? get accountId => mailboxDashBoardController.accountId.value;
Session? get session => mailboxDashBoardController.sessionCurrent;
@@ -197,6 +202,10 @@ class ThreadDetailController extends BaseController {
threadId: action.threadId,
isSentMailbox: action.isSentMailbox,
);
} else if (action is ReclaimMailViewKeyboardShortcutFocusAction) {
refocusMailShortcutFocus();
} else if (action is ClearMailViewKeyboardShortcutFocusAction) {
clearMailShortcutFocus();
}
// Reset [threadDetailUIAction] to original value
mailboxDashBoardController.dispatchThreadDetailUIAction(
@@ -212,6 +221,8 @@ class ThreadDetailController extends BaseController {
);
}
});
onKeyboardShortcutInit();
}
bool _validateLoadThread(ThreadId? threadId) {
@@ -295,4 +306,10 @@ class ThreadDetailController extends BaseController {
}
super.handleFailureViewState(failure);
}
@override
void onClose() {
onKeyboardShortcutDispose();
super.onClose();
}
}
@@ -7,6 +7,7 @@ import 'package:dartz/dartz.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wrapper.dart';
import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart';
import 'package:tmail_ui_user/features/email/presentation/styles/email_view_app_bar_widget_styles.dart';
import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_bottom_bar_widget.dart';
@@ -17,6 +18,7 @@ import 'package:tmail_ui_user/features/thread_detail/presentation/extension/clos
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/get_thread_detail_action_status.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/get_thread_details_email_views.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/on_thread_detail_action_click.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_mail_shortcut_actions_extension.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/on_thread_page_changed.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/extension/parsing_email_opened_properties_extension.dart';
import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart';
@@ -209,7 +211,7 @@ class ThreadDetailView extends GetWidget<ThreadDetailController> {
}
if (controller.responsiveUtils.isDesktop(context)) {
return Container(
bodyWidget = Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(20)),
color: Colors.white,
@@ -219,7 +221,17 @@ class ThreadDetailView extends GetWidget<ThreadDetailController> {
child: bodyWidget,
);
} else {
return ColoredBox(color: Colors.white, child: bodyWidget);
bodyWidget = ColoredBox(color: Colors.white, child: bodyWidget);
}
if (PlatformInfo.isWeb && controller.keyboardShortcutFocusNode != null) {
return KeyboardHandlerWrapper(
onKeyDownEventAction: controller.onKeyDownEventAction,
focusNode: controller.keyboardShortcutFocusNode!,
child: bodyWidget,
);
} else {
return bodyWidget;
}
}
@@ -35,7 +35,7 @@ class ThreadDetailAppBar extends StatelessWidget {
final ResponsiveUtils responsiveUtils;
final ImagePaths imagePaths;
final bool isSearchRunning;
final void Function(BuildContext context) closeThreadDetailAction;
final VoidCallback closeThreadDetailAction;
final bool threadActionReady;
final bool threadDetailIsStarred;
final bool isThreadDetailEnabled;
@@ -99,7 +99,7 @@ class ThreadDetailAppBar extends StatelessWidget {
builder: (_, constraints) {
Widget backButton = EmailViewBackButton(
imagePaths: imagePaths,
onBackAction: () => closeThreadDetailAction(context),
onBackAction: closeThreadDetailAction,
mailboxContain: mailboxContain,
isSearchActivated: isSearchRunning,
maxWidth: constraints.maxWidth,
@@ -164,7 +164,7 @@ class ThreadDetailAppBar extends StatelessWidget {
return PopScope(
canPop: false,
onPopInvokedWithResult: (_, __) {
closeThreadDetailAction(context);
closeThreadDetailAction();
},
child: child,
);