diff --git a/lib/utils/chat-thread-item-loader-cache.js b/lib/utils/chat-thread-item-loader-cache.js new file mode 100644 --- /dev/null +++ b/lib/utils/chat-thread-item-loader-cache.js @@ -0,0 +1,260 @@ +// @flow + +import invariant from 'invariant'; + +type ChatThreadPointer = + | { + +resolved: true, + +threadID: string, + +lastUpdatedTime: number, + } + | { + +resolved: false, + +threadID: string, + +lastUpdatedAtLeastTime: number, + +lastUpdatedAtMostTime: number, + }; + +const defaultNumItemsToDisplay = 25; + +function sortFuncForAtLeastTime(pointer: ChatThreadPointer): number { + return pointer.resolved + ? pointer.lastUpdatedTime + : pointer.lastUpdatedAtLeastTime; +} + +function sortFuncForAtMostTime(pointer: ChatThreadPointer): number { + return pointer.resolved + ? pointer.lastUpdatedTime + : pointer.lastUpdatedAtMostTime; +} + +function insertIntoSortedDescendingArray( + pointer: ChatThreadPointer, + sortedArray: Array, + sortFunc: ChatThreadPointer => number, +) { + let i = 0; + for (; i < sortedArray.length; i++) { + const itemAtIndex = sortedArray[i]; + if (sortFunc(pointer) > sortFunc(itemAtIndex)) { + break; + } + } + sortedArray.splice(i, 0, pointer); +} + +type BaseChatThreadItem = { + +lastUpdatedTimeIncludingSidebars: number, + ... +}; +type BaseChatThreadItemLoader = { + +threadInfo: { + +id: string, + ... + }, + +lastUpdatedAtLeastTimeIncludingSidebars: number, + +lastUpdatedAtMostTimeIncludingSidebars: number, + +initialChatThreadItem: T, + +getFinalChatThreadItem: () => Promise, +}; + +class ChatThreadItemLoaderCache< + Item: BaseChatThreadItem, + Loader: BaseChatThreadItemLoader = BaseChatThreadItemLoader, +> { + chatThreadItemLoaders: $ReadOnlyMap; + resolvedChatThreadItems: Map> = new Map(); + currentState: { + topNProcessed: number, + pointerListOrderedByAtLeastTime: $ReadOnlyArray, + pointerListOrderedByAtMostTime: $ReadOnlyArray, + }; + loadingState: ?{ + topNProcessed: number, + pointerListOrderedByAtLeastTime: Promise<$ReadOnlyArray>, + pointerListOrderedByAtMostTime: Promise<$ReadOnlyArray>, + }; + + constructor(loaders: $ReadOnlyArray) { + const chatThreadItemLoaders = new Map(); + for (const loader of loaders) { + chatThreadItemLoaders.set(loader.threadInfo.id, loader); + } + this.chatThreadItemLoaders = chatThreadItemLoaders; + this.currentState = { + topNProcessed: 0, + ...this.updateCurrentState(), + }; + } + + updateCurrentState(): { + +pointerListOrderedByAtLeastTime: $ReadOnlyArray, + +pointerListOrderedByAtMostTime: $ReadOnlyArray, + } { + const pointerListOrderedByAtLeastTime: Array = []; + const pointerListOrderedByAtMostTime: Array = []; + const loaders = this.chatThreadItemLoaders; + for (const threadID of loaders.keys()) { + let chatThreadPointer; + const resolved = this.resolvedChatThreadItems.get(threadID); + if (resolved && !(resolved instanceof Promise)) { + chatThreadPointer = { + resolved: true, + threadID, + lastUpdatedTime: resolved.lastUpdatedTimeIncludingSidebars, + }; + } else { + const loader = loaders.get(threadID); + invariant(loader, 'loader should be set during keys() iteration'); + chatThreadPointer = { + resolved: false, + threadID, + lastUpdatedAtLeastTime: + loader.lastUpdatedAtLeastTimeIncludingSidebars, + lastUpdatedAtMostTime: loader.lastUpdatedAtMostTimeIncludingSidebars, + }; + } + insertIntoSortedDescendingArray( + chatThreadPointer, + pointerListOrderedByAtLeastTime, + sortFuncForAtLeastTime, + ); + insertIntoSortedDescendingArray( + chatThreadPointer, + pointerListOrderedByAtMostTime, + sortFuncForAtMostTime, + ); + } + return { pointerListOrderedByAtLeastTime, pointerListOrderedByAtMostTime }; + } + + getChatThreadItemForThreadID(threadID: string): Item { + const resolved = this.resolvedChatThreadItems.get(threadID); + if (resolved && !(resolved instanceof Promise)) { + return resolved; + } + const loader = this.chatThreadItemLoaders.get(threadID); + invariant(loader, `loader should exist for threadID ${threadID}`); + return loader.initialChatThreadItem; + } + + getAllChatThreadItems(): Array { + return this.currentState.pointerListOrderedByAtLeastTime.map(pointer => + this.getChatThreadItemForThreadID(pointer.threadID), + ); + } + + loadMostRecent(n: number): Promise<$ReadOnlyArray> { + if (this.currentState.topNProcessed >= n) { + return Promise.resolve(this.currentState.pointerListOrderedByAtMostTime); + } else if (this.loadingState && this.loadingState.topNProcessed >= n) { + return this.loadingState.pointerListOrderedByAtMostTime; + } + + const getPromise = async () => { + let pointerList = this.currentState.pointerListOrderedByAtMostTime; + + while (true) { + // First, resolve the first n items + const pointerPromises: Array> = pointerList.map( + (pointer, i): mixed | Promise => { + if (i >= n || pointer.resolved) { + return undefined; + } + const { threadID } = pointer; + + const resolved = this.resolvedChatThreadItems.get(threadID); + if (resolved && resolved instanceof Promise) { + return resolved; + } else if (resolved) { + return undefined; + } + + const loader = this.chatThreadItemLoaders.get(threadID); + invariant(loader, `loader should exist for threadID ${threadID}`); + const promise = (async () => { + const finalChatThreadItemPromise = + loader.getFinalChatThreadItem(); + this.resolvedChatThreadItems.set( + threadID, + finalChatThreadItemPromise, + ); + const finalChatThreadItem = await finalChatThreadItemPromise; + this.resolvedChatThreadItems.set(threadID, finalChatThreadItem); + })(); + return promise; + }, + ); + await Promise.all(pointerPromises); + + // Next, reorder them + const { + pointerListOrderedByAtLeastTime, + pointerListOrderedByAtMostTime, + } = this.updateCurrentState(); + + // Decide if we need to continue + let firstNItemsResolved = true; + const numItems = Math.min(n, pointerListOrderedByAtMostTime.length); + for (let i = 0; i < numItems; i++) { + const pointer = pointerListOrderedByAtMostTime[i]; + if (!pointer.resolved) { + firstNItemsResolved = false; + break; + } + } + if ( + firstNItemsResolved && + this.loadingState && + n === this.loadingState.topNProcessed + ) { + this.currentState = { + topNProcessed: n, + pointerListOrderedByAtLeastTime, + pointerListOrderedByAtMostTime, + }; + this.loadingState = null; + break; + } + + pointerList = pointerListOrderedByAtMostTime; + } + }; + + let waitForOngoingThenFetchPromise; + if (!this.loadingState) { + waitForOngoingThenFetchPromise = getPromise(); + } else { + const oldPromise = this.loadingState.pointerListOrderedByAtMostTime; + waitForOngoingThenFetchPromise = (async () => { + await oldPromise; + await getPromise(); + })(); + } + + this.loadingState = { + topNProcessed: n, + pointerListOrderedByAtLeastTime: (async () => { + await waitForOngoingThenFetchPromise; + return this.currentState.pointerListOrderedByAtLeastTime; + })(), + pointerListOrderedByAtMostTime: (async () => { + await waitForOngoingThenFetchPromise; + return this.currentState.pointerListOrderedByAtMostTime; + })(), + }; + return this.loadingState.pointerListOrderedByAtMostTime; + } + + async loadMostRecentChatThreadItems( + n: number, + ): Promise<$ReadOnlyArray> { + await this.loadMostRecent(n); + return this.currentState.pointerListOrderedByAtMostTime.map(pointer => + this.getChatThreadItemForThreadID(pointer.threadID), + ); + } +} + +export { ChatThreadItemLoaderCache, defaultNumItemsToDisplay }; diff --git a/lib/utils/chat-thread-item-loader-cache.test.js b/lib/utils/chat-thread-item-loader-cache.test.js new file mode 100644 --- /dev/null +++ b/lib/utils/chat-thread-item-loader-cache.test.js @@ -0,0 +1,121 @@ +// @flow + +import { ChatThreadItemLoaderCache } from './chat-thread-item-loader-cache.js'; + +type MinChatThreadItem = { + +id: string, + +lastUpdatedTimeIncludingSidebars: number, +}; + +let cache = new ChatThreadItemLoaderCache([]); +const resetCache = () => { + cache = new ChatThreadItemLoaderCache([ + { + threadInfo: { + id: '0', + }, + lastUpdatedAtLeastTimeIncludingSidebars: 500, + lastUpdatedAtMostTimeIncludingSidebars: 500, + initialChatThreadItem: { + id: '0', + lastUpdatedTimeIncludingSidebars: 500, + }, + getFinalChatThreadItem: async () => ({ + id: '0', + lastUpdatedTimeIncludingSidebars: 500, + }), + }, + { + threadInfo: { + id: '1', + }, + lastUpdatedAtLeastTimeIncludingSidebars: 475, + lastUpdatedAtMostTimeIncludingSidebars: 525, + initialChatThreadItem: { + id: '1', + lastUpdatedTimeIncludingSidebars: 475, + }, + getFinalChatThreadItem: async () => ({ + id: '1', + lastUpdatedTimeIncludingSidebars: 525, + }), + }, + { + threadInfo: { + id: '3', + }, + lastUpdatedAtLeastTimeIncludingSidebars: 450, + lastUpdatedAtMostTimeIncludingSidebars: 450, + initialChatThreadItem: { + id: '3', + lastUpdatedTimeIncludingSidebars: 450, + }, + getFinalChatThreadItem: async () => ({ + id: '3', + lastUpdatedTimeIncludingSidebars: 450, + }), + }, + { + threadInfo: { + id: '4', + }, + lastUpdatedAtLeastTimeIncludingSidebars: 425, + lastUpdatedAtMostTimeIncludingSidebars: 480, + initialChatThreadItem: { + id: '4', + lastUpdatedTimeIncludingSidebars: 425, + }, + getFinalChatThreadItem: async () => ({ + id: '4', + lastUpdatedTimeIncludingSidebars: 480, + }), + }, + ]); +}; + +describe('getAllChatThreadItems', () => { + beforeAll(() => { + resetCache(); + }); + it('returns initial ordering', () => { + const items = cache.getAllChatThreadItems(); + expect(items[0].id === '0'); + expect(items[1].id === '1'); + expect(items[2].id === '2'); + expect(items[3].id === '3'); + }); +}); + +describe('loadMostRecentChatThreadItems', () => { + beforeAll(() => { + resetCache(); + }); + it('reorders items 0 and 1', async () => { + const items = await cache.loadMostRecentChatThreadItems(2); + expect(items[0].id === '1'); + expect(items[1].id === '0'); + expect(items[2].id === '2'); + expect(items[3].id === '3'); + }); + it('reorders all items', async () => { + const items = await cache.loadMostRecentChatThreadItems(4); + expect(items[0].id === '1'); + expect(items[1].id === '0'); + expect(items[2].id === '3'); + expect(items[3].id === '2'); + }); + it('calls loadMostRecentChatThreadItems twice in a row', async () => { + void (async () => { + const firstItems = await cache.loadMostRecentChatThreadItems(2); + expect(firstItems[0].id === '1'); + expect(firstItems[1].id === '0'); + expect(firstItems[2].id === '3'); + expect(firstItems[3].id === '2'); + })(); + const items = await cache.loadMostRecentChatThreadItems(4); + expect(items[0].id === '1'); + expect(items[1].id === '0'); + expect(items[2].id === '3'); + expect(items[3].id === '2'); + }); +});