diff --git a/src/channel_manager.ts b/src/channel_manager.ts index b0de0b645a..7a419a0af7 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -19,6 +19,7 @@ import { promoteChannel, shouldConsiderArchivedChannels, shouldConsiderPinnedChannels, + uniqBy, } from './utils'; export type ChannelManagerPagination = { @@ -275,7 +276,7 @@ export class ChannelManager { }; public loadNext = async () => { - const { pagination, channels, initialized } = this.state.getLatestValue(); + const { pagination, initialized } = this.state.getLatestValue(); const { filters, sort, options, isLoadingNext, hasNext } = pagination; if (!initialized || isLoadingNext || !hasNext) { @@ -288,11 +289,12 @@ export class ChannelManager { pagination: { ...pagination, isLoading: false, isLoadingNext: true }, }); const nextChannels = await this.client.queryChannels(filters, sort, options, this.stateOptions); + const { channels } = this.state.getLatestValue(); const newOffset = offset + (nextChannels?.length ?? 0); const newOptions = { ...options, offset: newOffset }; this.state.partialNext({ - channels: [...(channels || []), ...nextChannels], + channels: uniqBy>([...(channels || []), ...nextChannels], 'cid'), pagination: { ...pagination, hasNext: (nextChannels?.length ?? 0) >= limit, @@ -313,9 +315,11 @@ export class ChannelManager { private notificationAddedToChannelHandler = async (event: Event) => { const { id, type, members } = event?.channel ?? {}; + if (!type || !this.options.allowNotLoadedChannelPromotionForEvent?.['notification.added_to_channel']) { return; } + const channel = await getAndWatchChannel({ client: this.client, id, @@ -328,6 +332,7 @@ export class ChannelManager { }, []), type, }); + const { pagination, channels } = this.state.getLatestValue(); if (!channels) { return; @@ -415,10 +420,7 @@ export class ChannelManager { private notificationNewMessageHandler = async (event: Event) => { const { id, type } = event?.channel ?? {}; - const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = pagination ?? {}; - - if (!channels || !id || !type) { + if (!id || !type) { return; } @@ -428,10 +430,14 @@ export class ChannelManager { type, }); + const { channels, pagination } = this.state.getLatestValue(); + const { filters, sort } = pagination ?? {}; + const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); if ( + !channels || (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['notification.message_new'] @@ -449,11 +455,9 @@ export class ChannelManager { }; private channelVisibleHandler = async (event: Event) => { - const { channels, pagination } = this.state.getLatestValue(); - const { sort, filters } = pagination ?? {}; const { channel_type: channelType, channel_id: channelId } = event; - if (!channels || !channelType || !channelId) { + if (!channelType || !channelId) { return; } @@ -463,10 +467,14 @@ export class ChannelManager { type: event.channel_type, }); + const { channels, pagination } = this.state.getLatestValue(); + const { sort, filters } = pagination ?? {}; + const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); if ( + !channels || (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['channel.visible'] diff --git a/src/utils.ts b/src/utils.ts index 886cb6cf33..5691c25122 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -602,6 +602,27 @@ export const throttle = unknown>( }; }; +const get = (obj: T, path: string): unknown => + path.split('.').reduce((acc, key) => { + if (acc && typeof acc === 'object' && key in acc) { + return (acc as Record)[key]; + } + return undefined; + }, obj); + +// works exactly the same as lodash.uniqBy +export const uniqBy = (array: T[] | unknown, iteratee: ((item: T) => unknown) | keyof T): T[] => { + if (!Array.isArray(array)) return []; + + const seen = new Set(); + return array.filter((item) => { + const key = typeof iteratee === 'function' ? iteratee(item) : get(item, iteratee as string); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + type MessagePaginationUpdatedParams = { parentSet: MessageSet; requestedPageSize: number; diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 9d3f1b96b2..cd61a338c6 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -336,7 +336,7 @@ describe('ChannelManager', () => { }); clientQueryChannelsStub = sinon.stub(client, 'queryChannels').callsFake((_filters, _sort, options) => { const offset = options?.offset ?? 0; - return Promise.resolve(mockChannelPages[offset / 10]); + return Promise.resolve(mockChannelPages[Math.floor(offset / 10)]); }); }); @@ -563,6 +563,153 @@ describe('ChannelManager', () => { expect(channels.length).to.equal(20); }); + it('should properly paginate even if state.channels gets modified in the meantime', async () => { + await channelManager.queryChannels({ filterA: true }, { asc: 1 }, { limit: 10, offset: 0 }); + channelManager.state.next((prevState) => ({ + ...prevState, + channels: [...mockChannelPages[2].slice(0, 5), ...prevState.channels], + })); + + const stateChangeSpy = sinon.spy(); + channelManager.state.subscribeWithSelector( + (nextValue) => ({ pagination: nextValue.pagination }), + stateChangeSpy, + ); + + stateChangeSpy.resetHistory(); + + await channelManager.loadNext(); + + const { channels } = channelManager.state.getLatestValue(); + + expect(clientQueryChannelsStub.callCount).to.equal(2); + expect(stateChangeSpy.callCount).to.equal(2); + expect(stateChangeSpy.args[0][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: true, + isLoading: false, + isLoadingNext: true, + options: { limit: 10, offset: 10 }, + sort: { asc: 1 }, + }, + }); + expect(stateChangeSpy.args[1][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: true, + isLoading: false, + isLoadingNext: false, + options: { limit: 10, offset: 20 }, + sort: { asc: 1 }, + }, + }); + expect(channels.length).to.equal(25); + }); + + it('should properly deduplicate when paginating if channels from the next page have been promoted', async () => { + await channelManager.queryChannels({ filterA: true }, { asc: 1 }, { limit: 10, offset: 0 }); + channelManager.state.next((prevState) => ({ + ...prevState, + channels: [...mockChannelPages[1].slice(0, 5), ...prevState.channels], + })); + + const stateChangeSpy = sinon.spy(); + channelManager.state.subscribeWithSelector( + (nextValue) => ({ pagination: nextValue.pagination }), + stateChangeSpy, + ); + + stateChangeSpy.resetHistory(); + + await channelManager.loadNext(); + + const { channels } = channelManager.state.getLatestValue(); + + expect(clientQueryChannelsStub.callCount).to.equal(2); + expect(stateChangeSpy.callCount).to.equal(2); + expect(stateChangeSpy.args[0][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: true, + isLoading: false, + isLoadingNext: true, + options: { limit: 10, offset: 10 }, + sort: { asc: 1 }, + }, + }); + expect(stateChangeSpy.args[1][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: true, + isLoading: false, + isLoadingNext: false, + options: { limit: 10, offset: 20 }, + sort: { asc: 1 }, + }, + }); + expect(channels.length).to.equal(20); + }); + + it('should properly deduplicate when paginating if channels latter pages have been promoted and reached', async () => { + await channelManager.queryChannels({ filterA: true }, { asc: 1 }, { limit: 10, offset: 0 }); + channelManager.state.next((prevState) => ({ + ...prevState, + channels: [...mockChannelPages[2].slice(0, 3), ...prevState.channels], + })); + + const stateChangeSpy = sinon.spy(); + channelManager.state.subscribeWithSelector( + (nextValue) => ({ pagination: nextValue.pagination }), + stateChangeSpy, + ); + + stateChangeSpy.resetHistory(); + + await channelManager.loadNext(); + + const { channels: channelsAfterFirstPagination } = channelManager.state.getLatestValue(); + expect(channelsAfterFirstPagination.length).to.equal(23); + + await channelManager.loadNext(); + + const { channels } = channelManager.state.getLatestValue(); + + expect(clientQueryChannelsStub.callCount).to.equal(3); + expect(stateChangeSpy.callCount).to.equal(4); + expect(stateChangeSpy.args[0][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: true, + isLoading: false, + isLoadingNext: true, + options: { limit: 10, offset: 10 }, + sort: { asc: 1 }, + }, + }); + expect(stateChangeSpy.args[1][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: true, + isLoading: false, + isLoadingNext: false, + options: { limit: 10, offset: 20 }, + sort: { asc: 1 }, + }, + }); + expect(stateChangeSpy.args[3][0]).to.deep.equal({ + pagination: { + filters: { filterA: true }, + hasNext: false, + isLoading: false, + isLoadingNext: false, + options: { limit: 10, offset: 25 }, + sort: { asc: 1 }, + }, + }); + expect(channels.length).to.equal(25); + }); + it('should correctly update hasNext and offset if the last page has been reached', async () => { const { channels: initialChannels } = channelManager.state.getLatestValue(); expect(initialChannels.length).to.equal(0); @@ -999,7 +1146,7 @@ describe('ChannelManager', () => { await clock.runAllAsync(); - expect(getAndWatchChannelStub.called).to.be.false; + expect(getAndWatchChannelStub.called).to.be.true; expect(setChannelsStub.called).to.be.false; }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index c4b0084dde..eeecb1deda 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -22,15 +22,10 @@ import { findPinnedAtSortOrder, extractSortValue, promoteChannel, + uniqBy, } from '../../src/utils'; -import type { - ChannelFilters, - ChannelResponse, - ChannelSortBase, - FormatMessageResponse, - MessageResponse, -} from '../../src'; +import type { ChannelFilters, ChannelSortBase, FormatMessageResponse, MessageResponse } from '../../src'; import { StreamChat, Channel } from '../../src'; describe('addToMessageList', () => { @@ -753,3 +748,142 @@ describe('promoteChannel', () => { expect(result).to.not.equal(channels); }); }); + +describe('uniqBy', () => { + it('should return an empty array if input is not an array', () => { + expect(uniqBy(null, 'id')).to.deep.equal([]); + expect(uniqBy(undefined, 'id')).to.deep.equal([]); + expect(uniqBy(42, 'id')).to.deep.equal([]); + expect(uniqBy({}, 'id')).to.deep.equal([]); + }); + + it('should remove duplicates based on a property name', () => { + const array = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 1, name: 'Alice' }, + ]; + const result = uniqBy(array, 'id'); + expect(result).to.deep.equal([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + }); + + it('should remove duplicates based on a computed function', () => { + const array = [ + { id: 1, value: 10 }, + { id: 2, value: 20 }, + { id: 3, value: 10 }, + ]; + const result = uniqBy(array, (item: { id: number; value: number }) => item.value); + expect(result).to.deep.equal([ + { id: 1, value: 10 }, + { id: 2, value: 20 }, + ]); + }); + + it('should return the same array if all elements are unique', () => { + const array = [ + { id: 1, value: 'A' }, + { id: 2, value: 'B' }, + { id: 3, value: 'C' }, + ]; + expect(uniqBy(array, 'id')).to.deep.equal(array); + }); + + it('should work with nested properties', () => { + const array = [ + { user: { id: 1, name: 'Alice' } }, + { user: { id: 2, name: 'Bob' } }, + { user: { id: 1, name: 'Alice' } }, + ]; + const result = uniqBy(array, 'user.id'); + expect(result).to.deep.equal([{ user: { id: 1, name: 'Alice' } }, { user: { id: 2, name: 'Bob' } }]); + }); + + it('should work with primitive identities', () => { + expect(uniqBy([1, 2, 2, 3, 1], (x) => x)).to.deep.equal([1, 2, 3]); + expect(uniqBy(['a', 'b', 'a', 'c'], (x) => x)).to.deep.equal(['a', 'b', 'c']); + }); + + it('should handle an empty array', () => { + expect(uniqBy([], 'id')).to.deep.equal([]); + }); + + it('should handle falsy values correctly', () => { + const array = [{ id: 0 }, { id: false }, { id: null }, { id: undefined }, { id: 0 }]; + const result = uniqBy(array, 'id'); + expect(result).to.deep.equal([{ id: 0 }, { id: false }, { id: null }, { id: undefined }]); + }); + + it('should work when all elements are identical', () => { + const array = [ + { id: 1, name: 'Alice' }, + { id: 1, name: 'Alice' }, + { id: 1, name: 'Alice' }, + ]; + expect(uniqBy(array, 'id')).to.deep.equal([{ id: 1, name: 'Alice' }]); + }); + + it('should handle mixed types correctly', () => { + const array = [{ id: 1 }, { id: '1' }, { id: 1.0 }, { id: true }, { id: false }]; + expect(uniqBy(array, 'id')).to.deep.equal([{ id: 1 }, { id: '1' }, { id: true }, { id: false }]); + }); + + it('should handle undefined values in objects', () => { + const array = [{ id: undefined }, { id: undefined }, { id: 1 }, { id: 2 }]; + expect(uniqBy(array, 'id')).to.deep.equal([{ id: undefined }, { id: 1 }, { id: 2 }]); + }); + + it('should not modify the original array', () => { + const array = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 1, name: 'Alice' }, + ]; + const originalArray = [...array]; + uniqBy(array, 'id'); + expect(array).to.deep.equal(originalArray); + }); + + it('should call iteratee function for each element', () => { + const array = [{ id: 1 }, { id: 2 }, { id: 1 }]; + const iteratee = sinon.spy((item) => item.id); + + uniqBy(array, iteratee); + + expect(iteratee.calledThrice).to.be.true; + expect(iteratee.firstCall.returnValue).to.equal(1); + expect(iteratee.secondCall.returnValue).to.equal(2); + expect(iteratee.thirdCall.returnValue).to.equal(1); + }); + + it('should work with objects missing the given key', () => { + const array = [ + { id: 1 }, + { name: 'Alice' }, // missing 'id' + { id: 2 }, + { id: 1 }, + ]; + const result = uniqBy(array, 'id'); + expect(result).to.deep.equal([{ id: 1 }, { name: 'Alice' }, { id: 2 }]); + }); + + it('should work with an empty iteratee function', () => { + const array = [{ id: 1 }, { id: 2 }]; + const result = uniqBy(array, () => {}); + expect(result.length).to.equal(1); // Everything maps to `undefined`, so only first is kept + }); + + it('should handle more than 1 duplicate efficiently', () => { + const largeArray = Array.from({ length: 10000 }, (_, i) => ({ id: i % 100 })); + const result = uniqBy(largeArray, 'id'); + expect(result.length).to.equal(100); + }); + + it('should return an empty array when array contains only undefined values', () => { + const array = [undefined, undefined, undefined]; + expect(uniqBy(array, (x) => x)).to.deep.equal([undefined]); + }); +});