diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 2805e149e..e1d54c033 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -10,15 +10,13 @@ import type { ValueOrPatch } from './store'; import { isPatch, StateStore } from './store'; import type { Channel } from './channel'; import { - extractSortValue, - findLastPinnedChannelIndex, getAndWatchChannel, isChannelArchived, isChannelPinned, - promoteChannel, shouldConsiderArchivedChannels, shouldConsiderPinnedChannels, sleep, + sortChannels, uniqBy, } from './utils'; import { generateUUIDv4 } from './utils'; @@ -204,6 +202,20 @@ export class ChannelManager extends WithSubscriptions { notificationRemovedFromChannelHandler: this.notificationRemovedFromChannelHandler, }), ); + + this.state.addPreprocessor((currentState, previousState) => { + if ( + currentState.channels === previousState?.channels && + currentState.pagination.sort === previousState.pagination.sort + ) { + return; + } + + currentState.channels = sortChannels( + currentState.channels, + currentState.pagination.sort, + ); + }); } public setChannels = (valueOrFactory: ChannelSetterParameterType) => { @@ -438,10 +450,13 @@ export class ChannelManager extends WithSubscriptions { }; private notificationAddedToChannelHandler = async (event: Event) => { - const { id, type, members } = event?.channel ?? {}; + const channelId = event.channel_id ?? event.channel?.id; + const channelType = event.channel_type ?? event.channel?.type; + const members = event.channel?.members; if ( - !type || + !channelType || + !channelId || !this.options.allowNotLoadedChannelPromotionForEvent?.[ 'notification.added_to_channel' ] @@ -449,65 +464,64 @@ export class ChannelManager extends WithSubscriptions { return; } - const channel = await getAndWatchChannel({ + const targetChannel = await getAndWatchChannel({ client: this.client, - id, - members: members?.reduce((acc, { user, user_id }) => { - const userId = user_id || user?.id; + id: channelId, + members: members?.reduce((memberIds, { user, user_id }) => { + const userId = user_id ?? user?.id; if (userId) { - acc.push(userId); + memberIds.push(userId); } - return acc; + return memberIds; }, []), - type, + type: channelType, }); - const { pagination, channels } = this.state.getLatestValue(); - if (!channels) { - return; - } + this.setChannels((currentChannels) => { + const targetChannelExistsWithinList = currentChannels.indexOf(targetChannel) >= 0; - const { sort } = pagination ?? {}; + if (targetChannelExistsWithinList) { + return currentChannels; + } - this.setChannels( - promoteChannel({ - channels, - channelToMove: channel, - sort, - }), - ); + return [...currentChannels, targetChannel]; + }); }; private channelDeletedHandler = (event: Event) => { const { channels } = this.state.getLatestValue(); - if (!channels) { + + const channelId = event.channel_id ?? event.channel?.id; + const channelType = event.channel_type ?? event.channel?.type; + + if (!channelType || !channelId) { return; } - const newChannels = [...channels]; - const channelIndex = newChannels.findIndex( - (channel) => channel.cid === (event.cid || event.channel?.cid), + const targetChannelIndex = channels.indexOf( + this.client.channel(channelType, channelId), ); - if (channelIndex < 0) { - return; - } + if (targetChannelIndex < 0) return; - newChannels.splice(channelIndex, 1); - this.setChannels(newChannels); + this.setChannels((currentChannels) => { + const newChannels = [...currentChannels]; + + newChannels.splice(targetChannelIndex, 1); + + return newChannels; + }); }; private channelHiddenHandler = this.channelDeletedHandler; private newMessageHandler = (event: Event) => { const { pagination, channels } = this.state.getLatestValue(); - if (!channels) { - return; - } + const { filters, sort } = pagination ?? {}; - const channelType = event.channel_type; - const channelId = event.channel_id; + const channelId = event.channel_id ?? event.channel?.id; + const channelType = event.channel_type ?? event.channel?.type; if (!channelType || !channelId) { return; @@ -539,37 +553,36 @@ export class ChannelManager extends WithSubscriptions { return; } - this.setChannels( - promoteChannel({ - channels, - channelToMove: targetChannel, - channelToMoveIndexWithinChannels: targetChannelIndex, - sort, - }), - ); + this.setChannels((currentChannels) => { + if (targetChannelExistsWithinList) { + return [...currentChannels]; + } + + return [...currentChannels, targetChannel]; + }); }; private notificationNewMessageHandler = async (event: Event) => { - const { id, type } = event?.channel ?? {}; + const channelId = event.channel_id ?? event.channel?.id; + const channelType = event.channel_type ?? event.channel?.type; - if (!id || !type) { + if (!channelType || !channelId) { return; } - const channel = await getAndWatchChannel({ + const targetChannel = await getAndWatchChannel({ client: this.client, - id, - type, + id: channelId, + type: channelType, }); - const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = pagination ?? {}; + const { pagination } = this.state.getLatestValue(); + const { filters } = pagination ?? {}; const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const isTargetChannelArchived = isChannelArchived(channel); + const isTargetChannelArchived = isChannelArchived(targetChannel); if ( - !channels || (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['notification.message_new'] @@ -577,36 +590,38 @@ export class ChannelManager extends WithSubscriptions { return; } - this.setChannels( - promoteChannel({ - channels, - channelToMove: channel, - sort, - }), - ); + this.setChannels((currentChannels) => { + const targetChannelExistsWithinList = currentChannels.indexOf(targetChannel) >= 0; + + if (targetChannelExistsWithinList) { + return currentChannels; + } + + return [...currentChannels, targetChannel]; + }); }; private channelVisibleHandler = async (event: Event) => { - const { channel_type: channelType, channel_id: channelId } = event; + const channelId = event.channel_id ?? event.channel?.id; + const channelType = event.channel_type ?? event.channel?.type; if (!channelType || !channelId) { return; } - const channel = await getAndWatchChannel({ + const targetChannel = await getAndWatchChannel({ client: this.client, id: event.channel_id, type: event.channel_type, }); - const { channels, pagination } = this.state.getLatestValue(); - const { sort, filters } = pagination ?? {}; + const { pagination } = this.state.getLatestValue(); + const { filters } = pagination ?? {}; const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const isTargetChannelArchived = isChannelArchived(channel); + const isTargetChannelArchived = isChannelArchived(targetChannel); if ( - !channels || (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['channel.visible'] @@ -614,13 +629,15 @@ export class ChannelManager extends WithSubscriptions { return; } - this.setChannels( - promoteChannel({ - channels, - channelToMove: channel, - sort, - }), - ); + this.setChannels((currentChannels) => { + const targetChannelExistsWithinList = currentChannels.indexOf(targetChannel) >= 0; + + if (targetChannelExistsWithinList) { + return currentChannels; + } + + return [...currentChannels, targetChannel]; + }); }; private notificationRemovedFromChannelHandler = this.channelDeletedHandler; @@ -628,23 +645,22 @@ export class ChannelManager extends WithSubscriptions { private memberUpdatedHandler = (event: Event) => { const { pagination, channels } = this.state.getLatestValue(); const { filters, sort } = pagination; + const channelId = event.channel_id ?? event.channel?.id; + const channelType = event.channel_type ?? event.channel?.type; + if ( !event.member?.user || event.member.user.id !== this.client.userID || - !event.channel_type || - !event.channel_id + !channelType || + !channelId ) { return; } - const channelType = event.channel_type; - const channelId = event.channel_id; const considerPinnedChannels = shouldConsiderPinnedChannels(sort); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' }); if ( - !channels || (!considerPinnedChannels && !considerArchivedChannels) || this.options.lockChannelOrder ) { @@ -656,7 +672,6 @@ export class ChannelManager extends WithSubscriptions { const targetChannelIndex = channels.indexOf(targetChannel); const targetChannelExistsWithinList = targetChannelIndex >= 0; - const isTargetChannelPinned = isChannelPinned(targetChannel); const isTargetChannelArchived = isChannelArchived(targetChannel); const newChannels = [...channels]; @@ -672,25 +687,11 @@ export class ChannelManager extends WithSubscriptions { // When archived filter false, and channel is archived (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) ) { - this.setChannels(newChannels); - return; - } - - // handle pinning - let lastPinnedChannelIndex: number | null = null; - - if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !isTargetChannelPinned)) { - lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels }); - } - const newTargetChannelIndex = - typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0; - - // skip state update if the position of the channel does not change - if (channels[newTargetChannelIndex] === targetChannel) { - return; + // do nothing + } else { + newChannels.push(targetChannel); } - newChannels.splice(newTargetChannelIndex, 0, targetChannel); this.setChannels(newChannels); }; diff --git a/src/index.ts b/src/index.ts index a46c9077f..2d79fad0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,6 @@ export { logChatPromiseExecution, localMessageToNewMessagePayload, formatMessage, - promoteChannel, + sortChannels, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; diff --git a/src/types.ts b/src/types.ts index 7bc6c0808..a58a5e27c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ import type { EVENT_MAP } from './events'; -import type { Channel } from './channel'; import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; import type { Role } from './permissions'; @@ -3882,17 +3881,6 @@ export type VelocityFilterConfig = { async?: boolean; }; -export type PromoteChannelParams = { - channels: Array; - channelToMove: Channel; - sort: ChannelSort; - /** - * If the index of the channel within `channels` list which is being moved upwards - * (`channelToMove`) is known, you can supply it to skip extra calculation. - */ - channelToMoveIndexWithinChannels?: number; -}; - /** * An identifier containing information about the downstream SDK using stream-chat. It * is used to resolve the user agent. diff --git a/src/utils.ts b/src/utils.ts index d6e00a5d8..c4cc79219 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,7 +15,6 @@ import type { MessageSet, OwnUserBase, OwnUserResponse, - PromoteChannelParams, QueryChannelAPIResponse, ReactionGroupResponse, UpdatedMessage, @@ -1148,63 +1147,6 @@ export const findLastPinnedChannelIndex = ({ channels }: { channels: Channel[] } return lastPinnedChannelIndex; }; -/** - * A utility used to move a channel towards the beginning of a list of channels (promote it to a higher position). It - * considers pinned channels in the process if needed and makes sure to only update the list reference if the list - * should actually change. It will try to move the channel as high as it can within the list. - * @param channels - the list of channels we want to modify - * @param channelToMove - the channel we want to promote - * @param channelToMoveIndexWithinChannels - optionally, the index of the channel we want to move if we know it (will skip a manual check) - * @param sort - the sort value used to check for pinned channels - */ -export const promoteChannel = ({ - channels, - channelToMove, - channelToMoveIndexWithinChannels, - sort, -}: PromoteChannelParams) => { - // get index of channel to move up - const targetChannelIndex = - channelToMoveIndexWithinChannels ?? - channels.findIndex((channel) => channel.cid === channelToMove.cid); - - const targetChannelExistsWithinList = targetChannelIndex >= 0; - const targetChannelAlreadyAtTheTop = targetChannelIndex === 0; - - // pinned channels should not move within the list based on recent activity, channels which - // receive messages and are not pinned should move upwards but only under the last pinned channel - // in the list - const considerPinnedChannels = shouldConsiderPinnedChannels(sort); - const isTargetChannelPinned = isChannelPinned(channelToMove); - - if (targetChannelAlreadyAtTheTop || (considerPinnedChannels && isTargetChannelPinned)) { - return channels; - } - - const newChannels = [...channels]; - - // target channel index is known, remove it from the list - if (targetChannelExistsWithinList) { - newChannels.splice(targetChannelIndex, 1); - } - - // as position of pinned channels has to stay unchanged, we need to - // find last pinned channel in the list to move the target channel after - let lastPinnedChannelIndex: number | null = null; - if (considerPinnedChannels) { - lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels }); - } - - // re-insert it at the new place (to specific index if pinned channels are considered) - newChannels.splice( - typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0, - 0, - channelToMove, - ); - - return newChannels; -}; - export const isDate = (value: unknown): value is Date => !!(value as Date).getTime; export const isLocalMessage = (message: unknown): message is LocalMessage => @@ -1232,3 +1174,100 @@ export const runDetached = ( promise.catch(onError); }; + +export const sortChannels = (array: Channel[], criteria: ChannelSort) => { + const sortableChannelByConfId: Record> = {}; + + // format criteria to always be an iterable array + let arrayCriteria: ChannelSortBase[]; + if (!Array.isArray(criteria)) { + const remappedCriteria: ChannelSortBase[] = []; + for (const key in criteria) { + const typeSafeKey = key as keyof ChannelSortBase; + remappedCriteria.push({ [typeSafeKey]: criteria[typeSafeKey] }); + } + arrayCriteria = remappedCriteria; + } else { + arrayCriteria = criteria; + } + + const getSortable = (c: Channel) => { + if (!sortableChannelByConfId[c.cid]) { + sortableChannelByConfId[c.cid] = mapToSortable(c); + } + + return sortableChannelByConfId[c.cid]; + }; + + const arrayCopy = [...array]; + + arrayCopy.sort((a, b) => { + for (const criterion of arrayCriteria) { + const [[key, order]] = Object.entries(criterion); + + const typeSafeKey = key as keyof ChannelSortBase; + const sortableA = getSortable(a); + const sortableB = getSortable(b); + + const aValue = sortableA[typeSafeKey]; + const bValue = sortableB[typeSafeKey]; + + if (aValue === null || bValue === null) { + if (aValue === null && bValue !== null) return 1; + if (aValue !== null && bValue === null) return -1; + + continue; + } + + // ascending + if (order === 1) { + if (aValue > bValue) return 1; + if (aValue < bValue) return -1; + } + + // descending + if (order === -1) { + if (aValue > bValue) return -1; + if (aValue < bValue) return 1; + } + } + + return 0; + }); + + return arrayCopy; +}; + +/** + * Certain criteria properties rely on data which live across Channel. + * For example property `pinned_at` maps to `Channel.membership.pinned_at` that's + * why we need a simpler mapped object upon which we can apply criteria. + * Date objects are mapped to integers through `getTime`. + */ +const mapToSortable = (channel: Channel) => { + const unreadCount = channel.countUnread(); + return { + pinned_at: + typeof channel.state.membership.pinned_at === 'string' + ? new Date(channel.state.membership.pinned_at).getTime() + : null, + created_at: + typeof channel.data?.created_at === 'string' + ? new Date(channel.data.created_at).getTime() + : null, + has_unread: unreadCount > 0, + last_message_at: channel.state.last_message_at?.getTime() ?? null, + updated_at: + typeof channel.data?.updated_at === 'string' + ? new Date(channel.data?.updated_at).getTime() + : null, + member_count: channel.data?.member_count ?? null, + unread_count: unreadCount, + last_updated: null, // not sure what to map this one to + // TODO: figure out custom data \w normalization (isDate...) + } satisfies Record; +}; + +const mapToFilterable = (channel: Channel) => { + // TODO: https://github.com/GetStream/stream-video-js/blob/main/packages/react-sdk/src/utilities/filter.ts +}; diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 8c9445e50..760072314 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -37,7 +37,10 @@ describe('ChannelManager', () => { const channels = channelsResponse.map((c) => client.channel(c.channel.type, c.channel.id), ); - channelManager.state.partialNext({ channels, initialized: true }); + channelManager.state.partialNext({ + channels, + initialized: true, + }); }); afterEach(() => { @@ -448,7 +451,9 @@ describe('ChannelManager', () => { client.offlineDb!.getChannelsForQuery as unknown as MockInstance ).mockResolvedValue(mockChannelPages[0]); - hydrateActiveChannelsSpy = sinon.stub(client, 'hydrateActiveChannels'); + hydrateActiveChannelsSpy = sinon + .stub(client, 'hydrateActiveChannels') + .returns([]); executeChannelsQuerySpy = sinon.stub( channelManager as any, 'executeChannelsQuery', @@ -498,18 +503,11 @@ describe('ChannelManager', () => { it('does NOT hydrate from DB if already initialized', async () => { channelManager.state.partialNext({ initialized: true }); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); await channelManager.queryChannels({ filterA: true }, { asc: 1 }); expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.false; - expect(stateChangeSpy.called).to.be.false; expect(executeChannelsQuerySpy.called).to.be.false; expect(scheduleSyncStatusCallbackSpy.called).to.be.true; }); @@ -576,18 +574,10 @@ describe('ChannelManager', () => { it('continues with normal queryChannels flow if client.user is missing', async () => { client.user = undefined; - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.false; - expect(stateChangeSpy.called).to.be.false; expect(scheduleSyncStatusCallbackSpy.called).to.be.false; expect(executeChannelsQuerySpy.calledOnce).to.be.true; }); @@ -1186,7 +1176,6 @@ describe('ChannelManager', () => { let shouldConsiderPinnedChannelsStub: MockInstance< (typeof utils)['shouldConsiderPinnedChannels'] >; - let promoteChannelSpy: MockInstance<(typeof utils)['promoteChannel']>; let getAndWatchChannelStub: MockInstance<(typeof utils)['getAndWatchChannel']>; let findLastPinnedChannelIndexStub: MockInstance< (typeof utils)['findLastPinnedChannelIndex'] @@ -1205,7 +1194,6 @@ describe('ChannelManager', () => { getAndWatchChannelStub = vi.spyOn(utils, 'getAndWatchChannel'); findLastPinnedChannelIndexStub = vi.spyOn(utils, 'findLastPinnedChannelIndex'); extractSortValueStub = vi.spyOn(utils, 'extractSortValue'); - promoteChannelSpy = vi.spyOn(utils, 'promoteChannel'); }); afterEach(() => { @@ -1228,36 +1216,31 @@ describe('ChannelManager', () => { 'notification.removed_from_channel', ] as const ).forEach((eventType) => { - it('should return early if channels is undefined', () => { - channelManager.state.partialNext({ channels: undefined }); - - client.dispatchEvent({ type: eventType, cid: channelToRemove.cid }); - client.dispatchEvent({ type: eventType, channel: channelToRemove }); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - it('should remove the channel when event.cid matches', () => { - client.dispatchEvent({ type: eventType, cid: channelToRemove.cid }); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - const channels = setChannelsStub.mock.lastCall?.[0] as Channel[]; + const stateBefore = channelManager.state.getLatestValue(); - expect(channels.map((c) => c.id)).to.deep.equal(['channel1', 'channel3']); - }); + client.dispatchEvent({ + type: eventType, + channel_type: channelToRemove.type, + channel_id: channelToRemove.id, + }); - it('should remove the channel when event.channel?.cid matches', () => { - client.dispatchEvent({ type: eventType, channel: channelToRemove }); + const stateAfter = channelManager.state.getLatestValue(); - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3']); + expect(stateBefore.channels).toHaveLength(3); + expect(stateAfter.channels).toHaveLength(2); + expect(stateAfter.channels.map((c) => c.cid)).not.toContain( + channelToRemove.cid, + ); }); it('should not modify the list if no channels match', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); - client.dispatchEvent({ type: eventType, cid: 'channel123' }); + client.dispatchEvent({ + type: eventType, + channel_type: 'unknown', + channel_id: 'unknown', + }); const { channels: newChannels } = channelManager.state.getLatestValue(); expect(setChannelsStub).toHaveBeenCalledTimes(0); @@ -1268,18 +1251,6 @@ describe('ChannelManager', () => { }); describe('newMessageHandler', () => { - it('should not update the state early if channels are not defined', () => { - channelManager.state.partialNext({ channels: undefined }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - }); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - it('should not update the state if channel is pinned and sorting considers pinned channels', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); isChannelPinnedStub.mockReturnValueOnce(true); @@ -1399,67 +1370,24 @@ describe('ChannelManager', () => { const stateBefore = channelManager.state.getLatestValue(); - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel4', + const newChannelResponse = generateChannel({ + channel: { id: 'channel4' }, }); - const stateAfter = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - - expect(stateBefore.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - }); - - it('should move the channel upwards if all conditions allow it', () => { - isChannelPinnedStub.mockReturnValueOnce(false); - isChannelArchivedStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(false); - - const stateBefore = channelManager.state.getLatestValue(); - client.dispatchEvent({ type: 'message.new', channel_type: 'messaging', - channel_id: 'channel2', + channel_id: 'channel4', }); const stateAfter = channelManager.state.getLatestValue(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel2", - "messaging:channel1", - "messaging:channel3", - ] - `); + expect(stateBefore.channels).toHaveLength(3); + expect(stateBefore.channels.map((c) => c.cid)).not.toContain(newChannelResponse.channel.cid); + expect(stateAfter.channels).toHaveLength(4); + expect(stateAfter.channels.map((c) => c.cid)).toContain(newChannelResponse.channel.cid); }); }); @@ -1584,7 +1512,7 @@ describe('ChannelManager', () => { channelManager.setOptions({}); }); - it('should move channel when all criteria are met', async () => { + it('should add channel when all criteria are met', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( newChannelResponse.channel.type, @@ -1604,23 +1532,11 @@ describe('ChannelManager', () => { const stateAfter = channelManager.state.getLatestValue(); expect(getAndWatchChannelStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); + expect(stateBefore.channels).toHaveLength(3); + expect(stateBefore.channels.map((c) => c.cid)).not.toContain(newChannel.cid); + expect(stateAfter.channels).toHaveLength(4); + expect(stateAfter.channels.map((c) => c.cid)).toContain(newChannel.cid); }); it('should not add duplicate channels for multiple event invocations', async () => { @@ -1647,23 +1563,11 @@ describe('ChannelManager', () => { const stateAfter = channelManager.state.getLatestValue(); expect(getAndWatchChannelStub.mock.calls.length).to.equal(3); - expect(promoteChannelSpy.mock.calls.length).to.equal(3); expect(setChannelsStub.mock.calls.length).to.equal(3); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); + expect(stateBefore.channels).toHaveLength(3); + expect(stateBefore.channels.map((c) => c.cid)).not.toContain(newChannel.cid); + expect(stateAfter.channels).toHaveLength(4); + expect(stateAfter.channels.map((c) => c.cid)).toContain(newChannel.cid); }); }); @@ -1690,24 +1594,6 @@ describe('ChannelManager', () => { expect(setChannelsStub).toHaveBeenCalledTimes(0); }); - it('should not update the state if channels is undefined', async () => { - channelManager.state.partialNext({ channels: undefined }); - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), - ); - client.dispatchEvent({ - type: 'channel.visible', - channel_id: newChannelResponse.channel.id, - channel_type: newChannelResponse.channel.type, - }); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - it('should not update the state if the channel is archived and filters do not allow it (archived:false)', async () => { isChannelArchivedStub.mockReturnValueOnce(true); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -1782,23 +1668,11 @@ describe('ChannelManager', () => { const stateAfter = channelManager.state.getLatestValue(); expect(getAndWatchChannelStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); + expect(stateBefore.channels).toHaveLength(3); + expect(stateBefore.channels.map((c) => c.cid)).not.toContain(newChannel.cid); + expect(stateAfter.channels).toHaveLength(4); + expect(stateAfter.channels.map((c) => c.cid)).toContain(newChannel.cid); }); }); @@ -1859,13 +1733,6 @@ describe('ChannelManager', () => { expect(setChannelsStub).toHaveBeenCalledTimes(0); }); - it('should not update state early if channels are not available in state', () => { - channelManager.state.partialNext({ channels: undefined }); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - it('should not update state if options.lockChannelOrder is true', () => { channelManager.setOptions({ lockChannelOrder: true }); dispatchMemberUpdatedEvent(); @@ -1905,66 +1772,12 @@ describe('ChannelManager', () => { isChannelArchivedStub.mockReturnValueOnce(true); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel2', 'channel1', 'channel3']); - }); - - it('should pin channel at the correct position when pinnedAtSort is 1', () => { - isChannelPinnedStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(1); - dispatchMemberUpdatedEvent('channel3'); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3', 'channel2']); - }); - - it('should pin channel at the correct position when pinnedAtSort is -1 and the target is not pinned', () => { - isChannelPinnedStub.mockImplementationOnce((c) => c.id === 'channel1'); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(-1); - dispatchMemberUpdatedEvent('channel3'); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3', 'channel2']); - }); - it('should pin channel at the correct position when pinnedAtSort is -1 and the target is pinned', () => { - isChannelPinnedStub.mockImplementationOnce((c) => - ['channel1', 'channel3'].includes(c.id!), - ); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(-1); - dispatchMemberUpdatedEvent('channel3'); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel3', 'channel1', 'channel2']); - }); - - it('should not update state if position of target channel does not change', () => { - isChannelPinnedStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(1); dispatchMemberUpdatedEvent(); + const stateAfter = channelManager.state.getLatestValue(); - const { channels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(channels[1].id).to.equal('channel2'); + expect(stateAfter.channels).toHaveLength(3); + expect(setChannelsStub).toHaveBeenCalledTimes(1); }); }); @@ -2073,22 +1886,10 @@ describe('ChannelManager', () => { const stateAfter = channelManager.state.getLatestValue(); expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); + expect(stateBefore.channels).toHaveLength(3); + expect(stateBefore.channels.map((c) => c.cid)).not.toContain(newChannel.cid); + expect(stateAfter.channels).toHaveLength(4); + expect(stateAfter.channels.map((c) => c.cid)).toContain(newChannel.cid); }); }); }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index cbe9ed07b..ddbfe3f0e 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -21,7 +21,6 @@ import { findLastPinnedChannelIndex, findPinnedAtSortOrder, extractSortValue, - promoteChannel, uniqBy, runDetached, sleep, @@ -29,11 +28,12 @@ import { import type { ChannelFilters, + ChannelSort, ChannelSortBase, FormatMessageResponse, MessageResponse, } from '../../src'; -import { StreamChat, Channel } from '../../src'; +import { StreamChat, Channel, sortChannels } from '../../src'; describe('addToMessageList', () => { const timestamp = new Date('2024-09-18T15:30:00.000Z').getTime(); @@ -692,221 +692,6 @@ describe('Channel pinning and archiving utils', () => { }); }); -describe('promoteChannel', () => { - let client: StreamChat; - - beforeEach(async () => { - client = await getClientWithUser(); - }); - - it('should return the original list if the channel is already at the top', () => { - const channelsResponse = [generateChannel(), generateChannel()]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const result = promoteChannel({ - channels, - channelToMove: channels[0], - sort: {}, - }); - - expect(result).to.deep.equal(channels); - expect(result).to.be.equal(channels); - }); - - it('should return the original list if the channel is pinned and pinned channels should be considered', () => { - const channelsResponse = [ - generateChannel({ membership: { pinned_at: '2024-02-04T12:00:00Z' } }), - generateChannel({ membership: { pinned_at: '2024-02-04T12:01:00Z' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[1]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [{ pinned_at: 1 }], - }); - - expect(result).to.deep.equal(channels); - expect(result).to.be.equal(channels); - }); - - it('should move a non-pinned channel upwards if it exists in the list', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[2]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: {}, - }); - - expect(result.map((c) => c.id)).to.deep.equal(['channel3', 'channel1', 'channel2']); - expect(result).to.not.equal(channels); - }); - - it('should correctly move a non-pinned channel if its index is provided', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[2]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: {}, - channelToMoveIndexWithinChannels: 2, - }); - - expect(result.map((c) => c.id)).to.deep.equal(['channel3', 'channel1', 'channel2']); - expect(result).to.not.equal(channels); - }); - - it('should move a non-pinned channel upwards if it does not exist in the list', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - const newChannel = generateChannel({ channel: { id: 'channel4' } }); - client.hydrateActiveChannels([...channelsResponse, newChannel]); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = client.channel(newChannel.channel.type, newChannel.channel.id); - - const result = promoteChannel({ - channels, - channelToMove, - sort: {}, - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'channel4', - 'channel1', - 'channel2', - 'channel3', - ]); - expect(result).to.not.equal(channels); - }); - - it('should correctly move a non-pinned channel upwards if it does not exist and the index is provided', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - const newChannel = generateChannel({ channel: { id: 'channel4' } }); - client.hydrateActiveChannels([...channelsResponse, newChannel]); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = client.channel(newChannel.channel.type, newChannel.channel.id); - - const result = promoteChannel({ - channels, - channelToMove, - sort: {}, - channelToMoveIndexWithinChannels: -1, - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'channel4', - 'channel1', - 'channel2', - 'channel3', - ]); - expect(result).to.not.equal(channels); - }); - - it('should move the channel just below the last pinned channel if pinned channels are considered', () => { - const channelsResponse = [ - generateChannel({ - channel: { id: 'pinned1' }, - membership: { pinned_at: '2024-02-04T12:00:00Z' }, - }), - generateChannel({ - channel: { id: 'pinned2' }, - membership: { pinned_at: '2024-02-04T12:01:00Z' }, - }), - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[3]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [{ pinned_at: -1 }], - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'pinned1', - 'pinned2', - 'channel2', - 'channel1', - ]); - expect(result).to.not.equal(channels); - }); - - it('should move the channel to the top of the list if pinned channels exist but are not considered', () => { - const channelsResponse = [ - generateChannel({ - channel: { id: 'pinned1' }, - membership: { pinned_at: '2024-02-04T12:01:00Z' }, - }), - generateChannel({ - channel: { id: 'pinned2' }, - membership: { pinned_at: '2024-02-04T12:00:00Z' }, - }), - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[2]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: {}, - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'channel1', - 'pinned1', - 'pinned2', - 'channel2', - ]); - 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([]); @@ -1171,3 +956,178 @@ describe('sleep', () => { expect(resolved).toBe(true); }); }); + +describe('sortChannels', () => { + let client: StreamChat; + let channels: Channel[]; + + beforeEach(() => { + client = getClientWithUser(); + + // Create sample channels with different properties for sorting + const channelResponses = [ + generateChannel({ + channel: { + created_at: '2025-01-01T10:00:00Z', + updated_at: '2025-01-01T10:00:30Z', + member_count: 5, + name: 'Alpha', + id: 'alpha', + }, + membership: { pinned_at: '2025-01-01T11:00:00Z' }, + }), + generateChannel({ + channel: { + created_at: '2025-01-01T10:00:01Z', + updated_at: '2025-01-01T10:00:31Z', + member_count: 10, + name: 'Beta', + id: 'beta', + }, + }), + generateChannel({ + channel: { + created_at: '2025-01-01T10:00:02Z', + updated_at: '2025-01-01T10:00:32Z', + member_count: 3, + name: 'Charlie', + id: 'charlie', + }, + membership: { pinned_at: '2025-01-01T11:30:00Z' }, + }), + ]; + + client.hydrateActiveChannels(channelResponses); + channels = channelResponses.map((cr) => + client.channel(cr.channel.type, cr.channel.id), + ); + }); + + it('should sort channels by created_at in ascending order', () => { + const criteria: ChannelSort = { created_at: 1 }; + const sorted = sortChannels([...channels], criteria); + + expect(sorted.length).to.equal(3); + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:alpha", + "messaging:beta", + "messaging:charlie", + ] + `); + }); + + it('should sort channels by created_at in descending order', () => { + const criteria: ChannelSort = { created_at: -1 }; + const sorted = sortChannels([...channels], criteria); + + expect(sorted.length).to.equal(3); + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:charlie", + "messaging:beta", + "messaging:alpha", + ] + `); + }); + + it('should sort channels by updated_at', () => { + const criteria: ChannelSort = { updated_at: -1 }; + const sorted = sortChannels([...channels], criteria); + + expect(sorted.length).to.equal(3); + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:charlie", + "messaging:beta", + "messaging:alpha", + ] + `); + }); + + it('should sort channels by membership.pinned_at', () => { + const criteria: ChannelSort = { pinned_at: -1 }; + const sorted = sortChannels([...channels], criteria); + + expect(sorted.length).to.equal(3); + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:charlie", + "messaging:alpha", + "messaging:beta", + ] + `); + }); + + it('should sort channels by member_count', () => { + const criteria: ChannelSort = { member_count: -1 }; + const sorted = sortChannels([...channels], criteria); + + expect(sorted.length).to.equal(3); + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:beta", + "messaging:alpha", + "messaging:charlie", + ] + `); + }); + + it('should sort channels by name', () => { + // @ts-expect-error `name` is a custom property + const criteria: ChannelSort = { name: 1 }; + const sorted = sortChannels([...channels], criteria); + + expect(sorted.length).to.equal(3); + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:alpha", + "messaging:beta", + "messaging:charlie", + ] + `); + }); + + it('should sort by multiple criteria in the correct order', () => { + // Give two channels the same pinned status to test secondary sort + const channelsWithEqualPins = [...channels]; + const channelResponse = generateChannel({ + channel: { + created_at: '2025-01-01T10:00:04Z', + member_count: 7, + name: 'Delta', + id: 'delta', + }, + membership: { pinned_at: channels[0].state.membership.pinned_at }, + }); + + client.hydrateActiveChannels([channelResponse]); + + channelsWithEqualPins.push( + client.channel(channelResponse.channel.type, channelResponse.channel.id), + ); + + // Sort by pinned_at (desc) first, then by member_count (desc) + const criteria: ChannelSort = [{ pinned_at: 1 }, { member_count: -1 }]; + const sorted = sortChannels(channelsWithEqualPins, criteria); + + // First two should have the same pinned_at but different member_count + expect(sorted.map((c) => c.cid)).toMatchInlineSnapshot(` + [ + "messaging:delta", + "messaging:alpha", + "messaging:charlie", + "messaging:beta", + ] + `); + }); + + it('should handle non-existent property in sort criteria', () => { + // @ts-expect-error testing purposefully invalid criteria + const criteria: ChannelSort = { nonExistentProperty: 1 }; + const sorted = sortChannels([...channels], criteria); + + // Should maintain original order when property doesn't exist + expect(sorted).to.deep.equal(channels); + }); +});