diff --git a/package-lock.json b/package-lock.json index 5cc8fce9f7e1..e9fd2f3ddd05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -272,7 +272,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "type-fest": "4.20.0", + "type-fest": "4.34.1", "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.94.0", @@ -36147,7 +36147,9 @@ } }, "node_modules/type-fest": { - "version": "4.20.0", + "version": "4.34.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.34.1.tgz", + "integrity": "sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { diff --git a/package.json b/package.json index 8eec083cc0f4..9dacd650db17 100644 --- a/package.json +++ b/package.json @@ -339,7 +339,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "type-fest": "4.20.0", + "type-fest": "4.34.1", "typescript": "^5.4.5", "wait-port": "^0.2.9", "webpack": "^5.94.0", diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index dce55b29b876..599406e5d80f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,3 +1,4 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; import type {OnboardingCompanySize} from './CONST'; @@ -1088,14 +1089,33 @@ type OnyxCollectionKey = keyof OnyxCollectionValuesMapping; type OnyxFormKey = keyof OnyxFormValuesMapping; type OnyxFormDraftKey = keyof OnyxFormDraftValuesMapping; type OnyxValueKey = keyof OnyxValuesMapping; - type OnyxKey = OnyxValueKey | OnyxCollectionKey | OnyxFormKey | OnyxFormDraftKey; type OnyxPagesKey = typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES; +type GetOnyxTypeForKey = + // Forms (and draft forms) behave like value keys + K extends OnyxFormKey + ? OnyxEntry + : K extends OnyxFormDraftKey + ? OnyxEntry + : // Plain non-collection values + K extends OnyxValueKey + ? OnyxEntry + : // Exactly matching a collection key returns a collection + K extends OnyxCollectionKey + ? OnyxCollection + : // Otherwise, if K is a string that starts with one of the collection keys, + // return an entry for that collection’s value type. + K extends string + ? { + [X in OnyxCollectionKey]: K extends `${X}${string}` ? OnyxEntry : never; + }[OnyxCollectionKey] + : never; + type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: ${Exclude}`; /** If this type errors, it means that the `OnyxKey` type is missing some keys. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars type AssertOnyxKeys = AssertTypesEqual; export default ONYXKEYS; -export type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxFormDraftKey, OnyxFormKey, OnyxFormValuesMapping, OnyxKey, OnyxPagesKey, OnyxValueKey, OnyxValues}; +export type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxFormDraftKey, OnyxFormKey, OnyxFormValuesMapping, OnyxKey, OnyxPagesKey, OnyxValueKey, OnyxValues, GetOnyxTypeForKey}; diff --git a/src/hooks/useOnyxDerivedValue.ts b/src/hooks/useOnyxDerivedValue.ts new file mode 100644 index 000000000000..17f534534161 --- /dev/null +++ b/src/hooks/useOnyxDerivedValue.ts @@ -0,0 +1,18 @@ +import {useSyncExternalStore} from 'react'; +import type {ValueOf} from 'type-fest'; +import type {ONYX_DERIVED_VALUES} from '@libs/OnyxDerived'; +import OnyxDerived from '@libs/OnyxDerived'; + +/** + * Hook that subscribes to a derived Onyx value. + * + * Pass in one of the derived configs from ONYX_DERIVED_VALUES. + * This hook uses React's useSyncExternalStore to subscribe to the derived store, + * so that your component re-renders only when the derived value changes. + */ +function useDerivedOnyxValue(config: ValueOf): ReturnType { + const store = OnyxDerived.getDerivedValueStore(config); + return useSyncExternalStore(store.subscribe, () => store.currentValue); +} + +export default useDerivedOnyxValue; diff --git a/src/libs/OnyxDerived.ts b/src/libs/OnyxDerived.ts new file mode 100644 index 000000000000..e77ae9395bfe --- /dev/null +++ b/src/libs/OnyxDerived.ts @@ -0,0 +1,199 @@ +import Onyx from 'react-native-onyx'; +import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; +import type {NonEmptyTuple, ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type {GetOnyxTypeForKey, OnyxKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {isThread} from './ReportUtils'; + +/** + * A derived value configuration describes: + * - a unique key, + * - a tuple of Onyx keys to subscribe to (dependencies), + * - a compute function that derives a value from the dependent Onyx values. + * The compute function receives a single argument that's a tuple of the onyx values for the declared dependencies. + * For example, if your dependencies are `['report_', 'account'], then compute will receive a [OnyxCollection, OnyxEntry] + */ +type OnyxDerivedValueConfig> = { + key: Key; + dependencies: Deps; + compute: (args: { + -readonly [Index in keyof Deps]: GetOnyxTypeForKey; + }) => unknown; +}; + +/** + * Helper function to create a derived value config. This function is just here to help TypeScript infer Deps, so instead of writing this: + * + * const conciergeChatReportIDConfig: OnyxDerivedValueConfig = { + * dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.CONCIERGE_REPORT_ID], + * ... + * }; + * + * We can just write this: + * + * const conciergeChatReportIDConfig = createOnyxDerivedValueConfig({ + * dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.CONCIERGE_REPORT_ID] + * }) + */ +function createOnyxDerivedValueConfig>(config: OnyxDerivedValueConfig): OnyxDerivedValueConfig { + return config; +} + +/** + * Global map of derived configs. + * This object holds our derived value configurations. + */ +const ONYX_DERIVED_VALUES = { + CONCIERGE_CHAT_REPORT_ID: createOnyxDerivedValueConfig({ + key: 'conciergeChatReportID', + dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.CONCIERGE_REPORT_ID], + compute: ([reports, conciergeChatReportID]) => { + if (!reports) { + return null; + } + + const conciergeReport = Object.values(reports).find((report) => { + if (!report?.participants || isThread(report)) { + return false; + } + + const participantAccountIDs = new Set(Object.keys(report.participants)); + if (participantAccountIDs.size !== 2) { + return false; + } + + return participantAccountIDs.has(CONST.ACCOUNT_ID.CONCIERGE.toString()) || report?.reportID === conciergeChatReportID; + }); + + return conciergeReport?.reportID; + }, + }), +} as const; + +/** + * Interface for a derived value store. + * + * A derived value store holds the current derived value and a subscribe function. + * When any dependency updates, the store recalculates the derived value and notifies its subscribers. + */ +type DerivedValueStore = { + currentValue: T; + subscribe: (cb: () => void) => () => void; +}; + +/** + * Get the union of all derived config keys from our ONYX_DERIVED_VALUES map. + */ +type DerivedValueStoreKey = ValueOf['key']; + +/** + * This type maps each config key (from ONYX_DERIVED_VALUES) + * to a DerivedValueStore whose currentValue type is the return type of that config's compute function. + */ +type DerivedValueStoreMap = { + [K in DerivedValueStoreKey]: DerivedValueStore< + ReturnType< + // Extract from the union of derived configs the one with key K, then get its compute function. + Extract, {key: K}>['compute'] + > + >; +}; + +/** + * Global object holding our derived value stores. + * We use a Partial so that we can lazily create each store when needed. + */ +const derivedValueStores: Partial = {}; + +/** + * Creates a new derived value store for a given config. + * + * For each dependency key in the config, we create an array (currentOnyxValues) + * that will hold the latest Onyx values. Then we call Onyx.connect for each dependency, + * updating the corresponding entry in currentOnyxValues whenever the value changes. + * After each update, we recalculate the derived value using the config.compute function. + */ +function createDerivedValueStore(config: ValueOf): DerivedValueStore> { + // Create an array to hold the current values for each dependency. + // We cast its type to match the tuple expected by config.compute. + const currentOnyxValues = new Array(config.dependencies.length) as Parameters[0]; + let derivedValue = config.compute(currentOnyxValues); + + const subscribers = new Set<() => void>(); + + // Function to re-calculate the derived value and notify subscribers if it changes. + function recomputeValue() { + const newDerivedValue = config.compute(currentOnyxValues); + // If the derived value has changed, notify all subscribers. + if (newDerivedValue !== derivedValue) { + derivedValue = newDerivedValue; + for (const subscriber of subscribers) { + subscriber(); + } + } + } + + // Create Onyx subscriptions for each dependency. + const connections = config.dependencies.map((onyxKey, index) => + OnyxUtils.isCollectionKey(onyxKey) + ? Onyx.connect({ + key: onyxKey, + waitForCollectionCallback: true, + callback: (value) => { + currentOnyxValues[index] = value; + recomputeValue(); + }, + }) + : Onyx.connect({ + key: onyxKey, + callback: (value) => { + currentOnyxValues[index] = value; + recomputeValue(); + }, + }), + ); + + return { + get currentValue() { + return derivedValue; + }, + subscribe(cb: () => void) { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + if (subscribers.size === 0) { + for (const connection of connections) { + Onyx.disconnect(connection); + } + } + }; + }, + }; +} + +/** + * Retrieves (or creates if necessary) the derived value store for the given config. + * + * We use the config's literal key to index into our global derivedValueStores object. + * If a store for that key doesn't exist yet, we create it. + */ +function getDerivedValueStore(config: ValueOf) { + let store = derivedValueStores[config.key]; + if (!store) { + store = createDerivedValueStore(config); + derivedValueStores[config.key] = store; + } + return store; +} + +function get(config: ValueOf) { + const store = getDerivedValueStore(config); + return store.currentValue; +} + +export {ONYX_DERIVED_VALUES}; +export default { + get, + getDerivedValueStore, +}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f0defab02586..8701dbebe400 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -16,6 +16,7 @@ import {FallbackAvatar, IntacctSquare, NetSuiteSquare, NSQSSquare, QBOSquare, Xe import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; import type {MoneyRequestAmountInputProps} from '@components/MoneyRequestAmountInput'; +import OnyxDerived, {ONYX_DERIVED_VALUES} from '@libs/OnyxDerived'; import type {IOUAction, IOUType, OnboardingPurpose} from '@src/CONST'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; @@ -728,12 +729,6 @@ let isAnonymousUser = false; // Example case: when we need to get a report name of a thread which is dependent on a report action message. const parsedReportActionMessageCache: Record = {}; -let conciergeChatReportID: string | undefined; -Onyx.connect({ - key: ONYXKEYS.CONCIERGE_REPORT_ID, - callback: (value) => (conciergeChatReportID = value), -}); - const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon'; Onyx.connect({ key: ONYXKEYS.SESSION, @@ -1519,21 +1514,11 @@ function getReportNotificationPreference(report: OnyxEntry): ValueOf): boolean { - if (!report?.participants || isThread(report)) { - return false; - } - - const participantAccountIDs = new Set(Object.keys(report.participants)); - if (participantAccountIDs.size !== 2) { - return false; - } - - return participantAccountIDs.has(CONCIERGE_ACCOUNT_ID_STRING) || report?.reportID === conciergeChatReportID; + return !!report && report?.reportID === OnyxDerived.get(ONYX_DERIVED_VALUES.CONCIERGE_CHAT_REPORT_ID); } function findSelfDMReportID(): string | undefined {