Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/libs/API/parameters/MarkAllMessagesAsReadParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type MarkAllMessagesAsReadParams = {
reportIDList: string[];
};

export default MarkAllMessagesAsReadParams;
1 change: 1 addition & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/libs/KeyboardShortcut/getKeyEventModifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,6 +23,9 @@ function getKeyEventModifiers(event: KeyCommandEvent): string[] {
if (event.modifierFlags === keyModifierShiftCommand) {
return ['META', 'Shift'];
}
if (event.modifierFlags === keyModifierShift) {
return ['Shift'];
}

return [];
}
Expand Down
2 changes: 1 addition & 1 deletion src/libs/KeyboardShortcut/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type EventHandler = {
// Handlers for the various keyboard listeners we set up
const eventHandlers: Record<string, EventHandler[]> = {};

type ShortcutModifiers = readonly ['CTRL'] | readonly ['CTRL', 'SHIFT'] | readonly [];
type ShortcutModifiers = readonly ['CTRL'] | readonly ['SHIFT'] | readonly ['CTRL', 'SHIFT'] | readonly [];

type Shortcut = {
displayName: string;
Expand Down
10 changes: 10 additions & 0 deletions src/libs/Navigation/AppNavigator/AuthScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
};

Expand Down
63 changes: 63 additions & 0 deletions src/libs/actions/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
InviteToGroupChatParams,
InviteToRoomParams,
LeaveRoomParams,
MarkAllMessagesAsReadParams,
MarkAsExportedParams,
MarkAsUnreadParams,
MoveIOUReportToExistingPolicyParams,
Expand Down Expand Up @@ -144,6 +145,7 @@ import {
isIOUReportUsingReport,
isMoneyRequestReport,
isSelfDM,
isUnread,
isValidReportIDFromPath,
prepareOnboardingOnyxData,
} from '@libs/ReportUtils';
Expand Down Expand Up @@ -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<string, PartialReport> = {};
const failureReports: Record<string, PartialReport> = {};
const reportIDList: string[] = [];
Object.values(allReports ?? {}).forEach((report) => {
Comment thread
puneetlath marked this conversation as resolved.
if (!report) {
return;
}

const oneTransactionThreadReportID = ReportActionsUtils.getOneTransactionThreadReportID(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bernhardoj Is there a reason for doing this? Is there a problem if we mark both report and expense as read in one-transaction view?

@bernhardoj bernhardoj Nov 2, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you mark the transaction thread as read, then oneTransactionThreadReportID will be undefined.

// If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report.
if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) {
return;
}

Getting the transaction thread for a one-transaction report is useful when getting the lastVisibleActionCreated. We want to get the bigger lastVisibleActionCreated between the expense report and the transaction thread because a one-transaction report also contains the transaction thread report actions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bernhardoj Can you mark the code where I can identify this case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the code.

App/src/libs/ReportUtils.ts

Lines 8657 to 8667 in a0f0159

// We need oneTransactionThreadReport to get the correct last visible action created
function isUnread(report: OnyxEntry<Report>, oneTransactionThreadReport: OnyxEntry<Report>, isReportArchived: boolean | undefined): boolean {
if (!report) {
return false;
}
if (isEmptyReport(report, isReportArchived)) {
return false;
}
// lastVisibleActionCreated and lastReadTime are both datetime strings and can be compared directly
const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, oneTransactionThreadReport);

App/src/libs/ReportUtils.ts

Lines 11675 to 11679 in a0f0159

function getReportLastVisibleActionCreated(report: OnyxEntry<Report>, oneTransactionThreadReport: OnyxEntry<Report>) {
const reportLastVisibleActionCreated = report?.lastVisibleActionCreated ?? '';
const threadLastVisibleActionCreated = oneTransactionThreadReport?.lastVisibleActionCreated ?? '';
return reportLastVisibleActionCreated > threadLastVisibleActionCreated ? reportLastVisibleActionCreated : threadLastVisibleActionCreated;
}

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
*/
Expand Down Expand Up @@ -5585,6 +5647,7 @@ export {
openReportFromDeepLink,
openRoomMembersPage,
readNewestAction,
markAllMessagesAsRead,
removeFromGroupChat,
removeFromRoom,
resolveActionableMentionWhisper,
Expand Down
45 changes: 45 additions & 0 deletions tests/actions/ReportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<OnyxTypes.Report>(
(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<boolean>((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();
Expand Down