diff --git a/src/CONST.ts b/src/CONST.ts index 599f38122feb..66f74bf796f8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -37,6 +37,7 @@ const EXPENSIFY_POLICY_DOMAIN_EXTENSION = '.exfy'; const keyModifierControl = KeyCommand?.constants?.keyModifierControl ?? 'keyModifierControl'; const keyModifierCommand = KeyCommand?.constants?.keyModifierCommand ?? 'keyModifierCommand'; +const keyModifierShift = KeyCommand?.constants?.keyModifierShift ?? 'keyModifierShift'; const keyModifierShiftControl = KeyCommand?.constants?.keyModifierShiftControl ?? 'keyModifierShiftControl'; const keyModifierShiftCommand = KeyCommand?.constants?.keyModifierShiftCommand ?? 'keyModifierShiftCommand'; const keyInputEscape = KeyCommand?.constants?.keyInputEscape ?? 'keyInputEscape'; @@ -891,6 +892,14 @@ const CONST = { }, }, KEYBOARD_SHORTCUTS: { + MARK_ALL_MESSAGES_AS_READ: { + descriptionKey: 'markAllMessagesAsRead', + shortcutKey: 'Escape', + modifiers: ['SHIFT'], + trigger: { + DEFAULT: {input: keyInputEscape, modifierFlags: keyModifierShift}, + }, + }, SEARCH: { descriptionKey: 'search', shortcutKey: 'K', diff --git a/src/languages/en.ts b/src/languages/en.ts index 43317eabbf92..673271df710e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5249,6 +5249,7 @@ const translations = { subtitle: 'Save time with these handy keyboard shortcuts:', shortcuts: { openShortcutDialog: 'Opens the keyboard shortcuts dialog', + markAllMessagesAsRead: 'Mark all messages as read', escape: 'Escape dialogs', search: 'Open search dialog', newChat: 'New chat screen', diff --git a/src/languages/es.ts b/src/languages/es.ts index cb091be80ffe..251cd9da2ebe 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5303,6 +5303,7 @@ const translations = { subtitle: 'Ahorra tiempo con estos atajos de teclado:', shortcuts: { openShortcutDialog: 'Abre el cuadro de diálogo de métodos abreviados de teclado', + markAllMessagesAsRead: 'Marcar todos los mensajes como leídos', escape: 'Diálogos de escape', search: 'Abrir diálogo de búsqueda', newChat: 'Nueva pantalla de chat', diff --git a/src/libs/API/parameters/MarkAllMessagesAsReadParams.ts b/src/libs/API/parameters/MarkAllMessagesAsReadParams.ts new file mode 100644 index 000000000000..b1f21c8c056b --- /dev/null +++ b/src/libs/API/parameters/MarkAllMessagesAsReadParams.ts @@ -0,0 +1,5 @@ +type MarkAllMessagesAsReadParams = { + reportIDList: string[]; +}; + +export default MarkAllMessagesAsReadParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 508975998af5..46ad348f73d9 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -99,6 +99,7 @@ export type {default as VerifyIdentityForBankAccountParams} from './VerifyIdenti export type {default as AnswerQuestionsForWalletParams} from './AnswerQuestionsForWalletParams'; export type {default as AddCommentOrAttachmentParams} from './AddCommentOrAttachmentParams'; export type {default as ReadNewestActionParams} from './ReadNewestActionParams'; +export type {default as MarkAllMessagesAsReadParams} from './MarkAllMessagesAsReadParams'; export type {default as MarkAsUnreadParams} from './MarkAsUnreadParams'; export type {default as TogglePinnedChatParams} from './TogglePinnedChatParams'; export type {default as DeleteCommentParams} from './DeleteCommentParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d0e9dda5c198..dbfe344ec699 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -106,6 +106,7 @@ const WRITE_COMMANDS = { RESET_BANK_ACCOUNT_SETUP: 'ResetBankAccountSetup', RESEND_VALIDATE_CODE: 'ResendValidateCode', READ_NEWEST_ACTION: 'ReadNewestAction', + MARK_ALL_MESSAGES_AS_READ: 'MarkAllMessagesAsRead', MARK_AS_UNREAD: 'MarkAsUnread', TOGGLE_PINNED_CHAT: 'TogglePinnedChat', DELETE_COMMENT: 'DeleteComment', @@ -576,6 +577,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RESET_BANK_ACCOUNT_SETUP]: Parameters.ResetBankAccountSetupParams; [WRITE_COMMANDS.RESEND_VALIDATE_CODE]: null; [WRITE_COMMANDS.READ_NEWEST_ACTION]: Parameters.ReadNewestActionParams; + [WRITE_COMMANDS.MARK_ALL_MESSAGES_AS_READ]: Parameters.MarkAllMessagesAsReadParams; [WRITE_COMMANDS.MARK_AS_UNREAD]: Parameters.MarkAsUnreadParams; [WRITE_COMMANDS.TOGGLE_PINNED_CHAT]: Parameters.TogglePinnedChatParams; [WRITE_COMMANDS.DELETE_COMMENT]: Parameters.DeleteCommentParams; diff --git a/src/libs/KeyboardShortcut/getKeyEventModifiers.ts b/src/libs/KeyboardShortcut/getKeyEventModifiers.ts index d6fbf80bc5bf..29d857293dd3 100644 --- a/src/libs/KeyboardShortcut/getKeyEventModifiers.ts +++ b/src/libs/KeyboardShortcut/getKeyEventModifiers.ts @@ -3,6 +3,7 @@ import type {KeyCommandEvent} from './bindHandlerToKeydownEvent/types'; const keyModifierControl = KeyCommand?.constants.keyModifierControl ?? 'keyModifierControl'; const keyModifierCommand = KeyCommand?.constants.keyModifierCommand ?? 'keyModifierCommand'; +const keyModifierShift = KeyCommand?.constants.keyModifierShift ?? 'keyModifierShift'; const keyModifierShiftControl = KeyCommand?.constants.keyModifierShiftControl ?? 'keyModifierShiftControl'; const keyModifierShiftCommand = KeyCommand?.constants.keyModifierShiftCommand ?? 'keyModifierShiftCommand'; @@ -22,6 +23,9 @@ function getKeyEventModifiers(event: KeyCommandEvent): string[] { if (event.modifierFlags === keyModifierShiftCommand) { return ['META', 'Shift']; } + if (event.modifierFlags === keyModifierShift) { + return ['Shift']; + } return []; } diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts index a6f62967e9ee..f2d806fd0fda 100644 --- a/src/libs/KeyboardShortcut/index.ts +++ b/src/libs/KeyboardShortcut/index.ts @@ -20,7 +20,7 @@ type EventHandler = { // Handlers for the various keyboard listeners we set up const eventHandlers: Record = {}; -type ShortcutModifiers = readonly ['CTRL'] | readonly ['CTRL', 'SHIFT'] | readonly []; +type ShortcutModifiers = readonly ['CTRL'] | readonly ['SHIFT'] | readonly ['CTRL', 'SHIFT'] | readonly []; type Shortcut = { displayName: string; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 79d1c44b5cd8..302b5c57f6a1 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -289,6 +289,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie const shortcutsOverviewShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUTS; const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; const chatShortcutConfig = CONST.KEYBOARD_SHORTCUTS.NEW_CHAT; + const markAllMessagesAsReadShortcutConfig = CONST.KEYBOARD_SHORTCUTS.MARK_ALL_MESSAGES_AS_READ; const isLoggingInAsNewUser = !!session?.email && SessionUtils.isLoggingInAsNewUser(currentUrl, session.email); // Sign out the current user if we're transitioning with a different user const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS); @@ -425,12 +426,21 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie true, ); + const unsubscribeMarkAllMessagesAsReadShortcut = KeyboardShortcut.subscribe( + markAllMessagesAsReadShortcutConfig.shortcutKey, + Report.markAllMessagesAsRead, + markAllMessagesAsReadShortcutConfig.descriptionKey, + markAllMessagesAsReadShortcutConfig.modifiers, + true, + ); + return () => { unsubscribeEscapeKey(); unsubscribeOnyxModal(); unsubscribeShortcutsOverviewShortcut(); unsubscribeSearchShortcut(); unsubscribeChatShortcut(); + unsubscribeMarkAllMessagesAsReadShortcut(); Session.cleanupSession(); }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6e7f4e5b245c..3fbf774e94e3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -27,6 +27,7 @@ import type { InviteToGroupChatParams, InviteToRoomParams, LeaveRoomParams, + MarkAllMessagesAsReadParams, MarkAsExportedParams, MarkAsUnreadParams, MoveIOUReportToExistingPolicyParams, @@ -144,6 +145,7 @@ import { isIOUReportUsingReport, isMoneyRequestReport, isSelfDM, + isUnread, isValidReportIDFromPath, prepareOnboardingOnyxData, } from '@libs/ReportUtils'; @@ -1607,6 +1609,66 @@ function readNewestAction(reportID: string | undefined, shouldResetUnreadMarker } } +function markAllMessagesAsRead() { + if (isAnonymousUser()) { + return; + } + + const newLastReadTime = DateUtils.getDBTimeWithSkew(); + + type PartialReport = { + lastReadTime: Report['lastReadTime'] | null; + }; + const optimisticReports: Record = {}; + const failureReports: Record = {}; + const reportIDList: string[] = []; + Object.values(allReports ?? {}).forEach((report) => { + if (!report) { + return; + } + + const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID( + report.reportID, + allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`], + ); + const oneTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`]; + if (!isUnread(report, oneTransactionThreadReport)) { + return; + } + + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`; + optimisticReports[reportKey] = {lastReadTime: newLastReadTime}; + failureReports[reportKey] = {lastReadTime: report.lastReadTime ?? null}; + reportIDList.push(report.reportID); + }); + + if (reportIDList.length === 0) { + return; + } + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT, + value: optimisticReports, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: ONYXKEYS.COLLECTION.REPORT, + value: failureReports, + }, + ]; + + const parameters: MarkAllMessagesAsReadParams = { + reportIDList, + }; + + API.write(WRITE_COMMANDS.MARK_ALL_MESSAGES_AS_READ, parameters, {optimisticData, failureData}); +} + /** * Sets the last read time on a report */ @@ -5585,6 +5647,7 @@ export { openReportFromDeepLink, openRoomMembersPage, readNewestAction, + markAllMessagesAsRead, removeFromGroupChat, removeFromRoom, resolveActionableMentionWhisper, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 1fe19e2ec832..38a4293310e8 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -19,6 +19,7 @@ import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import createCollection from '../utils/collections/createCollection'; import createRandomReportAction from '../utils/collections/reportActions'; import createRandomReport from '../utils/collections/reports'; import getIsUsingFakeTimers from '../utils/getIsUsingFakeTimers'; @@ -1618,6 +1619,50 @@ describe('actions/Report', () => { }); }); + describe('markAllMessagesAsRead', () => { + it('should mark all unread reports as read', async () => { + // Given a collection of 10 unread and read reports, where even-index report is unread + const currentTime = DateUtils.getDBTime(); + const reportCollections: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, OnyxTypes.Report> = createCollection( + (item) => `${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, + (index) => { + if (index % 2 === 0) { + return { + ...createRandomReport(index), + lastMessageText: 'test', + lastReadTime: DateUtils.subtractMillisecondsFromDateTime(currentTime, 1), + lastVisibleActionCreated: currentTime, + }; + } + return createRandomReport(index); + }, + 10, + ); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, reportCollections); + + // When mark all reports as read + Report.markAllMessagesAsRead(); + + await waitForBatchedUpdates(); + + // Then all report should be read + const isUnreadCollection = await Promise.all( + Object.values(reportCollections).map((report) => { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + callback: (reportVal) => { + Onyx.disconnect(connection); + resolve(ReportUtils.isUnread(reportVal, undefined)); + }, + }); + }); + }), + ); + expect(isUnreadCollection.some(Boolean)).toBe(false); + }); + }); + describe('updateDescription', () => { it('should not call UpdateRoomDescription API if the description is not changed', async () => { global.fetch = TestHelper.getGlobalFetchMock();