Skip to content

Commit

Permalink
Communicate store refresh and item moves across tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
bhollis committed Jan 26, 2025
1 parent d6e8fd0 commit 68bd4b8
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 44 deletions.
9 changes: 6 additions & 3 deletions src/app/inventory/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -65,9 +66,11 @@ export const error = createAction('inventory/ERROR')<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;
}>();
Expand Down
59 changes: 59 additions & 0 deletions src/app/inventory/cross-tab.ts
Original file line number Diff line number Diff line change
@@ -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<CrossTabMessage>) => {
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<ItemMovedMessage, 'type'>) {
if (!crossTabChannel) {
return;
}
crossTabChannel.postMessage({ type: 'item-moved', ...args } satisfies ItemMovedMessage);
}
112 changes: 80 additions & 32 deletions src/app/inventory/d2-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -109,40 +110,76 @@ let firstTime = true;
/**
* Returns a promise for a fresh view of the stores and their items.
*/
export function loadStores(): ThunkResult<DimStore[] | undefined> {
export function loadStores({
fromOtherTab = false,
}: {
fromOtherTab?: boolean;
} = {}): ThunkResult<DimStore[] | undefined> {
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;
};
}

/** time in milliseconds after which we could expect Bnet to return an updated response */
const BUNGIE_CACHE_TTL = 15_000;
const BUNGIE_CACHE_TTL = 15_000; // TODO: use a latch
/** How old the profile can be and still trigger cleanup of tags. */
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;
Expand All @@ -162,21 +199,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<DestinyProfileResponse>(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 };
Expand Down Expand Up @@ -248,7 +293,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) {
Expand All @@ -271,7 +316,10 @@ let lastCheckedManifest = 0;

function loadStoresData(
account: DestinyAccount,
firstTime: boolean,
profileArgs: {
firstTime: boolean;
fromOtherTab: boolean;
},
): ThunkResult<DimStore[] | undefined> {
return async (dispatch, getState) => {
const promise = (async () => {
Expand All @@ -286,7 +334,7 @@ function loadStoresData(
try {
const [originalDefs, profileInfo] = await Promise.all([
dispatch(getDefinitions()),
dispatch(loadProfile(account, firstTime)),
dispatch(loadProfile(account, profileArgs)),
]);

let defs = originalDefs;
Expand Down
14 changes: 13 additions & 1 deletion src/app/inventory/item-move-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
reverseComparator,
} from '../utils/comparators';
import { itemLockStateChanged, itemMoved } from './actions';
import { notifyOtherTabsItemMoved } from './cross-tab';
import {
TagValue,
characterDisplacePriority,
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 9 additions & 6 deletions src/app/inventory/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export interface InventoryState {
readonly currencies: AccountCurrency[];

readonly profileResponse?: DestinyProfileResponse;

readonly profileError?: Error;

/**
Expand Down Expand Up @@ -83,8 +82,10 @@ export const inventory: Reducer<InventoryState, InventoryAction | AccountsAction
return updateCharacters(state, action.payload);

case getType(actions.itemMoved): {
const { item, source, target, equip, amount } = action.payload;
return produce(state, (draft) => 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): {
Expand Down Expand Up @@ -282,7 +283,9 @@ function setsEqual<T>(first: Set<T>, second: Set<T>) {
*/
function itemMoved(
draft: Draft<InventoryState>,
item: DimItem,
itemHash: number,
itemId: string,
itemLocation: BucketHashes,
sourceStoreId: string,
targetStoreId: string,
equip: boolean,
Expand All @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/app/inventory/store/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
12 changes: 11 additions & 1 deletion src/app/loadout/ingame/ingame-loadout-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 68bd4b8

Please sign in to comment.