diff --git a/src/app/inventory/actions.ts b/src/app/inventory/actions.ts index 9590879f0f..872f252ffc 100644 --- a/src/app/inventory/actions.ts +++ b/src/app/inventory/actions.ts @@ -11,6 +11,7 @@ import { DestinyItemChangeResponse, DestinyProfileResponse, } from 'bungie-api-ts/destiny2'; +import { BucketHashes } from 'data/d2/generated-enums'; import { createAction } from 'typesafe-actions'; import { TagCommand, TagValue } from './dim-item-info'; import { DimItem } from './item-types'; @@ -65,9 +66,11 @@ export const error = createAction('inventory/ERROR')(); * An item has moved (or equipped/dequipped) */ export const itemMoved = createAction('inventory/MOVE_ITEM')<{ - item: DimItem; - source: DimStore; - target: DimStore; + itemHash: number; + itemId: string; + itemLocation: BucketHashes; + sourceId: string; + targetId: string; equip: boolean; amount: number; }>(); diff --git a/src/app/inventory/cross-tab.ts b/src/app/inventory/cross-tab.ts new file mode 100644 index 0000000000..142183246a --- /dev/null +++ b/src/app/inventory/cross-tab.ts @@ -0,0 +1,59 @@ +import { infoLog } from 'app/utils/log'; +import { BucketHashes } from 'data/d2/generated-enums'; +import { useCallback, useEffect } from 'react'; + +export const crossTabChannel = + 'BroadcastChannel' in globalThis ? new BroadcastChannel('dim') : undefined; + +export interface StoreUpdatedMessage { + type: 'stores-updated'; +} + +export interface ItemMovedMessage { + type: 'item-moved'; + itemHash: number; + itemId: string; + itemLocation: BucketHashes; + sourceId: string; + targetId: string; + equip: boolean; + amount: number; +} + +// TODO: other inventory changes, dim api changes, etc. + +export type CrossTabMessage = StoreUpdatedMessage | ItemMovedMessage; + +export function useCrossTabUpdates(callback: (m: CrossTabMessage) => void) { + const onMsg = useCallback( + (m: MessageEvent) => { + const message = m.data; + infoLog('cross-tab', 'message', message.type, message); + if (message.type) { + callback(message); + } + }, + [callback], + ); + useEffect(() => { + if (!crossTabChannel) { + return; + } + crossTabChannel.addEventListener('message', onMsg); + return () => crossTabChannel.removeEventListener('message', onMsg); + }, [onMsg]); +} + +export function notifyOtherTabsStoreUpdated() { + if (!crossTabChannel) { + return; + } + crossTabChannel.postMessage({ type: 'stores-updated' } satisfies StoreUpdatedMessage); +} + +export function notifyOtherTabsItemMoved(args: Omit) { + if (!crossTabChannel) { + return; + } + crossTabChannel.postMessage({ type: 'item-moved', ...args } satisfies ItemMovedMessage); +} diff --git a/src/app/inventory/d2-stores.ts b/src/app/inventory/d2-stores.ts index acf815ad2c..7dc97a5bf5 100644 --- a/src/app/inventory/d2-stores.ts +++ b/src/app/inventory/d2-stores.ts @@ -34,6 +34,7 @@ import { profileLoaded, update, } from './actions'; +import { notifyOtherTabsStoreUpdated } from './cross-tab'; import { cleanInfos } from './dim-item-info'; import { d2BucketsSelector, storesLoadedSelector } from './selectors'; import { DimStore } from './store-types'; @@ -109,29 +110,59 @@ let firstTime = true; /** * Returns a promise for a fresh view of the stores and their items. */ -export function loadStores(): ThunkResult { +export function loadStores({ + fromOtherTab = false, +}: { + fromOtherTab?: boolean; +} = {}): ThunkResult { return async (dispatch, getState) => { - let account = currentAccountSelector(getState()); - if (!account) { - // TODO: throw here? - await dispatch(getPlatforms); - account = currentAccountSelector(getState()); - if (!account || account.destinyVersion !== 2) { - return; - } - } + try { + let stores: DimStore[] | undefined; + await navigator.locks.request( + 'loadStores', + { + // If another tab is working on it, don't wait. The callback will get a null lock. + ifAvailable: true, + mode: 'exclusive', + }, + async (lock) => { + if (!lock) { + infoLog('cross-tab', 'Another tab is already loading stores'); + // This means another tab was already requesting the stores. + throw new Error('lock-held'); + } + let account = currentAccountSelector(getState()); + if (!account) { + // TODO: throw here? + await dispatch(getPlatforms); + account = currentAccountSelector(getState()); + if (!account || account.destinyVersion !== 2) { + return; + } + } - dispatch(loadCoreSettings()); // no need to wait - $featureFlags.clarityDescriptions && dispatch(loadClarity()); // no need to await - await dispatch(loadNewItems(account)); - // The first time we load, allow the data to be loaded from IDB. We then do a second - // load to make sure that we immediately try to get remote data. - if (firstTime) { - await dispatch(loadStoresData(account, firstTime)); - firstTime = false; + dispatch(loadCoreSettings()); // no need to wait + $featureFlags.clarityDescriptions && dispatch(loadClarity()); // no need to await + await dispatch(loadNewItems(account)); + // The first time we load, allow the data to be loaded from IDB. We then do a second + // load to make sure that we immediately try to get remote data. + if (firstTime) { + await dispatch(loadStoresData(account, { firstTime, fromOtherTab })); + firstTime = false; + } + stores = await dispatch(loadStoresData(account, { firstTime, fromOtherTab })); + }, + ); + // Need to do this after the lock has been released + if (!firstTime && stores !== undefined && !fromOtherTab) { + notifyOtherTabsStoreUpdated(); + } + return stores; + } catch (e) { + if (!(e instanceof Error) || e.message !== 'lock-held') { + throw e; + } } - const stores = await dispatch(loadStoresData(account, firstTime)); - return stores; }; } @@ -140,7 +171,13 @@ const FRESH_ENOUGH_TO_CLEAN_INFOS = 90_000; // 90 seconds function loadProfile( account: DestinyAccount, - firstTime: boolean, + { + firstTime, + fromOtherTab, + }: { + firstTime: boolean; + fromOtherTab: boolean; + }, ): ThunkResult< | { profile: DestinyProfileResponse; @@ -160,21 +197,29 @@ function loadProfile( // First try loading from IndexedDB let cachedProfileResponse = getState().inventory.profileResponse; - if (!cachedProfileResponse) { + // TODO: always check IDB, in case another tab loaded it? + if (!cachedProfileResponse || fromOtherTab) { try { cachedProfileResponse = await get(cachedProfileKey); // Check to make sure the profile hadn't been loaded in the meantime - if (getState().inventory.profileResponse) { + if (!fromOtherTab && getState().inventory.profileResponse) { cachedProfileResponse = getState().inventory.profileResponse; } else if (cachedProfileResponse) { const profileAgeSecs = (Date.now() - new Date(cachedProfileResponse.responseMintedTimestamp ?? 0).getTime()) / 1000; - infoLog( - TAG, - `Loaded cached profile from IndexedDB, using it until new data is available. It is ${profileAgeSecs}s old.`, - ); - dispatch(profileLoaded({ profile: cachedProfileResponse, live: false })); + if (fromOtherTab) { + infoLog( + TAG, + `Loaded cached profile from IndexedDB because another tab updated it. It is ${profileAgeSecs}s old.`, + ); + } else { + infoLog( + TAG, + `Loaded cached profile from IndexedDB, using it until new data is available. It is ${profileAgeSecs}s old.`, + ); + } + dispatch(profileLoaded({ profile: cachedProfileResponse, live: fromOtherTab })); // The first time we load, just use the IDB version if we can, to speed up loading if (firstTime) { return { profile: cachedProfileResponse, live: false }; @@ -233,7 +278,7 @@ function loadProfile( ); } - set(cachedProfileKey, remoteProfileResponse); // don't await + await set(cachedProfileKey, remoteProfileResponse); dispatch(profileLoaded({ profile: remoteProfileResponse, live: true })); return { profile: remoteProfileResponse, live: true }; } catch (e) { @@ -256,7 +301,10 @@ let lastCheckedManifest = 0; function loadStoresData( account: DestinyAccount, - firstTime: boolean, + profileArgs: { + firstTime: boolean; + fromOtherTab: boolean; + }, ): ThunkResult { return async (dispatch, getState) => { const promise = (async () => { @@ -271,7 +319,7 @@ function loadStoresData( try { const [originalDefs, profileInfo] = await Promise.all([ dispatch(getDefinitions()), - dispatch(loadProfile(account, firstTime)), + dispatch(loadProfile(account, profileArgs)), ]); let defs = originalDefs; diff --git a/src/app/inventory/item-move-service.ts b/src/app/inventory/item-move-service.ts index aa64d27c25..4bf29f61a3 100644 --- a/src/app/inventory/item-move-service.ts +++ b/src/app/inventory/item-move-service.ts @@ -38,6 +38,7 @@ import { reverseComparator, } from '../utils/comparators'; import { itemLockStateChanged, itemMoved } from './actions'; +import { notifyOtherTabsItemMoved } from './cross-tab'; import { TagValue, characterDisplacePriority, @@ -166,8 +167,19 @@ function updateItemModel( startSpan({ name: 'updateItemModel' }, () => { const stopTimer = timer(TAG, 'itemMovedUpdate'); + const args = { + itemId: item.id, + itemHash: item.hash, + itemLocation: item.location.hash, + sourceId: source.id, + targetId: target.id, + equip, + amount, + }; + try { - dispatch(itemMoved({ item, source, target, equip, amount })); + dispatch(itemMoved(args)); + notifyOtherTabsItemMoved(args); const stores = storesSelector(getState()); return getItemAcrossStores(stores, item) || item; } finally { diff --git a/src/app/inventory/reducer.ts b/src/app/inventory/reducer.ts index 4e12b7d80e..34fd60a4ac 100644 --- a/src/app/inventory/reducer.ts +++ b/src/app/inventory/reducer.ts @@ -36,7 +36,6 @@ export interface InventoryState { readonly currencies: AccountCurrency[]; readonly profileResponse?: DestinyProfileResponse; - readonly profileError?: Error; /** @@ -83,8 +82,10 @@ export const inventory: Reducer itemMoved(draft, item, source.id, target.id, equip, amount)); + const { itemId, itemHash, itemLocation, sourceId, targetId, equip, amount } = action.payload; + return produce(state, (draft) => + itemMoved(draft, itemHash, itemId, itemLocation, sourceId, targetId, equip, amount), + ); } case getType(actions.itemLockStateChanged): { @@ -282,7 +283,9 @@ function setsEqual(first: Set, second: Set) { */ function itemMoved( draft: Draft, - item: DimItem, + itemHash: number, + itemId: string, + itemLocation: BucketHashes, sourceStoreId: string, targetStoreId: string, equip: boolean, @@ -297,8 +300,8 @@ function itemMoved( return; } - item = source.items.find( - (i) => i.hash === item.hash && i.id === item.id && i.location.hash === item.location.hash, + let item = source.items.find( + (i) => i.hash === itemHash && i.id === itemId && i.location.hash === itemLocation, )!; if (!item) { warnLog(TAG, 'Moved item not found', item); diff --git a/src/app/inventory/store/hooks.ts b/src/app/inventory/store/hooks.ts index 25b9c8a43e..27a3b363c8 100644 --- a/src/app/inventory/store/hooks.ts +++ b/src/app/inventory/store/hooks.ts @@ -4,6 +4,8 @@ import { useThunkDispatch } from 'app/store/thunk-dispatch'; import { useEventBusListener } from 'app/utils/hooks'; import { useCallback, useEffect } from 'react'; import { useSelector } from 'react-redux'; +import { itemMoved } from '../actions'; +import { CrossTabMessage, useCrossTabUpdates } from '../cross-tab'; import { loadStores as d1LoadStores } from '../d1-stores'; import { loadStores as d2LoadStores } from '../d2-stores'; import { storesLoadedSelector } from '../selectors'; @@ -40,5 +42,25 @@ export function useLoadStores(account: DestinyAccount | undefined) { }, [account, dispatch]), ); + const onMessage = useCallback( + (msg: CrossTabMessage) => { + switch (msg.type) { + case 'stores-updated': + // This is only implemented for D2 + if (account?.destinyVersion === 2) { + return dispatch(d2LoadStores({ fromOtherTab: true })); + } + break; + case 'item-moved': + if (account?.destinyVersion === 2) { + dispatch(itemMoved(msg)); + } + break; + } + }, + [account?.destinyVersion, dispatch], + ); + useCrossTabUpdates(onMessage); + return loaded; } diff --git a/src/app/loadout/ingame/ingame-loadout-apply.ts b/src/app/loadout/ingame/ingame-loadout-apply.ts index 375927b7c0..b5c90c65c6 100644 --- a/src/app/loadout/ingame/ingame-loadout-apply.ts +++ b/src/app/loadout/ingame/ingame-loadout-apply.ts @@ -63,7 +63,17 @@ export function updateAfterInGameLoadoutApply(loadout: InGameLoadout): ThunkResu // Update items to be equipped // TODO: we don't get updated mod states :-( https://github.com/Bungie-net/api/issues/1792 const source = getStore(stores, item.owner)!; - dispatch(itemMoved({ item, source, target, equip: true, amount: 1 })); + dispatch( + itemMoved({ + itemHash: item.hash, + itemId: item.id, + itemLocation: item.location.hash, + sourceId: source.id, + targetId: target.id, + equip: true, + amount: 1, + }), + ); // TODO: update the item model to have the right mods plugged. Hard to do // this without knowing more about the loadouts structure. diff --git a/src/locale/en.json b/src/locale/en.json index dd2288b1f8..bbd2b782b9 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -1119,7 +1119,6 @@ "RecordValue": "{{value}}pts", "Resets": "1 reset", "Resets_plural": "{{count}} resets", - "SecretTriumph": "Secret Triumph", "StatTrackers": "Stat Trackers", "TrackedTriumphs": "Tracked Triumphs", "VanguardPathfinder": "Vanguard Pathfinder"