, fund
return;
}
- const {icon, iconSize} = getBankIcon(bankAccount?.accountData?.additionalData?.bankName ?? '', false);
+ const {icon, iconSize, iconHeight, iconWidth, iconStyles} = getBankIcon(bankAccount?.accountData?.additionalData?.bankName ?? '', false);
combinedPaymentMethods.push({
...bankAccount,
description: getPaymentMethodDescription(bankAccount?.accountType, bankAccount.accountData),
icon,
iconSize,
+ iconHeight,
+ iconWidth,
+ iconStyles,
});
});
Object.values(fundList).forEach((card) => {
- const {icon, iconSize} = getBankIcon(card?.accountData?.bank ?? '', true);
+ const {icon, iconSize, iconHeight, iconWidth, iconStyles} = getBankIcon(card?.accountData?.bank ?? '', true);
combinedPaymentMethods.push({
...card,
description: getPaymentMethodDescription(card?.accountType, card.accountData),
icon,
iconSize,
+ iconHeight,
+ iconWidth,
+ iconStyles,
});
});
diff --git a/src/libs/Performance.js b/src/libs/Performance.tsx
similarity index 52%
rename from src/libs/Performance.js
rename to src/libs/Performance.tsx
index 0207fd20c564..cfb5e258c9f8 100644
--- a/src/libs/Performance.js
+++ b/src/libs/Performance.tsx
@@ -1,39 +1,73 @@
-import _ from 'underscore';
-import lodashTransform from 'lodash/transform';
import React, {Profiler, forwardRef} from 'react';
import {Alert, InteractionManager} from 'react-native';
+import lodashTransform from 'lodash/transform';
+import isObject from 'lodash/isObject';
+import isEqual from 'lodash/isEqual';
+import {Performance as RNPerformance, PerformanceEntry, PerformanceMark, PerformanceMeasure} from 'react-native-performance';
+import {PerformanceObserverEntryList} from 'react-native-performance/lib/typescript/performance-observer';
import * as Metrics from './Metrics';
import getComponentDisplayName from './getComponentDisplayName';
import CONST from '../CONST';
import isE2ETestSession from './E2E/isE2ETestSession';
-/** @type {import('react-native-performance').Performance} */
-let rnPerformance;
+type WrappedComponentConfig = {id: string};
+
+type PerformanceEntriesCallback = (entry: PerformanceEntry) => void;
+
+type Phase = 'mount' | 'update';
+
+type WithRenderTraceHOC = >(WrappedComponent: React.ComponentType
) => React.ComponentType
>;
+
+type BlankHOC =
>(Component: React.ComponentType
) => React.ComponentType
;
+
+type SetupPerformanceObserver = () => void;
+type DiffObject = (object: Record, base: Record) => Record;
+type GetPerformanceMetrics = () => PerformanceEntry[];
+type PrintPerformanceMetrics = () => void;
+type MarkStart = (name: string, detail?: Record) => PerformanceMark | void;
+type MarkEnd = (name: string, detail?: Record) => PerformanceMark | void;
+type MeasureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => void;
+type MeasureTTI = (endMark: string) => void;
+type TraceRender = (id: string, phase: Phase, actualDuration: number, baseDuration: number, startTime: number, commitTime: number, interactions: Set) => PerformanceMeasure | void;
+type WithRenderTrace = ({id}: WrappedComponentConfig) => WithRenderTraceHOC | BlankHOC;
+type SubscribeToMeasurements = (callback: PerformanceEntriesCallback) => void;
+
+type PerformanceModule = {
+ diffObject: DiffObject;
+ setupPerformanceObserver: SetupPerformanceObserver;
+ getPerformanceMetrics: GetPerformanceMetrics;
+ printPerformanceMetrics: PrintPerformanceMetrics;
+ markStart: MarkStart;
+ markEnd: MarkEnd;
+ measureFailSafe: MeasureFailSafe;
+ measureTTI: MeasureTTI;
+ traceRender: TraceRender;
+ withRenderTrace: WithRenderTrace;
+ subscribeToMeasurements: SubscribeToMeasurements;
+};
+
+let rnPerformance: RNPerformance;
/**
* Deep diff between two objects. Useful for figuring out what changed about an object from one render to the next so
* that state and props updates can be optimized.
- *
- * @param {Object} object
- * @param {Object} base
- * @return {Object}
*/
-function diffObject(object, base) {
- function changes(obj, comparisonObject) {
+function diffObject(object: Record, base: Record): Record {
+ function changes(obj: Record, comparisonObject: Record): Record {
return lodashTransform(obj, (result, value, key) => {
- if (_.isEqual(value, comparisonObject[key])) {
+ if (isEqual(value, comparisonObject[key])) {
return;
}
// eslint-disable-next-line no-param-reassign
- result[key] = _.isObject(value) && _.isObject(comparisonObject[key]) ? changes(value, comparisonObject[key]) : value;
+ result[key] = isObject(value) && isObject(comparisonObject[key]) ? changes(value as Record, comparisonObject[key] as Record) : value;
});
}
return changes(object, base);
}
-const Performance = {
+const Performance: PerformanceModule = {
// When performance monitoring is disabled the implementations are blank
diffObject,
setupPerformanceObserver: () => {},
@@ -44,7 +78,11 @@ const Performance = {
measureFailSafe: () => {},
measureTTI: () => {},
traceRender: () => {},
- withRenderTrace: () => (Component) => Component,
+ withRenderTrace:
+ () =>
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ >(Component: React.ComponentType
): React.ComponentType
=>
+ Component,
subscribeToMeasurements: () => {},
};
@@ -53,20 +91,21 @@ if (Metrics.canCapturePerformanceMetrics()) {
perfModule.setResourceLoggingEnabled(true);
rnPerformance = perfModule.default;
- Performance.measureFailSafe = (measureName, startOrMeasureOptions, endMark) => {
+ Performance.measureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => {
try {
rnPerformance.measure(measureName, startOrMeasureOptions, endMark);
} catch (error) {
// Sometimes there might be no start mark recorded and the measure will fail with an error
- console.debug(error.message);
+ if (error instanceof Error) {
+ console.debug(error.message);
+ }
}
};
/**
* Measures the TTI time. To be called when the app is considered to be interactive.
- * @param {String} [endMark] Optional end mark name
*/
- Performance.measureTTI = (endMark) => {
+ Performance.measureTTI = (endMark: string) => {
// Make sure TTI is captured when the app is really usable
InteractionManager.runAfterInteractions(() => {
requestAnimationFrame(() => {
@@ -88,8 +127,8 @@ if (Metrics.canCapturePerformanceMetrics()) {
performanceReported.setupDefaultFlipperReporter();
// Monitor some native marks that we want to put on the timeline
- new perfModule.PerformanceObserver((list, observer) => {
- list.getEntries().forEach((entry) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList, observer: PerformanceObserver) => {
+ list.getEntries().forEach((entry: PerformanceEntry) => {
if (entry.name === 'nativeLaunchEnd') {
Performance.measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd');
}
@@ -108,8 +147,8 @@ if (Metrics.canCapturePerformanceMetrics()) {
}).observe({type: 'react-native-mark', buffered: true});
// Monitor for "_end" marks and capture "_start" to "_end" measures
- new perfModule.PerformanceObserver((list) => {
- list.getEntriesByType('mark').forEach((mark) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => {
+ list.getEntriesByType('mark').forEach((mark: PerformanceEntry) => {
if (mark.name.endsWith('_end')) {
const end = mark.name;
const name = end.replace(/_end$/, '');
@@ -125,65 +164,64 @@ if (Metrics.canCapturePerformanceMetrics()) {
}).observe({type: 'mark', buffered: true});
};
- Performance.getPerformanceMetrics = () =>
- _.chain([
+ Performance.getPerformanceMetrics = (): PerformanceEntry[] =>
+ [
...rnPerformance.getEntriesByName('nativeLaunch'),
...rnPerformance.getEntriesByName('runJsBundle'),
...rnPerformance.getEntriesByName('jsBundleDownload'),
...rnPerformance.getEntriesByName('TTI'),
...rnPerformance.getEntriesByName('regularAppStart'),
...rnPerformance.getEntriesByName('appStartedToReady'),
- ])
- .filter((entry) => entry.duration > 0)
- .value();
+ ].filter((entry) => entry.duration > 0);
/**
* Outputs performance stats. We alert these so that they are easy to access in release builds.
*/
Performance.printPerformanceMetrics = () => {
const stats = Performance.getPerformanceMetrics();
- const statsAsText = _.map(stats, (entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n');
+ const statsAsText = stats.map((entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n');
if (stats.length > 0) {
Alert.alert('Performance', statsAsText);
}
};
- Performance.subscribeToMeasurements = (callback) => {
- new perfModule.PerformanceObserver((list) => {
+ Performance.subscribeToMeasurements = (callback: PerformanceEntriesCallback) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => {
list.getEntriesByType('measure').forEach(callback);
}).observe({type: 'measure', buffered: true});
};
/**
* Add a start mark to the performance entries
- * @param {string} name
- * @param {Object} [detail]
- * @returns {PerformanceMark}
*/
- Performance.markStart = (name, detail) => rnPerformance.mark(`${name}_start`, {detail});
+ Performance.markStart = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_start`, {detail});
/**
* Add an end mark to the performance entries
* A measure between start and end is captured automatically
- * @param {string} name
- * @param {Object} [detail]
- * @returns {PerformanceMark}
*/
- Performance.markEnd = (name, detail) => rnPerformance.mark(`${name}_end`, {detail});
+ Performance.markEnd = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_end`, {detail});
/**
* Put data emitted by Profiler components on the timeline
- * @param {string} id the "id" prop of the Profiler tree that has just committed
- * @param {'mount'|'update'} phase either "mount" (if the tree just mounted) or "update" (if it re-rendered)
- * @param {number} actualDuration time spent rendering the committed update
- * @param {number} baseDuration estimated time to render the entire subtree without memoization
- * @param {number} startTime when React began rendering this update
- * @param {number} commitTime when React committed this update
- * @param {Set} interactions the Set of interactions belonging to this update
- * @returns {PerformanceMeasure}
+ * @param id the "id" prop of the Profiler tree that has just committed
+ * @param phase either "mount" (if the tree just mounted) or "update" (if it re-rendered)
+ * @param actualDuration time spent rendering the committed update
+ * @param baseDuration estimated time to render the entire subtree without memoization
+ * @param startTime when React began rendering this update
+ * @param commitTime when React committed this update
+ * @param interactions the Set of interactions belonging to this update
*/
- Performance.traceRender = (id, phase, actualDuration, baseDuration, startTime, commitTime, interactions) =>
+ Performance.traceRender = (
+ id: string,
+ phase: Phase,
+ actualDuration: number,
+ baseDuration: number,
+ startTime: number,
+ commitTime: number,
+ interactions: Set,
+ ): PerformanceMeasure =>
rnPerformance.measure(id, {
start: startTime,
duration: actualDuration,
@@ -197,14 +235,12 @@ if (Metrics.canCapturePerformanceMetrics()) {
/**
* A HOC that captures render timings of the Wrapped component
- * @param {object} config
- * @param {string} config.id
- * @returns {function(React.Component): React.FunctionComponent}
*/
Performance.withRenderTrace =
- ({id}) =>
- (WrappedComponent) => {
- const WithRenderTrace = forwardRef((props, ref) => (
+ ({id}: WrappedComponentConfig) =>
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ >(WrappedComponent: React.ComponentType
): React.ComponentType
> => {
+ const WithRenderTrace: React.ComponentType
> = forwardRef((props: P, ref) => (
));
- WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent)})`;
+ WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent as React.ComponentType)})`;
return WithRenderTrace;
};
}
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 05322472a407..13489c396c3c 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -17,13 +17,6 @@ function canUseDefaultRooms(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas);
}
-/**
- * IOU Send feature is temporarily disabled.
- */
-function canUseIOUSend(): boolean {
- return false;
-}
-
function canUseWallet(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas);
}
@@ -68,7 +61,6 @@ export default {
canUseChronos,
canUsePayWithExpensify,
canUseDefaultRooms,
- canUseIOUSend,
canUseWallet,
canUseCommentLinking,
canUsePolicyRooms,
diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js
index 6b9335ab263d..29c49427bc81 100644
--- a/src/libs/PersonalDetailsUtils.js
+++ b/src/libs/PersonalDetailsUtils.js
@@ -36,21 +36,21 @@ function getDisplayNameOrDefault(passedPersonalDetails, pathToDisplayName, defau
* @returns {Array} - Array of personal detail objects
*/
function getPersonalDetailsByIDs(accountIDs, currentUserAccountID, shouldChangeUserDisplayName = false) {
- const result = [];
- _.each(
- _.filter(personalDetails, (detail) => accountIDs.includes(detail.accountID)),
- (detail) => {
+ return _.chain(accountIDs)
+ .filter((accountID) => !!allPersonalDetails[accountID])
+ .map((accountID) => {
+ const detail = allPersonalDetails[accountID];
+
if (shouldChangeUserDisplayName && currentUserAccountID === detail.accountID) {
- result.push({
+ return {
...detail,
displayName: Localize.translateLocal('common.you'),
- });
- } else {
- result.push(detail);
+ };
}
- },
- );
- return result;
+
+ return detail;
+ })
+ .value();
}
/**
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 347a825f59cc..de902b53a7a4 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -155,6 +155,14 @@ function isExpensifyGuideTeam(email) {
*/
const isPolicyAdmin = (policy) => lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN;
+/**
+ *
+ * @param {String} policyID
+ * @param {Object} policies
+ * @returns {Boolean}
+ */
+const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => policy.id === policyID);
+
/**
* @param {Object} policyMembers
* @param {Object} personalDetails
@@ -174,7 +182,7 @@ function getMemberAccountIDsForWorkspace(policyMembers, personalDetails) {
if (!personalDetail || !personalDetail.login) {
return;
}
- memberEmailsToAccountIDs[personalDetail.login] = accountID;
+ memberEmailsToAccountIDs[personalDetail.login] = Number(accountID);
});
return memberEmailsToAccountIDs;
}
@@ -276,6 +284,7 @@ export {
isPolicyAdmin,
getMemberAccountIDsForWorkspace,
getIneligibleInvitees,
+ isPolicyMember,
getTag,
getTagListName,
getTagList,
diff --git a/src/libs/Pusher/EventType.js b/src/libs/Pusher/EventType.ts
similarity index 97%
rename from src/libs/Pusher/EventType.js
rename to src/libs/Pusher/EventType.ts
index 85ccc5e17242..89e8a0ca0260 100644
--- a/src/libs/Pusher/EventType.js
+++ b/src/libs/Pusher/EventType.ts
@@ -11,4 +11,4 @@ export default {
MULTIPLE_EVENT_TYPE: {
ONYX_API_UPDATE: 'onyxApiUpdate',
},
-};
+} as const;
diff --git a/src/libs/Pusher/library/index.js b/src/libs/Pusher/library/index.js
deleted file mode 100644
index 12cfae7df02f..000000000000
--- a/src/libs/Pusher/library/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * We use the standard pusher-js module to support pusher on web environments.
- * @see: https://github.com/pusher/pusher-js
- */
-import Pusher from 'pusher-js/with-encryption';
-
-export default Pusher;
diff --git a/src/libs/Pusher/library/index.native.js b/src/libs/Pusher/library/index.native.js
deleted file mode 100644
index 7b87d0c8bdfb..000000000000
--- a/src/libs/Pusher/library/index.native.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * We use the pusher-js/react-native module to support pusher on native environments.
- * @see: https://github.com/pusher/pusher-js
- */
-import Pusher from 'pusher-js/react-native';
-
-export default Pusher;
diff --git a/src/libs/Pusher/library/index.native.ts b/src/libs/Pusher/library/index.native.ts
new file mode 100644
index 000000000000..f50834366515
--- /dev/null
+++ b/src/libs/Pusher/library/index.native.ts
@@ -0,0 +1,10 @@
+/**
+ * We use the pusher-js/react-native module to support pusher on native environments.
+ * @see: https://github.com/pusher/pusher-js
+ */
+import PusherImplementation from 'pusher-js/react-native';
+import Pusher from './types';
+
+const PusherNative: Pusher = PusherImplementation;
+
+export default PusherNative;
diff --git a/src/libs/Pusher/library/index.ts b/src/libs/Pusher/library/index.ts
new file mode 100644
index 000000000000..6a7104a1d2a5
--- /dev/null
+++ b/src/libs/Pusher/library/index.ts
@@ -0,0 +1,10 @@
+/**
+ * We use the standard pusher-js module to support pusher on web environments.
+ * @see: https://github.com/pusher/pusher-js
+ */
+import PusherImplementation from 'pusher-js/with-encryption';
+import type Pusher from './types';
+
+const PusherWeb: Pusher = PusherImplementation;
+
+export default PusherWeb;
diff --git a/src/libs/Pusher/library/types.ts b/src/libs/Pusher/library/types.ts
new file mode 100644
index 000000000000..cc8c70fccdbb
--- /dev/null
+++ b/src/libs/Pusher/library/types.ts
@@ -0,0 +1,10 @@
+import PusherClass from 'pusher-js/with-encryption';
+import {LiteralUnion} from 'type-fest';
+
+type Pusher = typeof PusherClass;
+
+type SocketEventName = LiteralUnion<'error' | 'connected' | 'disconnected' | 'state_change', string>;
+
+export default Pusher;
+
+export type {SocketEventName};
diff --git a/src/libs/Pusher/pusher.js b/src/libs/Pusher/pusher.ts
similarity index 72%
rename from src/libs/Pusher/pusher.js
rename to src/libs/Pusher/pusher.ts
index 4f2b63d36c0c..dad963e933fe 100644
--- a/src/libs/Pusher/pusher.js
+++ b/src/libs/Pusher/pusher.ts
@@ -1,9 +1,48 @@
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
+import {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption';
+import isObject from 'lodash/isObject';
+import {LiteralUnion, ValueOf} from 'type-fest';
import ONYXKEYS from '../../ONYXKEYS';
import Pusher from './library';
import TYPE from './EventType';
import Log from '../Log';
+import DeepValueOf from '../../types/utils/DeepValueOf';
+import {SocketEventName} from './library/types';
+import CONST from '../../CONST';
+import {OnyxUpdateEvent, OnyxUpdatesFromServer} from '../../types/onyx';
+
+type States = {
+ previous: string;
+ current: string;
+};
+
+type Args = {
+ appKey: string;
+ cluster: string;
+ authEndpoint: string;
+};
+
+type PushJSON = OnyxUpdateEvent[] | OnyxUpdatesFromServer;
+
+type EventCallbackError = {type: ValueOf; data: {code: number}};
+
+type ChunkedDataEvents = {chunks: unknown[]; receivedFinal: boolean};
+
+type EventData = {id?: string; chunk?: unknown; final?: boolean; index: number};
+
+type SocketEventCallback = (eventName: SocketEventName, data?: States | EventCallbackError) => void;
+
+type PusherWithAuthParams = InstanceType & {
+ config: {
+ auth?: {
+ params?: unknown;
+ };
+ };
+};
+
+type PusherEventName = LiteralUnion, string>;
+
+type PusherSubscribtionErrorData = {type?: string; error?: string; status?: string};
let shouldForceOffline = false;
Onyx.connect({
@@ -16,33 +55,23 @@ Onyx.connect({
},
});
-let socket;
+let socket: PusherWithAuthParams | null;
let pusherSocketID = '';
-const socketEventCallbacks = [];
-let customAuthorizer;
+const socketEventCallbacks: SocketEventCallback[] = [];
+let customAuthorizer: ChannelAuthorizerGenerator;
/**
* Trigger each of the socket event callbacks with the event information
- *
- * @param {String} eventName
- * @param {*} data
*/
-function callSocketEventCallbacks(eventName, data) {
- _.each(socketEventCallbacks, (cb) => cb(eventName, data));
+function callSocketEventCallbacks(eventName: SocketEventName, data?: EventCallbackError | States) {
+ socketEventCallbacks.forEach((cb) => cb(eventName, data));
}
/**
* Initialize our pusher lib
- *
- * @param {Object} args
- * @param {String} args.appKey
- * @param {String} args.cluster
- * @param {String} args.authEndpoint
- * @param {Object} [params]
- * @public
- * @returns {Promise} resolves when Pusher has connected
+ * @returns resolves when Pusher has connected
*/
-function init(args, params) {
+function init(args: Args, params?: unknown): Promise {
return new Promise((resolve) => {
if (socket) {
return resolve();
@@ -55,7 +84,7 @@ function init(args, params) {
// }
// };
- const options = {
+ const options: Options = {
cluster: args.cluster,
authEndpoint: args.authEndpoint,
};
@@ -65,7 +94,6 @@ function init(args, params) {
}
socket = new Pusher(args.appKey, options);
-
// If we want to pass params in our requests to api.php we'll need to add it to socket.config.auth.params
// as per the documentation
// (https://pusher.com/docs/channels/using_channels/connection#channels-options-parameter).
@@ -77,21 +105,21 @@ function init(args, params) {
}
// Listen for connection errors and log them
- socket.connection.bind('error', (error) => {
+ socket?.connection.bind('error', (error: EventCallbackError) => {
callSocketEventCallbacks('error', error);
});
- socket.connection.bind('connected', () => {
- pusherSocketID = socket.connection.socket_id;
+ socket?.connection.bind('connected', () => {
+ pusherSocketID = socket?.connection.socket_id ?? '';
callSocketEventCallbacks('connected');
resolve();
});
- socket.connection.bind('disconnected', () => {
+ socket?.connection.bind('disconnected', () => {
callSocketEventCallbacks('disconnected');
});
- socket.connection.bind('state_change', (states) => {
+ socket?.connection.bind('state_change', (states: States) => {
callSocketEventCallbacks('state_change', states);
});
});
@@ -99,12 +127,8 @@ function init(args, params) {
/**
* Returns a Pusher channel for a channel name
- *
- * @param {String} channelName
- *
- * @returns {Channel}
*/
-function getChannel(channelName) {
+function getChannel(channelName: string): Channel | undefined {
if (!socket) {
return;
}
@@ -114,19 +138,14 @@ function getChannel(channelName) {
/**
* Binds an event callback to a channel + eventName
- * @param {Pusher.Channel} channel
- * @param {String} eventName
- * @param {Function} [eventCallback]
- *
- * @private
*/
-function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
+function bindEventToChannel(channel: Channel | undefined, eventName: PusherEventName, eventCallback: (data: PushJSON) => void = () => {}) {
if (!eventName) {
return;
}
- const chunkedDataEvents = {};
- const callback = (eventData) => {
+ const chunkedDataEvents: Record = {};
+ const callback = (eventData: string | Record | EventData) => {
if (shouldForceOffline) {
Log.info('[Pusher] Ignoring a Push event because shouldForceOffline = true');
return;
@@ -134,7 +153,7 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
let data;
try {
- data = _.isObject(eventData) ? eventData : JSON.parse(eventData);
+ data = isObject(eventData) ? eventData : JSON.parse(eventData);
} catch (err) {
Log.alert('[Pusher] Unable to parse single JSON event data from Pusher', {error: err, eventData});
return;
@@ -164,7 +183,7 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
// Only call the event callback if we've received the last packet and we don't have any holes in the complete
// packet.
- if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === _.keys(chunkedEvent.chunks).length) {
+ if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === Object.keys(chunkedEvent.chunks).length) {
try {
eventCallback(JSON.parse(chunkedEvent.chunks.join('')));
} catch (err) {
@@ -181,22 +200,14 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
}
};
- channel.bind(eventName, callback);
+ channel?.bind(eventName, callback);
}
/**
* Subscribe to a channel and an event
- *
- * @param {String} channelName
- * @param {String} eventName
- * @param {Function} [eventCallback]
- * @param {Function} [onResubscribe] Callback to be called when reconnection happen
- *
- * @return {Promise}
- *
- * @public
+ * @param [onResubscribe] Callback to be called when reconnection happen
*/
-function subscribe(channelName, eventName, eventCallback = () => {}, onResubscribe = () => {}) {
+function subscribe(channelName: string, eventName: PusherEventName, eventCallback: (data: PushJSON) => void = () => {}, onResubscribe = () => {}): Promise {
return new Promise((resolve, reject) => {
// We cannot call subscribe() before init(). Prevent any attempt to do this on dev.
if (!socket) {
@@ -226,7 +237,7 @@ function subscribe(channelName, eventName, eventCallback = () => {}, onResubscri
onResubscribe();
});
- channel.bind('pusher:subscription_error', (data = {}) => {
+ channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => {
const {type, error, status} = data;
Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', {
channelName,
@@ -245,12 +256,8 @@ function subscribe(channelName, eventName, eventCallback = () => {}, onResubscri
/**
* Unsubscribe from a channel and optionally a specific event
- *
- * @param {String} channelName
- * @param {String} [eventName]
- * @public
*/
-function unsubscribe(channelName, eventName = '') {
+function unsubscribe(channelName: string, eventName: PusherEventName = '') {
const channel = getChannel(channelName);
if (!channel) {
@@ -269,18 +276,14 @@ function unsubscribe(channelName, eventName = '') {
Log.info('[Pusher] Unsubscribing from channel', false, {channelName});
channel.unbind();
- socket.unsubscribe(channelName);
+ socket?.unsubscribe(channelName);
}
}
/**
* Are we already in the process of subscribing to this channel?
- *
- * @param {String} channelName
- *
- * @returns {Boolean}
*/
-function isAlreadySubscribing(channelName) {
+function isAlreadySubscribing(channelName: string): boolean {
if (!socket) {
return false;
}
@@ -291,12 +294,8 @@ function isAlreadySubscribing(channelName) {
/**
* Are we already subscribed to this channel?
- *
- * @param {String} channelName
- *
- * @returns {Boolean}
*/
-function isSubscribed(channelName) {
+function isSubscribed(channelName: string): boolean {
if (!socket) {
return false;
}
@@ -307,12 +306,8 @@ function isSubscribed(channelName) {
/**
* Sends an event over a specific event/channel in pusher.
- *
- * @param {String} channelName
- * @param {String} eventName
- * @param {Object} payload
*/
-function sendEvent(channelName, eventName, payload) {
+function sendEvent(channelName: string, eventName: PusherEventName, payload: Record) {
// Check to see if we are subscribed to this channel before sending the event. Sending client events over channels
// we are not subscribed too will throw errors and cause reconnection attempts. Subscriptions are not instant and
// can happen later than we expect.
@@ -325,15 +320,13 @@ function sendEvent(channelName, eventName, payload) {
return;
}
- socket.send_event(eventName, payload, channelName);
+ socket?.send_event(eventName, payload, channelName);
}
/**
* Register a method that will be triggered when a socket event happens (like disconnecting)
- *
- * @param {Function} cb
*/
-function registerSocketEventCallback(cb) {
+function registerSocketEventCallback(cb: SocketEventCallback) {
socketEventCallbacks.push(cb);
}
@@ -341,10 +334,8 @@ function registerSocketEventCallback(cb) {
* A custom authorizer allows us to take a more fine-grained approach to
* authenticating Pusher. e.g. we can handle failed attempts to authorize
* with an expired authToken and retry the attempt.
- *
- * @param {Function} authorizer
*/
-function registerCustomAuthorizer(authorizer) {
+function registerCustomAuthorizer(authorizer: ChannelAuthorizerGenerator) {
customAuthorizer = authorizer;
}
@@ -376,18 +367,13 @@ function reconnect() {
socket.connect();
}
-/**
- * @returns {String}
- */
-function getPusherSocketID() {
+function getPusherSocketID(): string {
return pusherSocketID;
}
if (window) {
/**
* Pusher socket for debugging purposes
- *
- * @returns {Function}
*/
window.getPusherInstance = () => socket;
}
@@ -407,3 +393,5 @@ export {
TYPE,
getPusherSocketID,
};
+
+export type {EventCallbackError, States, PushJSON};
diff --git a/src/libs/PusherConnectionManager.ts b/src/libs/PusherConnectionManager.ts
index 4ab08d6dc760..9b1f6ebe1b2a 100644
--- a/src/libs/PusherConnectionManager.ts
+++ b/src/libs/PusherConnectionManager.ts
@@ -1,11 +1,10 @@
-import {ValueOf} from 'type-fest';
+import {ChannelAuthorizationCallback} from 'pusher-js/with-encryption';
import * as Pusher from './Pusher/pusher';
import * as Session from './actions/Session';
import Log from './Log';
import CONST from '../CONST';
-
-type EventCallbackError = {type: ValueOf; data: {code: number}};
-type CustomAuthorizerChannel = {name: string};
+import {SocketEventName} from './Pusher/library/types';
+import {EventCallbackError, States} from './Pusher/pusher';
function init() {
/**
@@ -14,30 +13,32 @@ function init() {
* current valid token to generate the signed auth response
* needed to subscribe to Pusher channels.
*/
- Pusher.registerCustomAuthorizer((channel: CustomAuthorizerChannel) => ({
- authorize: (socketID: string, callback: () => void) => {
- Session.authenticatePusher(socketID, channel.name, callback);
+ Pusher.registerCustomAuthorizer((channel) => ({
+ authorize: (socketId: string, callback: ChannelAuthorizationCallback) => {
+ Session.authenticatePusher(socketId, channel.name, callback);
},
}));
- Pusher.registerSocketEventCallback((eventName: string, error: EventCallbackError) => {
+ Pusher.registerSocketEventCallback((eventName: SocketEventName, error?: EventCallbackError | States) => {
switch (eventName) {
case 'error': {
- const errorType = error?.type;
- const code = error?.data?.code;
- if (errorType === CONST.ERROR.PUSHER_ERROR && code === 1006) {
- // 1006 code happens when a websocket connection is closed. There may or may not be a reason attached indicating why the connection was closed.
- // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5
- Log.hmmm('[PusherConnectionManager] Channels Error 1006', {error});
- } else if (errorType === CONST.ERROR.PUSHER_ERROR && code === 4201) {
- // This means the connection was closed because Pusher did not receive a reply from the client when it pinged them for a response
- // https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/#4200-4299
- Log.hmmm('[PusherConnectionManager] Pong reply not received', {error});
- } else if (errorType === CONST.ERROR.WEB_SOCKET_ERROR) {
- // It's not clear why some errors are wrapped in a WebSocketError type - this error could mean different things depending on the contents.
- Log.hmmm('[PusherConnectionManager] WebSocketError', {error});
- } else {
- Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PusherConnectionManager] Unknown error event`, {error});
+ if (error && 'type' in error) {
+ const errorType = error?.type;
+ const code = error?.data?.code;
+ if (errorType === CONST.ERROR.PUSHER_ERROR && code === 1006) {
+ // 1006 code happens when a websocket connection is closed. There may or may not be a reason attached indicating why the connection was closed.
+ // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5
+ Log.hmmm('[PusherConnectionManager] Channels Error 1006', {error});
+ } else if (errorType === CONST.ERROR.PUSHER_ERROR && code === 4201) {
+ // This means the connection was closed because Pusher did not receive a reply from the client when it pinged them for a response
+ // https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/#4200-4299
+ Log.hmmm('[PusherConnectionManager] Pong reply not received', {error});
+ } else if (errorType === CONST.ERROR.WEB_SOCKET_ERROR) {
+ // It's not clear why some errors are wrapped in a WebSocketError type - this error could mean different things depending on the contents.
+ Log.hmmm('[PusherConnectionManager] WebSocketError', {error});
+ } else {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PusherConnectionManager] Unknown error event`, {error});
+ }
}
break;
}
diff --git a/src/libs/PusherUtils.js b/src/libs/PusherUtils.ts
similarity index 61%
rename from src/libs/PusherUtils.js
rename to src/libs/PusherUtils.ts
index b4615d3c7d8b..d47283f21bbf 100644
--- a/src/libs/PusherUtils.js
+++ b/src/libs/PusherUtils.ts
@@ -1,26 +1,21 @@
+import {OnyxUpdate} from 'react-native-onyx';
import CONFIG from '../CONFIG';
import Log from './Log';
import NetworkConnection from './NetworkConnection';
import * as Pusher from './Pusher/pusher';
import CONST from '../CONST';
+import {PushJSON} from './Pusher/pusher';
+
+type Callback = (data: OnyxUpdate[]) => Promise;
// Keeps track of all the callbacks that need triggered for each event type
-const multiEventCallbackMapping = {};
+const multiEventCallbackMapping: Record = {};
-/**
- * @param {String} eventType
- * @param {Function} callback
- */
-function subscribeToMultiEvent(eventType, callback) {
+function subscribeToMultiEvent(eventType: string, callback: Callback) {
multiEventCallbackMapping[eventType] = callback;
}
-/**
- * @param {String} eventType
- * @param {Mixed} data
- * @returns {Promise}
- */
-function triggerMultiEventHandler(eventType, data) {
+function triggerMultiEventHandler(eventType: string, data: OnyxUpdate[]): Promise {
if (!multiEventCallbackMapping[eventType]) {
return Promise.resolve();
}
@@ -29,18 +24,11 @@ function triggerMultiEventHandler(eventType, data) {
/**
* Abstraction around subscribing to private user channel events. Handles all logs and errors automatically.
- *
- * @param {String} eventName
- * @param {String} accountID
- * @param {Function} onEvent
*/
-function subscribeToPrivateUserChannelEvent(eventName, accountID, onEvent) {
+function subscribeToPrivateUserChannelEvent(eventName: string, accountID: string, onEvent: (pushJSON: PushJSON) => void) {
const pusherChannelName = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}${accountID}${CONFIG.PUSHER.SUFFIX}`;
- /**
- * @param {Object} pushJSON
- */
- function logPusherEvent(pushJSON) {
+ function logPusherEvent(pushJSON: PushJSON) {
Log.info(`[Report] Handled ${eventName} event sent by Pusher`, false, pushJSON);
}
@@ -48,19 +36,13 @@ function subscribeToPrivateUserChannelEvent(eventName, accountID, onEvent) {
NetworkConnection.triggerReconnectionCallbacks('Pusher re-subscribed to private user channel');
}
- /**
- * @param {*} pushJSON
- */
- function onEventPush(pushJSON) {
+ function onEventPush(pushJSON: PushJSON) {
logPusherEvent(pushJSON);
onEvent(pushJSON);
}
- /**
- * @param {*} error
- */
- function onSubscriptionFailed(error) {
- Log.hmmm('Failed to subscribe to Pusher channel', false, {error, pusherChannelName, eventName});
+ function onSubscriptionFailed(error: Error) {
+ Log.hmmm('Failed to subscribe to Pusher channel', {error, pusherChannelName, eventName});
}
Pusher.subscribe(pusherChannelName, eventName, onEventPush, onPusherResubscribeToPrivateUserChannel).catch(onSubscriptionFailed);
}
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index cdc45cb119d5..13e8a195cccb 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -6,10 +6,13 @@ import ReceiptHTML from '../../assets/images/receipt-html.png';
import ReceiptDoc from '../../assets/images/receipt-doc.png';
import ReceiptGeneric from '../../assets/images/receipt-generic.png';
import ReceiptSVG from '../../assets/images/receipt-svg.png';
+import {Transaction} from '../types/onyx';
+import ROUTES from '../ROUTES';
type ThumbnailAndImageURI = {
image: ImageSourcePropType | string;
thumbnail: string | null;
+ transaction?: Transaction;
};
type FileNameAndExtension = {
@@ -20,12 +23,23 @@ type FileNameAndExtension = {
/**
* Grab the appropriate receipt image and thumbnail URIs based on file type
*
- * @param path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
- * @param filename of uploaded image or last part of remote URI
+ * @param transaction
+ * @param receiptPath
+ * @param receiptFileName
*/
-function getThumbnailAndImageURIs(path: string, filename: string): ThumbnailAndImageURI {
+function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI {
+ // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
+ const path = transaction?.receipt?.source ?? receiptPath ?? '';
+ // filename of uploaded image or last part of remote URI
+ const filename = transaction?.filename ?? receiptFileName ?? '';
const isReceiptImage = Str.isImage(filename);
+ const hasEReceipt = transaction?.hasEReceipt;
+
+ if (hasEReceipt) {
+ return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
+ }
+
// For local files, we won't have a thumbnail yet
if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
return {thumbnail: null, image: path};
diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts
index ca4f9d77898b..65466fa4a204 100644
--- a/src/libs/ReportActionComposeFocusManager.ts
+++ b/src/libs/ReportActionComposeFocusManager.ts
@@ -1,5 +1,7 @@
import React from 'react';
import {TextInput} from 'react-native';
+import ROUTES from '../ROUTES';
+import Navigation from './Navigation/Navigation';
type FocusCallback = () => void;
@@ -28,6 +30,11 @@ function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
* Request focus on the ReportActionComposer
*/
function focus() {
+ /** Do not trigger the refocusing when the active route is not the report route, */
+ if (!Navigation.isActiveRoute(ROUTES.REPORT_WITH_ID.getRoute(Navigation.getTopmostReportId() ?? ''))) {
+ return;
+ }
+
if (typeof focusCallback !== 'function') {
if (typeof mainComposerFocusCallback !== 'function') {
return;
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
deleted file mode 100644
index 258582d9f653..000000000000
--- a/src/libs/ReportActionsUtils.js
+++ /dev/null
@@ -1,689 +0,0 @@
-/* eslint-disable rulesdir/prefer-underscore-method */
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
-import {max, parseISO, isEqual} from 'date-fns';
-import lodashFindLast from 'lodash/findLast';
-import Onyx from 'react-native-onyx';
-import * as CollectionUtils from './CollectionUtils';
-import CONST from '../CONST';
-import ONYXKEYS from '../ONYXKEYS';
-import Log from './Log';
-import isReportMessageAttachment from './isReportMessageAttachment';
-
-const allReports = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT,
- callback: (report, key) => {
- if (!key || !report) {
- return;
- }
-
- const reportID = CollectionUtils.extractCollectionItemID(key);
- allReports[reportID] = report;
- },
-});
-
-const allReportActions = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- callback: (actions, key) => {
- if (!key || !actions) {
- return;
- }
-
- const reportID = CollectionUtils.extractCollectionItemID(key);
- allReportActions[reportID] = actions;
- },
-});
-
-let isNetworkOffline = false;
-Onyx.connect({
- key: ONYXKEYS.NETWORK,
- callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)),
-});
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isCreatedAction(reportAction) {
- return lodashGet(reportAction, 'actionName') === CONST.REPORT.ACTIONS.TYPE.CREATED;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isDeletedAction(reportAction) {
- // A deleted comment has either an empty array or an object with html field with empty string as value
- const message = lodashGet(reportAction, 'message', []);
- return message.length === 0 || lodashGet(message, [0, 'html']) === '';
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isDeletedParentAction(reportAction) {
- return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false) && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isPendingRemove(reportAction) {
- return lodashGet(reportAction, 'message[0].moderationDecision.decision') === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isMoneyRequestAction(reportAction) {
- return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.IOU;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isReportPreviewAction(reportAction) {
- return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isModifiedExpenseAction(reportAction) {
- return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE;
-}
-
-function isWhisperAction(action) {
- return (action.whisperedToAccountIDs || []).length > 0;
-}
-
-/**
- * Returns whether the comment is a thread parent message/the first message in a thread
- *
- * @param {Object} reportAction
- * @param {String} reportID
- * @returns {Boolean}
- */
-function isThreadParentMessage(reportAction = {}, reportID) {
- const {childType, childVisibleActionCount = 0, childReportID} = reportAction;
- return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID);
-}
-
-/**
- * Returns the parentReportAction if the given report is a thread/task.
- *
- * @param {Object} report
- * @param {Object} [allReportActionsParam]
- * @returns {Object}
- * @deprecated Use Onyx.connect() or withOnyx() instead
- */
-function getParentReportAction(report, allReportActionsParam = undefined) {
- if (!report || !report.parentReportID || !report.parentReportActionID) {
- return {};
- }
- return lodashGet(allReportActionsParam || allReportActions, [report.parentReportID, report.parentReportActionID], {});
-}
-
-/**
- * Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isSentMoneyReportAction(reportAction) {
- return (
- reportAction &&
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
- lodashGet(reportAction, 'originalMessage.type') === CONST.IOU.REPORT_ACTION_TYPE.PAY &&
- _.has(reportAction.originalMessage, 'IOUDetails')
- );
-}
-
-/**
- * Returns whether the thread is a transaction thread, which is any thread with IOU parent
- * report action from requesting money (type - create) or from sending money (type - pay with IOUDetails field)
- *
- * @param {Object} parentReportAction
- * @returns {Boolean}
- */
-function isTransactionThread(parentReportAction) {
- const originalMessage = lodashGet(parentReportAction, 'originalMessage', {});
- return (
- parentReportAction &&
- parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
- (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails')))
- );
-}
-
-/**
- * Sort an array of reportActions by their created timestamp first, and reportActionID second
- * This gives us a stable order even in the case of multiple reportActions created on the same millisecond
- *
- * @param {Array} reportActions
- * @param {Boolean} shouldSortInDescendingOrder
- * @returns {Array}
- */
-function getSortedReportActions(reportActions, shouldSortInDescendingOrder = false) {
- if (!_.isArray(reportActions)) {
- throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`);
- }
-
- const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1;
- return _.chain(reportActions)
- .compact()
- .sort((first, second) => {
- // First sort by timestamp
- if (first.created !== second.created) {
- return (first.created < second.created ? -1 : 1) * invertedMultiplier;
- }
-
- // Then by action type, ensuring that `CREATED` actions always come first if they have the same timestamp as another action type
- if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) {
- return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier;
- }
- // Ensure that `REPORTPREVIEW` actions always come after if they have the same timestamp as another action type
- if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW || second.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) && first.actionName !== second.actionName) {
- return (first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? 1 : -1) * invertedMultiplier;
- }
-
- // Then fallback on reportActionID as the final sorting criteria. It is a random number,
- // but using this will ensure that the order of reportActions with the same created time and action type
- // will be consistent across all users and devices
- return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
- })
- .value();
-}
-
-/**
- * Finds most recent IOU request action ID.
- *
- * @param {Array} reportActions
- * @returns {String}
- */
-function getMostRecentIOURequestActionID(reportActions) {
- const iouRequestTypes = [CONST.IOU.REPORT_ACTION_TYPE.CREATE, CONST.IOU.REPORT_ACTION_TYPE.SPLIT];
- const iouRequestActions = _.filter(reportActions, (action) => iouRequestTypes.includes(lodashGet(action, 'originalMessage.type')));
-
- if (_.isEmpty(iouRequestActions)) {
- return null;
- }
-
- const sortedReportActions = getSortedReportActions(iouRequestActions);
- return _.last(sortedReportActions).reportActionID;
-}
-
-/**
- * Returns array of links inside a given report action
- *
- * @param {Object} reportAction
- * @returns {Array}
- */
-function extractLinksFromMessageHtml(reportAction) {
- const htmlContent = lodashGet(reportAction, ['message', 0, 'html']);
-
- // Regex to get link in href prop inside of component
- const regex = /]*?\s+)?href="([^"]*)"/gi;
-
- if (!htmlContent) {
- return [];
- }
-
- return _.map([...htmlContent.matchAll(regex)], (match) => match[1]);
-}
-
-/**
- * Returns the report action immediately before the specified index.
- * @param {Array} reportActions - all actions
- * @param {Number} actionIndex - index of the action
- * @returns {Object|null}
- */
-function findPreviousAction(reportActions, actionIndex) {
- for (let i = actionIndex + 1; i < reportActions.length; i++) {
- // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list.
- // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete.
- if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return reportActions[i];
- }
- }
- return null;
-}
-
-/**
- * Returns true when the report action immediately before the specified index is a comment made by the same actor who who is leaving a comment in the action at the specified index.
- * Also checks to ensure that the comment is not too old to be shown as a grouped comment.
- *
- * @param {Array} reportActions
- * @param {Number} actionIndex - index of the comment item in state to check
- * @returns {Boolean}
- */
-function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) {
- const previousAction = findPreviousAction(reportActions, actionIndex);
- const currentAction = reportActions[actionIndex];
-
- // It's OK for there to be no previous action, and in that case, false will be returned
- // so that the comment isn't grouped
- if (!currentAction || !previousAction) {
- return false;
- }
-
- // Comments are only grouped if they happen within 5 minutes of each other
- if (new Date(currentAction.created).getTime() - new Date(previousAction.created).getTime() > 300000) {
- return false;
- }
-
- // Do not group if previous action was a created action
- if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
- return false;
- }
-
- // Do not group if previous or current action was a renamed action
- if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
- return false;
- }
-
- // Do not group if the delegate account ID is different
- if (previousAction.delegateAccountID !== currentAction.delegateAccountID) {
- return false;
- }
-
- return currentAction.actorAccountID === previousAction.actorAccountID;
-}
-
-/**
- * Checks if a reportAction is deprecated.
- *
- * @param {Object} reportAction
- * @param {String} key
- * @returns {Boolean}
- */
-function isReportActionDeprecated(reportAction, key) {
- if (!reportAction) {
- return true;
- }
-
- // HACK ALERT: We're temporarily filtering out any reportActions keyed by sequenceNumber
- // to prevent bugs during the migration from sequenceNumber -> reportActionID
- if (String(reportAction.sequenceNumber) === key) {
- Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction);
- return true;
- }
-
- return false;
-}
-
-/**
- * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid
- * and supported type, it's not deleted and also not closed.
- *
- * @param {Object} reportAction
- * @param {String} key
- * @returns {Boolean}
- */
-function shouldReportActionBeVisible(reportAction, key) {
- if (isReportActionDeprecated(reportAction, key)) {
- return false;
- }
-
- if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKEDITED) {
- return false;
- }
-
- // Filter out any unsupported reportAction types
- if (!Object.values(CONST.REPORT.ACTIONS.TYPE).includes(reportAction.actionName) && !Object.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG).includes(reportAction.actionName)) {
- return false;
- }
-
- // Ignore closed action here since we're already displaying a footer that explains why the report was closed
- if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) {
- return false;
- }
-
- if (isPendingRemove(reportAction)) {
- return false;
- }
-
- // All other actions are displayed except thread parents, deleted, or non-pending actions
- const isDeleted = isDeletedAction(reportAction);
- const isPending = !!reportAction.pendingAction;
- return !isDeleted || isPending || isDeletedParentAction(reportAction);
-}
-
-/**
- * Checks if a reportAction is fit for display as report last action, meaning that
- * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted.
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function shouldReportActionBeVisibleAsLastAction(reportAction) {
- if (!reportAction) {
- return false;
- }
-
- if (!_.isEmpty(reportAction.errors)) {
- return false;
- }
-
- // If a whisper action is the REPORTPREVIEW action, we are displaying it.
- return (
- shouldReportActionBeVisible(reportAction, reportAction.reportActionID) &&
- !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) &&
- !isDeletedAction(reportAction)
- );
-}
-
-/**
- * @param {String} reportID
- * @param {Object} [actionsToMerge]
- * @return {Object}
- */
-function getLastVisibleAction(reportID, actionsToMerge = {}) {
- const updatedActionsToMerge = {};
- if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) {
- Object.keys(actionsToMerge).forEach(
- (actionToMergeID) => (updatedActionsToMerge[actionToMergeID] = {...allReportActions[reportID][actionToMergeID], ...actionsToMerge[actionToMergeID]}),
- );
- }
- const actions = Object.values({
- ...allReportActions[reportID],
- ...updatedActionsToMerge,
- });
- const visibleActions = actions.filter((action) => shouldReportActionBeVisibleAsLastAction(action));
-
- if (visibleActions.length === 0) {
- return {};
- }
- const maxDate = max(visibleActions.map((action) => parseISO(action.created)));
- const maxAction = visibleActions.find((action) => isEqual(parseISO(action.created), maxDate));
- return maxAction;
-}
-
-/**
- * @param {String} reportID
- * @param {Object} [actionsToMerge]
- * @return {Object}
- */
-function getLastVisibleMessage(reportID, actionsToMerge = {}) {
- const lastVisibleAction = getLastVisibleAction(reportID, actionsToMerge);
- const message = lodashGet(lastVisibleAction, ['message', 0], {});
-
- if (isReportMessageAttachment(message)) {
- return {
- lastMessageTranslationKey: CONST.TRANSLATION_KEYS.ATTACHMENT,
- lastMessageText: CONST.ATTACHMENT_MESSAGE_TEXT,
- lastMessageHtml: CONST.TRANSLATION_KEYS.ATTACHMENT,
- };
- }
-
- if (isCreatedAction(lastVisibleAction)) {
- return {
- lastMessageText: '',
- };
- }
-
- const messageText = lodashGet(message, 'text', '');
- return {
- lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
- };
-}
-
-/**
- * A helper method to filter out report actions keyed by sequenceNumbers.
- *
- * @param {Object} reportActions
- * @returns {Array}
- */
-function filterOutDeprecatedReportActions(reportActions) {
- return _.filter(reportActions, (reportAction, key) => !isReportActionDeprecated(reportAction, key));
-}
-
-/**
- * This method returns the report actions that are ready for display in the ReportActionsView.
- * The report actions need to be sorted by created timestamp first, and reportActionID second
- * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp).
- * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY.
- *
- * @param {Object} reportActions
- * @returns {Array}
- */
-function getSortedReportActionsForDisplay(reportActions) {
- const filteredReportActions = _.filter(reportActions, (reportAction, key) => shouldReportActionBeVisible(reportAction, key));
- return getSortedReportActions(filteredReportActions, true);
-}
-
-/**
- * In some cases, there can be multiple closed report actions in a chat report.
- * This method returns the last closed report action so we can always show the correct archived report reason.
- * Additionally, archived #admins and #announce do not have the closed report action so we will return null if none is found.
- *
- * @param {Object} reportActions
- * @returns {Object|null}
- */
-function getLastClosedReportAction(reportActions) {
- // If closed report action is not present, return early
- if (!_.some(reportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED)) {
- return null;
- }
- const filteredReportActions = filterOutDeprecatedReportActions(reportActions);
- const sortedReportActions = getSortedReportActions(filteredReportActions);
- return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED);
-}
-
-/**
- * @param {Array} onyxData
- * @returns {Object} The latest report action in the `onyxData` or `null` if one couldn't be found
- */
-function getLatestReportActionFromOnyxData(onyxData) {
- const reportActionUpdate = _.find(onyxData, (onyxUpdate) => onyxUpdate.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS));
-
- if (!reportActionUpdate) {
- return null;
- }
-
- const reportActions = _.values(reportActionUpdate.value);
- const sortedReportActions = getSortedReportActions(reportActions);
- return _.last(sortedReportActions);
-}
-
-/**
- * Find the transaction associated with this reportAction, if one exists.
- *
- * @param {String} reportID
- * @param {String} reportActionID
- * @returns {String|null}
- */
-function getLinkedTransactionID(reportID, reportActionID) {
- const reportAction = lodashGet(allReportActions, [reportID, reportActionID]);
- if (!reportAction || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
- return null;
- }
- return reportAction.originalMessage.IOUTransactionID;
-}
-
-/**
- *
- * @param {String} reportID
- * @param {String} reportActionID
- * @returns {Object}
- */
-function getReportAction(reportID, reportActionID) {
- return lodashGet(allReportActions, [reportID, reportActionID], {});
-}
-
-/**
- * @returns {string}
- */
-function getMostRecentReportActionLastModified() {
- // Start with the oldest date possible
- let mostRecentReportActionLastModified = new Date(0).toISOString();
-
- // Flatten all the actions
- // Loop over them all to find the one that is the most recent
- const flatReportActions = _.flatten(_.map(allReportActions, (actions) => _.values(actions)));
- _.each(flatReportActions, (action) => {
- // Pending actions should not be counted here as a user could create a comment or some other action while offline and the server might know about
- // messages they have not seen yet.
- if (!_.isEmpty(action.pendingAction)) {
- return;
- }
-
- const lastModified = action.lastModified || action.created;
- if (lastModified < mostRecentReportActionLastModified) {
- return;
- }
-
- mostRecentReportActionLastModified = lastModified;
- });
-
- // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get
- // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these
- _.each(allReports, (report) => {
- const reportLastVisibleActionLastModified = report.lastVisibleActionLastModified || report.lastVisibleActionCreated;
- if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) {
- return;
- }
-
- mostRecentReportActionLastModified = reportLastVisibleActionLastModified;
- });
-
- return mostRecentReportActionLastModified;
-}
-
-/**
- * @param {*} chatReportID
- * @param {*} iouReportID
- * @returns {Object} The report preview action or `null` if one couldn't be found
- */
-function getReportPreviewAction(chatReportID, iouReportID) {
- return _.find(
- allReportActions[chatReportID],
- (reportAction) => reportAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lodashGet(reportAction, 'originalMessage.linkedReportID') === iouReportID,
- );
-}
-
-/**
- * Get the iouReportID for a given report action.
- *
- * @param {Object} reportAction
- * @returns {String}
- */
-function getIOUReportIDFromReportActionPreview(reportAction) {
- return lodashGet(reportAction, 'originalMessage.linkedReportID', '');
-}
-
-function isCreatedTaskReportAction(reportAction) {
- return reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && _.has(reportAction.originalMessage, 'taskReportID');
-}
-
-/**
- * A helper method to identify if the message is deleted or not.
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isMessageDeleted(reportAction) {
- return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false);
-}
-
-/**
- * Returns the number of money requests associated with a report preview
- *
- * @param {Object|null} reportPreviewAction
- * @returns {Number}
- */
-function getNumberOfMoneyRequests(reportPreviewAction) {
- return lodashGet(reportPreviewAction, 'childMoneyRequestCount', 0);
-}
-
-/**
- * @param {*} reportAction
- * @returns {Boolean}
- */
-function isSplitBillAction(reportAction) {
- return lodashGet(reportAction, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
-}
-
-/**
- *
- * @param {*} reportAction
- * @returns {Boolean}
- */
-function isTaskAction(reportAction) {
- const reportActionName = lodashGet(reportAction, 'actionName', '');
- return (
- reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
- reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ||
- reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED
- );
-}
-
-/**
- * @param {*} reportID
- * @returns {[Object]}
- */
-function getAllReportActions(reportID) {
- return lodashGet(allReportActions, reportID, []);
-}
-
-/**
- * Check whether a report action is an attachment (a file, such as an image or a zip).
- *
- * @param {Object} reportAction report action
- * @returns {Boolean}
- */
-function isReportActionAttachment(reportAction) {
- const message = _.first(lodashGet(reportAction, 'message', [{}]));
- return _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : isReportMessageAttachment(message);
-}
-
-// eslint-disable-next-line rulesdir/no-negated-variables
-function isNotifiableReportAction(reportAction) {
- return reportAction && _.contains([CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE], reportAction.actionName);
-}
-
-export {
- getSortedReportActions,
- getLastVisibleAction,
- getLastVisibleMessage,
- getMostRecentIOURequestActionID,
- extractLinksFromMessageHtml,
- isCreatedAction,
- isDeletedAction,
- shouldReportActionBeVisible,
- shouldReportActionBeVisibleAsLastAction,
- isReportActionDeprecated,
- isConsecutiveActionMadeByPreviousActor,
- getSortedReportActionsForDisplay,
- getLastClosedReportAction,
- getLatestReportActionFromOnyxData,
- isMoneyRequestAction,
- isThreadParentMessage,
- getLinkedTransactionID,
- getMostRecentReportActionLastModified,
- getReportPreviewAction,
- isCreatedTaskReportAction,
- getParentReportAction,
- isTransactionThread,
- isSentMoneyReportAction,
- isDeletedParentAction,
- isReportPreviewAction,
- isModifiedExpenseAction,
- getIOUReportIDFromReportActionPreview,
- isMessageDeleted,
- isWhisperAction,
- isPendingRemove,
- getReportAction,
- getNumberOfMoneyRequests,
- isSplitBillAction,
- isTaskAction,
- getAllReportActions,
- isReportActionAttachment,
- isNotifiableReportAction,
-};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
new file mode 100644
index 000000000000..c795e5d1c3b1
--- /dev/null
+++ b/src/libs/ReportActionsUtils.ts
@@ -0,0 +1,647 @@
+import {isEqual, max} from 'date-fns';
+import _ from 'lodash';
+import lodashFindLast from 'lodash/findLast';
+import Onyx, {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
+import CONST from '../CONST';
+import ONYXKEYS from '../ONYXKEYS';
+import ReportAction, {ReportActions} from '../types/onyx/ReportAction';
+import Report from '../types/onyx/Report';
+import {ActionName} from '../types/onyx/OriginalMessage';
+import * as CollectionUtils from './CollectionUtils';
+import Log from './Log';
+import isReportMessageAttachment from './isReportMessageAttachment';
+import * as Environment from './Environment/Environment';
+
+type LastVisibleMessage = {
+ lastMessageTranslationKey?: string;
+ lastMessageText: string;
+ lastMessageHtml?: string;
+};
+
+const allReports: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ callback: (report, key) => {
+ if (!key || !report) {
+ return;
+ }
+
+ const reportID = CollectionUtils.extractCollectionItemID(key);
+ allReports[reportID] = report;
+ },
+});
+
+const allReportActions: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ callback: (actions, key) => {
+ if (!key || !actions) {
+ return;
+ }
+
+ const reportID = CollectionUtils.extractCollectionItemID(key);
+ allReportActions[reportID] = actions;
+ },
+});
+
+let isNetworkOffline = false;
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ callback: (val) => (isNetworkOffline = val?.isOffline ?? false),
+});
+
+let environmentURL: string;
+Environment.getEnvironmentURL().then((url: string) => (environmentURL = url));
+
+function isCreatedAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
+}
+
+function isDeletedAction(reportAction: OnyxEntry): boolean {
+ // A deleted comment has either an empty array or an object with html field with empty string as value
+ const message = reportAction?.message ?? [];
+ return message.length === 0 || message[0]?.html === '';
+}
+
+function isDeletedParentAction(reportAction: OnyxEntry): boolean {
+ return (reportAction?.message?.[0]?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+}
+
+function isReversedTransaction(reportAction: OnyxEntry) {
+ return (reportAction?.message?.[0].isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+}
+
+function isPendingRemove(reportAction: OnyxEntry): boolean {
+ return reportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE;
+}
+
+function isMoneyRequestAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU;
+}
+
+function isReportPreviewAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
+}
+
+function isModifiedExpenseAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE;
+}
+
+function isWhisperAction(reportAction: OnyxEntry): boolean {
+ return (reportAction?.whisperedToAccountIDs ?? []).length > 0;
+}
+
+/**
+ * Returns whether the comment is a thread parent message/the first message in a thread
+ */
+function isThreadParentMessage(reportAction: OnyxEntry, reportID: string): boolean {
+ const {childType, childVisibleActionCount = 0, childReportID} = reportAction ?? {};
+ return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID);
+}
+
+/**
+ * Returns the parentReportAction if the given report is a thread/task.
+ *
+ * @deprecated Use Onyx.connect() or withOnyx() instead
+ */
+function getParentReportAction(report: OnyxEntry, allReportActionsParam?: OnyxCollection): ReportAction | Record {
+ if (!report?.parentReportID || !report.parentReportActionID) {
+ return {};
+ }
+ return (allReportActionsParam ?? allReportActions)?.[report.parentReportID]?.[report.parentReportActionID] ?? {};
+}
+
+/**
+ * Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
+ */
+function isSentMoneyReportAction(reportAction: OnyxEntry): boolean {
+ return (
+ reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction?.originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!reportAction?.originalMessage?.IOUDetails
+ );
+}
+
+/**
+ * Returns whether the thread is a transaction thread, which is any thread with IOU parent
+ * report action from requesting money (type - create) or from sending money (type - pay with IOUDetails field)
+ */
+function isTransactionThread(parentReportAction: OnyxEntry): boolean {
+ return (
+ parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
+ (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE ||
+ (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!parentReportAction.originalMessage.IOUDetails))
+ );
+}
+
+/**
+ * Sort an array of reportActions by their created timestamp first, and reportActionID second
+ * This gives us a stable order even in the case of multiple reportActions created on the same millisecond
+ *
+ */
+function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false): ReportAction[] {
+ if (!Array.isArray(reportActions)) {
+ throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`);
+ }
+
+ const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1;
+
+ return reportActions?.filter(Boolean).sort((first, second) => {
+ // First sort by timestamp
+ if (first.created !== second.created) {
+ return (first.created < second.created ? -1 : 1) * invertedMultiplier;
+ }
+
+ // Then by action type, ensuring that `CREATED` actions always come first if they have the same timestamp as another action type
+ if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) {
+ return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier;
+ }
+ // Ensure that `REPORTPREVIEW` actions always come after if they have the same timestamp as another action type
+ if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW || second.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) && first.actionName !== second.actionName) {
+ return (first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? 1 : -1) * invertedMultiplier;
+ }
+
+ // Then fallback on reportActionID as the final sorting criteria. It is a random number,
+ // but using this will ensure that the order of reportActions with the same created time and action type
+ // will be consistent across all users and devices
+ return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
+ });
+}
+
+/**
+ * Finds most recent IOU request action ID.
+ */
+function getMostRecentIOURequestActionID(reportActions: ReportAction[] | null): string | null {
+ if (!Array.isArray(reportActions)) {
+ return null;
+ }
+ const iouRequestTypes: Array> = [CONST.IOU.REPORT_ACTION_TYPE.CREATE, CONST.IOU.REPORT_ACTION_TYPE.SPLIT];
+ const iouRequestActions = reportActions?.filter((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && iouRequestTypes.includes(action.originalMessage.type)) ?? [];
+
+ if (iouRequestActions.length === 0) {
+ return null;
+ }
+
+ const sortedReportActions = getSortedReportActions(iouRequestActions);
+ return sortedReportActions.at(-1)?.reportActionID ?? null;
+}
+
+/**
+ * Returns array of links inside a given report action
+ */
+function extractLinksFromMessageHtml(reportAction: OnyxEntry): string[] {
+ const htmlContent = reportAction?.message?.[0]?.html;
+
+ // Regex to get link in href prop inside of component
+ const regex = /]*?\s+)?href="([^"]*)"/gi;
+
+ if (!htmlContent) {
+ return [];
+ }
+
+ return [...htmlContent.matchAll(regex)].map((match) => match[1]);
+}
+
+/**
+ * Returns the report action immediately before the specified index.
+ * @param reportActions - all actions
+ * @param actionIndex - index of the action
+ */
+function findPreviousAction(reportActions: ReportAction[] | null, actionIndex: number): OnyxEntry {
+ if (!reportActions) {
+ return null;
+ }
+
+ for (let i = actionIndex + 1; i < reportActions.length; i++) {
+ // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list.
+ // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete.
+ if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ return reportActions[i];
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Returns true when the report action immediately before the specified index is a comment made by the same actor who who is leaving a comment in the action at the specified index.
+ * Also checks to ensure that the comment is not too old to be shown as a grouped comment.
+ *
+ * @param actionIndex - index of the comment item in state to check
+ */
+function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | null, actionIndex: number): boolean {
+ const previousAction = findPreviousAction(reportActions, actionIndex);
+ const currentAction = reportActions?.[actionIndex];
+
+ // It's OK for there to be no previous action, and in that case, false will be returned
+ // so that the comment isn't grouped
+ if (!currentAction || !previousAction) {
+ return false;
+ }
+
+ // Comments are only grouped if they happen within 5 minutes of each other
+ if (new Date(currentAction.created).getTime() - new Date(previousAction.created).getTime() > 300000) {
+ return false;
+ }
+
+ // Do not group if previous action was a created action
+ if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ return false;
+ }
+
+ // Do not group if previous or current action was a renamed action
+ if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
+ return false;
+ }
+
+ // Do not group if the delegate account ID is different
+ if (previousAction.delegateAccountID !== currentAction.delegateAccountID) {
+ return false;
+ }
+
+ return currentAction.actorAccountID === previousAction.actorAccountID;
+}
+
+/**
+ * Checks if a reportAction is deprecated.
+ */
+function isReportActionDeprecated(reportAction: OnyxEntry, key: string): boolean {
+ if (!reportAction) {
+ return true;
+ }
+
+ // HACK ALERT: We're temporarily filtering out any reportActions keyed by sequenceNumber
+ // to prevent bugs during the migration from sequenceNumber -> reportActionID
+ if (String(reportAction.sequenceNumber) === key) {
+ Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction);
+ return true;
+ }
+
+ return false;
+}
+
+const {POLICYCHANGELOG: policyChangelogTypes, ROOMCHANGELOG: roomChangeLogTypes, ...otherActionTypes} = CONST.REPORT.ACTIONS.TYPE;
+const supportedActionTypes: ActionName[] = [...Object.values(otherActionTypes), ...Object.values(policyChangelogTypes), ...Object.values(roomChangeLogTypes)];
+
+/**
+ * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid
+ * and supported type, it's not deleted and also not closed.
+ */
+function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string): boolean {
+ if (!reportAction) {
+ return false;
+ }
+
+ if (isReportActionDeprecated(reportAction, key)) {
+ return false;
+ }
+
+ if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKEDITED) {
+ return false;
+ }
+
+ // Filter out any unsupported reportAction types
+ if (!supportedActionTypes.includes(reportAction.actionName)) {
+ return false;
+ }
+
+ // Ignore closed action here since we're already displaying a footer that explains why the report was closed
+ if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) {
+ return false;
+ }
+
+ if (isPendingRemove(reportAction)) {
+ return false;
+ }
+
+ // All other actions are displayed except thread parents, deleted, or non-pending actions
+ const isDeleted = isDeletedAction(reportAction);
+ const isPending = !!reportAction.pendingAction;
+ return !isDeleted || isPending || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction);
+}
+
+/**
+ * Checks if a reportAction is fit for display as report last action, meaning that
+ * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted.
+ */
+function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxEntry): boolean {
+ if (!reportAction) {
+ return false;
+ }
+
+ if (Object.keys(reportAction.errors ?? {}).length > 0) {
+ return false;
+ }
+
+ // If a whisper action is the REPORTPREVIEW action, we are displaying it.
+ // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable.
+ return (
+ shouldReportActionBeVisible(reportAction, reportAction.reportActionID) &&
+ !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) &&
+ !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction))
+ );
+}
+
+/**
+ * For invite to room and remove from room policy change logs, report URLs are generated in the server,
+ * which includes a baseURL placeholder that's replaced in the client.
+ */
+function replaceBaseURL(reportAction: ReportAction): ReportAction {
+ if (!reportAction) {
+ return reportAction;
+ }
+
+ if (
+ !reportAction ||
+ (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.REMOVE_FROM_ROOM)
+ ) {
+ return reportAction;
+ }
+ if (!reportAction.message) {
+ return reportAction;
+ }
+ const updatedReportAction = _.clone(reportAction);
+ if (!updatedReportAction.message) {
+ return updatedReportAction;
+ }
+ updatedReportAction.message[0].html = reportAction.message[0].html.replace('%baseURL', environmentURL);
+ return updatedReportAction;
+}
+
+/**
+ */
+function getLastVisibleAction(reportID: string, actionsToMerge: ReportActions = {}): OnyxEntry {
+ const updatedActionsToMerge: ReportActions = {};
+ if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) {
+ Object.keys(actionsToMerge).forEach(
+ (actionToMergeID) => (updatedActionsToMerge[actionToMergeID] = {...allReportActions?.[reportID]?.[actionToMergeID], ...actionsToMerge[actionToMergeID]}),
+ );
+ }
+ const actions = Object.values({
+ ...allReportActions?.[reportID],
+ ...updatedActionsToMerge,
+ });
+ const visibleActions = actions.filter((action) => shouldReportActionBeVisibleAsLastAction(action));
+
+ if (visibleActions.length === 0) {
+ return null;
+ }
+ const maxDate = max(visibleActions.map((action) => new Date(action.created)));
+ const maxAction = visibleActions.find((action) => isEqual(new Date(action.created), maxDate));
+ return maxAction ?? null;
+}
+
+function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = {}): LastVisibleMessage {
+ const lastVisibleAction = getLastVisibleAction(reportID, actionsToMerge);
+ const message = lastVisibleAction?.message?.[0];
+
+ if (message && isReportMessageAttachment(message)) {
+ return {
+ lastMessageTranslationKey: CONST.TRANSLATION_KEYS.ATTACHMENT,
+ lastMessageText: CONST.ATTACHMENT_MESSAGE_TEXT,
+ lastMessageHtml: CONST.TRANSLATION_KEYS.ATTACHMENT,
+ };
+ }
+
+ if (isCreatedAction(lastVisibleAction)) {
+ return {
+ lastMessageText: '',
+ };
+ }
+
+ const messageText = message?.text ?? '';
+ return {
+ lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
+ };
+}
+
+/**
+ * A helper method to filter out report actions keyed by sequenceNumbers.
+ */
+function filterOutDeprecatedReportActions(reportActions: ReportActions | null): ReportAction[] {
+ return Object.entries(reportActions ?? {})
+ .filter(([key, reportAction]) => !isReportActionDeprecated(reportAction, key))
+ .map((entry) => entry[1]);
+}
+
+/**
+ * This method returns the report actions that are ready for display in the ReportActionsView.
+ * The report actions need to be sorted by created timestamp first, and reportActionID second
+ * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp).
+ * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY.
+ */
+function getSortedReportActionsForDisplay(reportActions: ReportActions | null): ReportAction[] {
+ const filteredReportActions = Object.entries(reportActions ?? {})
+ .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
+ .map((entry) => entry[1]);
+ const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURL(reportAction));
+ return getSortedReportActions(baseURLAdjustedReportActions, true);
+}
+
+/**
+ * In some cases, there can be multiple closed report actions in a chat report.
+ * This method returns the last closed report action so we can always show the correct archived report reason.
+ * Additionally, archived #admins and #announce do not have the closed report action so we will return null if none is found.
+ *
+ */
+function getLastClosedReportAction(reportActions: ReportActions | null): OnyxEntry {
+ // If closed report action is not present, return early
+ if (!Object.values(reportActions ?? {}).some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED)) {
+ return null;
+ }
+
+ const filteredReportActions = filterOutDeprecatedReportActions(reportActions);
+ const sortedReportActions = getSortedReportActions(filteredReportActions);
+ return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) ?? null;
+}
+
+/**
+ * @returns The latest report action in the `onyxData` or `null` if one couldn't be found
+ */
+function getLatestReportActionFromOnyxData(onyxData: OnyxUpdate[] | null): OnyxEntry {
+ const reportActionUpdate = onyxData?.find((onyxUpdate) => onyxUpdate.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS));
+
+ if (!reportActionUpdate) {
+ return null;
+ }
+
+ const reportActions = Object.values((reportActionUpdate.value as ReportActions) ?? {});
+ const sortedReportActions = getSortedReportActions(reportActions);
+ return sortedReportActions.at(-1) ?? null;
+}
+
+/**
+ * Find the transaction associated with this reportAction, if one exists.
+ */
+function getLinkedTransactionID(reportID: string, reportActionID: string): string | null {
+ const reportAction = allReportActions?.[reportID]?.[reportActionID];
+ if (!reportAction || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
+ return null;
+ }
+ return reportAction.originalMessage.IOUTransactionID ?? null;
+}
+
+function getReportAction(reportID: string, reportActionID: string): OnyxEntry {
+ return allReportActions?.[reportID]?.[reportActionID] ?? null;
+}
+
+function getMostRecentReportActionLastModified(): string {
+ // Start with the oldest date possible
+ let mostRecentReportActionLastModified = new Date(0).toISOString();
+
+ // Flatten all the actions
+ // Loop over them all to find the one that is the most recent
+ const flatReportActions = Object.values(allReportActions ?? {})
+ .flatMap((actions) => (actions ? Object.values(actions) : []))
+ .filter(Boolean);
+ flatReportActions.forEach((action) => {
+ // Pending actions should not be counted here as a user could create a comment or some other action while offline and the server might know about
+ // messages they have not seen yet.
+ if (action.pendingAction) {
+ return;
+ }
+
+ const lastModified = action.lastModified ?? action.created;
+
+ if (lastModified < mostRecentReportActionLastModified) {
+ return;
+ }
+
+ mostRecentReportActionLastModified = lastModified;
+ });
+
+ // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get
+ // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these
+ Object.values(allReports ?? {}).forEach((report) => {
+ const reportLastVisibleActionLastModified = report?.lastVisibleActionLastModified ?? report?.lastVisibleActionCreated;
+ if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) {
+ return;
+ }
+
+ mostRecentReportActionLastModified = reportLastVisibleActionLastModified;
+ });
+
+ return mostRecentReportActionLastModified;
+}
+
+/**
+ * @returns The report preview action or `null` if one couldn't be found
+ */
+function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxEntry {
+ return (
+ Object.values(allReportActions?.[chatReportID] ?? {}).find(
+ (reportAction) => reportAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && reportAction.originalMessage.linkedReportID === iouReportID,
+ ) ?? null
+ );
+}
+
+/**
+ * Get the iouReportID for a given report action.
+ */
+function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? reportAction.originalMessage.linkedReportID : '';
+}
+
+function isCreatedTaskReportAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !!reportAction.originalMessage?.taskReportID;
+}
+
+/**
+ * A helper method to identify if the message is deleted or not.
+ */
+function isMessageDeleted(reportAction: OnyxEntry): boolean {
+ return reportAction?.message?.[0]?.isDeletedParentAction ?? false;
+}
+
+/**
+ * Returns the number of money requests associated with a report preview
+ */
+function getNumberOfMoneyRequests(reportPreviewAction: OnyxEntry): number {
+ return reportPreviewAction?.childMoneyRequestCount ?? 0;
+}
+
+function isSplitBillAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
+}
+
+function isTaskAction(reportAction: OnyxEntry): boolean {
+ const reportActionName = reportAction?.actionName;
+ return (
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ||
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED
+ );
+}
+
+function getAllReportActions(reportID: string): ReportActions {
+ return allReportActions?.[reportID] ?? {};
+}
+
+/**
+ * Check whether a report action is an attachment (a file, such as an image or a zip).
+ *
+ */
+function isReportActionAttachment(reportAction: OnyxEntry): boolean {
+ const message = reportAction?.message?.[0];
+
+ if (reportAction && 'isAttachment' in reportAction) {
+ return reportAction.isAttachment ?? false;
+ }
+
+ if (message) {
+ return isReportMessageAttachment(message);
+ }
+
+ return false;
+}
+
+// eslint-disable-next-line rulesdir/no-negated-variables
+function isNotifiableReportAction(reportAction: OnyxEntry