From be23e4ee4d2d47649eb61c79bfa11851826cf572 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 24 Feb 2025 14:55:49 +0100 Subject: [PATCH 1/3] feat: customPaginator is removed from ChannelService BREAKING CHANGE: `channelService.customPaginator` is removed, provide a custom query if you want to control pagination logic --- .../src/lib/channel.service.spec.ts | 31 ------------------- .../src/lib/channel.service.ts | 23 -------------- 2 files changed, 54 deletions(-) diff --git a/projects/stream-chat-angular/src/lib/channel.service.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.spec.ts index 3a651ad9..4a49eaa6 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -248,37 +248,6 @@ describe('ChannelService', () => { /* eslint-enable @typescript-eslint/no-unsafe-member-access */ }); - it('should set pagination options correctly if #customPaginator is provided', async () => { - service.customPaginator = ( - channelQueryResult: Channel[], - ) => { - const lastChannel = channelQueryResult[channelQueryResult.length - 1]; - if (!lastChannel) { - return { - type: 'filter', - paginationFilter: {}, - }; - } else { - return { - type: 'filter', - paginationFilter: { - cid: { $gte: lastChannel.cid }, - }, - }; - } - }; - - await init(); - - // @ts-expect-error we know channelQuery exists, TS doesn't - expect(service['channelQuery']?.['nextPageConfiguration']).toEqual({ - type: 'filter', - paginationFilter: { - cid: { $gte: jasmine.any(String) }, - }, - }); - }); - it('should not set active channel if #shouldSetActiveChannel is false', async () => { const activeChannelSpy = jasmine.createSpy(); service.activeChannel$.subscribe(activeChannelSpy); diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index a47f5f04..de989ac7 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -38,7 +38,6 @@ import { DefaultStreamChatGenerics, MessageInput, MessageReactionType, - NextPageConfiguration, StreamMessage, } from './types'; import { ChannelQuery } from './channel-query'; @@ -360,9 +359,6 @@ export class ChannelService< private channelQuery?: | ChannelQuery | ((queryType: ChannelQueryType) => Promise>); - private _customPaginator: - | ((channelQueryResult: Channel[]) => NextPageConfiguration) - | undefined; private channelListSetter = ( channels: Channel[], @@ -533,24 +529,6 @@ export class ChannelService< this._shouldMarkActiveChannelAsRead = shouldMarkActiveChannelAsRead; } - /** - * By default the SDK uses an offset based pagination, you can change/extend this by providing your own custom paginator method. - * - * The method will be called with the result of the latest channel query. - * - * You can return either an offset, or a filter using the [`$lte`/`$gte` operator](/chat/docs/javascript/query_syntax_operators/). If you return a filter, it will be merged with the filter provided for the `init` method. - */ - set customPaginator( - paginator: - | ((channelQueryResult: Channel[]) => NextPageConfiguration) - | undefined, - ) { - this._customPaginator = paginator; - if (this.channelQuery && 'customPaginator' in this.channelQuery) { - this.channelQuery.customPaginator = this._customPaginator; - } - } - /** * Sets the given `channel` as active and marks it as read. * If the channel wasn't previously part of the channel, it will be added to the beginning of the list. @@ -745,7 +723,6 @@ export class ChannelService< ...options, }, ); - this.channelQuery.customPaginator = this._customPaginator; return this._init({ shouldSetActiveChannel, From d101694d91063ebf0a13e8534d80ab618b05633e Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 25 Feb 2025 11:49:14 +0100 Subject: [PATCH 2/3] feat: use ChannelManager from stream-chat-js BREAKING CHANGE: ChannelService: init and initWithCustomQuery syntax changed; registering WS event handler overrides has a simplified syntax; ChannelQuery class is removed; for the full list check https://getstream.io/chat/docs/sdk/angular/basics/upgrade-v5/ --- package-lock.json | 15 +- package.json | 2 +- projects/sample-app/src/app/app.component.ts | 9 +- projects/stream-chat-angular/karma.conf.js | 3 +- projects/stream-chat-angular/package.json | 2 +- .../src/lib/channel-query.spec.ts | 2 - .../src/lib/channel-query.ts | 115 --- .../src/lib/channel.service.spec.ts | 506 +++-------- .../src/lib/channel.service.thread.spec.ts | 69 +- .../src/lib/channel.service.ts | 793 +++++------------- .../src/lib/mocks/index.ts | 2 + projects/stream-chat-angular/src/lib/types.ts | 27 + .../stream-chat-angular/src/public-api.ts | 1 - 13 files changed, 390 insertions(+), 1156 deletions(-) delete mode 100644 projects/stream-chat-angular/src/lib/channel-query.spec.ts delete mode 100644 projects/stream-chat-angular/src/lib/channel-query.ts diff --git a/package-lock.json b/package-lock.json index 68bfa83b..126b8c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "pretty-bytes": "^6.1.1", "rxjs": "~7.4.0", "starwars-names": "^1.6.0", - "stream-chat": "^8.44.0", + "stream-chat": "^8.57.2", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^11.1.0", @@ -23043,9 +23043,10 @@ } }, "node_modules/stream-chat": { - "version": "8.44.0", - "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.44.0.tgz", - "integrity": "sha512-7HNtimD8sT/51rsFibGcD6uBgKj+vlKyYCZMDzjYQEaEsrLqyAg48dOyNM4L2FTF5aXgo9SlxZr21SPleeA2BA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.57.2.tgz", + "integrity": "sha512-BibsBsxk7VnqjecYcLu4DnY8067BJAvsKa6zX5DPvkwdK8YS24ZoczxmwlpzMFELUp33sbDfmB1wI/e3wgfh6Q==", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@babel/runtime": "^7.16.3", "@types/jsonwebtoken": "~9.0.0", @@ -40544,9 +40545,9 @@ "dev": true }, "stream-chat": { - "version": "8.44.0", - "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.44.0.tgz", - "integrity": "sha512-7HNtimD8sT/51rsFibGcD6uBgKj+vlKyYCZMDzjYQEaEsrLqyAg48dOyNM4L2FTF5aXgo9SlxZr21SPleeA2BA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.57.2.tgz", + "integrity": "sha512-BibsBsxk7VnqjecYcLu4DnY8067BJAvsKa6zX5DPvkwdK8YS24ZoczxmwlpzMFELUp33sbDfmB1wI/e3wgfh6Q==", "requires": { "@babel/runtime": "^7.16.3", "@types/jsonwebtoken": "~9.0.0", diff --git a/package.json b/package.json index 7461362c..a849049b 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "pretty-bytes": "^6.1.1", "rxjs": "~7.4.0", "starwars-names": "^1.6.0", - "stream-chat": "^8.44.0", + "stream-chat": "^8.57.2", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^11.1.0", diff --git a/projects/sample-app/src/app/app.component.ts b/projects/sample-app/src/app/app.component.ts index c7deb23a..d32877cc 100644 --- a/projects/sample-app/src/app/app.component.ts +++ b/projects/sample-app/src/app/app.component.ts @@ -59,14 +59,13 @@ export class AppComponent implements AfterViewInit { : environment.userToken, { timeout: 10000 }, ); - void this.channelService.init( - environment.channelsFilter || { + void this.channelService.init({ + filters: environment.channelsFilter || { type: 'messaging', members: { $in: [environment.userId] }, }, - undefined, - { limit: 10 }, - ); + options: { limit: 10 }, + }); this.streamI18nService.setTranslation(); this.channelService.activeParentMessage$ .pipe(map((m) => !!m)) diff --git a/projects/stream-chat-angular/karma.conf.js b/projects/stream-chat-angular/karma.conf.js index 95da9892..822ef34e 100644 --- a/projects/stream-chat-angular/karma.conf.js +++ b/projects/stream-chat-angular/karma.conf.js @@ -18,6 +18,7 @@ module.exports = function (config) { // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html // for example, you can disable the random execution with `random: false` // or set a specific seed with `seed: 4321` + random: false, }, clearContext: false, // leave Jasmine Spec Runner output visible in browser }, @@ -27,7 +28,7 @@ module.exports = function (config) { coverageReporter: { dir: require("path").join( __dirname, - "../../coverage/stream-chat-angular" + "../../coverage/stream-chat-angular", ), subdir: ".", reporters: [{ type: "html" }, { type: "text-summary" }], diff --git a/projects/stream-chat-angular/package.json b/projects/stream-chat-angular/package.json index 72722133..7be044d3 100644 --- a/projects/stream-chat-angular/package.json +++ b/projects/stream-chat-angular/package.json @@ -22,7 +22,7 @@ "@breezystack/lamejs": "^1.2.7", "@ngx-translate/core": "^16.0.0", "rxjs": "^7.4.0", - "stream-chat": "^8.44.0" + "stream-chat": "^8.57.2" }, "peerDependenciesMeta": { "@breezystack/lamejs": { diff --git a/projects/stream-chat-angular/src/lib/channel-query.spec.ts b/projects/stream-chat-angular/src/lib/channel-query.spec.ts deleted file mode 100644 index 66417449..00000000 --- a/projects/stream-chat-angular/src/lib/channel-query.spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Tests are inside ChannelService -describe('ChannelQuery', () => {}); diff --git a/projects/stream-chat-angular/src/lib/channel-query.ts b/projects/stream-chat-angular/src/lib/channel-query.ts deleted file mode 100644 index 42a372c4..00000000 --- a/projects/stream-chat-angular/src/lib/channel-query.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - Channel, - ChannelFilters, - ChannelOptions, - ChannelSort, -} from 'stream-chat'; -import { ChannelService } from './channel.service'; -import { - ChannelQueryResult, - ChannelQueryType, - DefaultStreamChatGenerics, - NextPageConfiguration, -} from './types'; -import { ChatClientService } from './chat-client.service'; - -/** - * This class allows you to make paginated channel query requests. - */ -export class ChannelQuery< - T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> { - /** - * By default the SDK uses an offset based pagination, you can change/extend this by providing your own custom paginator method. - * - * The method will be called with the result of the latest channel query. - * - * You can return either an offset, or a filter using the [`$lte`/`$gte` operator](/chat/docs/javascript/query_syntax_operators/). If you return a filter, it will be merged with the filter provided for the `init` method. - */ - customPaginator?: (channelQueryResult: Channel[]) => NextPageConfiguration; - private nextPageConfiguration?: NextPageConfiguration; - - constructor( - private chatService: ChatClientService, - private channelService: ChannelService, - private filters: ChannelFilters, - private sort: ChannelSort = { last_message_at: -1 }, - private options: ChannelOptions = { - limit: 25, - state: true, - presence: true, - watch: true, - }, - ) {} - - async query(queryType: ChannelQueryType): Promise> { - if (queryType === 'first-page' || queryType === 'recover-state') { - this.nextPageConfiguration = undefined; - } - const prevChannels = - queryType === 'recover-state' ? [] : this.channelService.channels; - let filters: ChannelFilters; - let options: ChannelOptions; - if (this.nextPageConfiguration) { - if (this.nextPageConfiguration.type === 'filter') { - filters = { - ...this.filters, - ...this.nextPageConfiguration.paginationFilter, - }; - options = this.options; - } else { - options = { - ...this.options, - offset: this.nextPageConfiguration.offset, - }; - filters = this.filters; - } - } else { - filters = this.filters; - options = this.options; - } - const channels = await this.chatService.chatClient.queryChannels( - filters, - this.sort || {}, - options, - ); - this.setNextPageConfiguration(channels); - - const currentActiveChannel = this.channelService.activeChannel; - if ( - queryType === 'recover-state' && - currentActiveChannel && - !channels.find((c) => c.cid === currentActiveChannel?.cid) - ) { - try { - await currentActiveChannel.watch(); - channels.unshift(currentActiveChannel); - } catch (error) { - this.chatService.chatClient.logger( - 'warn', - 'Unable to refetch active channel after state recover', - error as Record, - ); - } - } - - return { - channels: [...prevChannels, ...channels], - hasMorePage: channels.length >= this.options.limit!, - }; - } - - setNextPageConfiguration(channelQueryResult: Channel[]) { - if (this.customPaginator) { - this.nextPageConfiguration = this.customPaginator(channelQueryResult); - } else { - this.nextPageConfiguration = { - type: 'offset', - offset: - (this.nextPageConfiguration?.type === 'offset' - ? this.nextPageConfiguration.offset - : 0) + channelQueryResult.length, - }; - } - } -} diff --git a/projects/stream-chat-angular/src/lib/channel.service.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.spec.ts index 4a49eaa6..60441c79 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -34,6 +34,7 @@ import { describe('ChannelService', () => { let service: ChannelService; let mockChatClient: { + dispatchEvent: (event: Event) => void; queryChannels: jasmine.Spy; channel: jasmine.Spy; updateMessage: jasmine.Spy; @@ -41,7 +42,9 @@ describe('ChannelService', () => { userID: string; pinMessage: jasmine.Spy; unpinMessage: jasmine.Spy; + logger: () => void; activeChannels: { [key: string]: Channel }; + on: (eventType: string, callback: (event: Event) => void) => void; }; let events$: Subject; let connectionState$: Subject<'online' | 'offline'>; @@ -58,6 +61,7 @@ describe('ChannelService', () => { beforeEach(() => { user = mockCurrentUser(); connectionState$ = new Subject<'online' | 'offline'>(); + const eventHandlers: { [key: string]: (event: Event) => void } = {}; mockChatClient = { queryChannels: jasmine .createSpy() @@ -69,6 +73,14 @@ describe('ChannelService', () => { pinMessage: jasmine.createSpy(), unpinMessage: jasmine.createSpy(), activeChannels: {}, + logger: () => undefined, + on: (eventType: string, callback: (event: Event) => void) => { + eventHandlers[eventType] = callback; + return { unsubscribe: () => delete eventHandlers[eventType] }; + }, + dispatchEvent: (event: Event) => { + eventHandlers[event.type]?.(event); + }, }; events$ = new Subject(); TestBed.configureTestingModule({ @@ -100,7 +112,10 @@ describe('ChannelService', () => { ); } - return service.init(filters, sort, options, shouldSetActiveChannel); + return service.init( + { filters, sort, options }, + { shouldSetActiveChannel }, + ); }; }); @@ -112,6 +127,7 @@ describe('ChannelService', () => { jasmine.any(Object), sort, jasmine.any(Object), + jasmine.any(Object), ); }); @@ -123,6 +139,7 @@ describe('ChannelService', () => { jasmine.any(Object), jasmine.any(Object), jasmine.objectContaining(options), + jasmine.any(Object), ); }); @@ -133,6 +150,7 @@ describe('ChannelService', () => { filters, jasmine.any(Object), jasmine.any(Object), + jasmine.any(Object), ); }); @@ -143,6 +161,9 @@ describe('ChannelService', () => { const mockChannels = generateMockChannels(); const result = spy.calls.mostRecent().args[0] as Channel[]; + + expect(result.length).toBe(mockChannels.length); + result.forEach((channel, index) => { expect(channel.cid).toEqual(mockChannels[index].cid); }); @@ -381,11 +402,11 @@ describe('ChannelService', () => { await init(); // Check that offset is set properly after query - // @ts-expect-error we know channelQuery exists, TS doesn't - expect(service['channelQuery']?.['nextPageConfiguration']).toEqual({ - type: 'offset', - offset: service.channels.length, - }); + expect( + // @ts-expect-error we know channelManager exists, TS doesn't + service.channelManager?.state?.getLatestValue().pagination?.options + .offset, + ).toEqual(service.channels.length); mockChatClient.queryChannels.calls.reset(); const existingChannel = service.channels[0]; @@ -399,6 +420,7 @@ describe('ChannelService', () => { jasmine.any(Object), jasmine.any(Object), jasmine.any(Object), + jasmine.any(Object), ); expect(service.channels.length).toEqual(prevChannelCount + 1); @@ -669,39 +691,18 @@ describe('ChannelService', () => { .subscribe((channels) => (channel = channels![1])); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); + mockChatClient.channel.and.returnValue(channel); const event = { message: mockMessage(), type: 'message.new', + channel_type: channel.type, + channel_id: channel.id, } as any as Event; - (channel as MockChannel).handleEvent('message.new', event); - - const firtChannel = (spy.calls.mostRecent().args[0] as Channel[])[0]; - - expect(firtChannel).toBe(channel); - }); + mockChatClient.dispatchEvent(event); - it('should call custom #customNewMessageHandler, if handler is provided', async () => { - await init(); - let channel!: Channel; - service.channels$ - .pipe(first()) - .subscribe((channels) => (channel = channels![1])); - const spy = jasmine.createSpy(); - service.customNewMessageHandler = spy; - const event = { - message: mockMessage(), - type: 'message.new', - } as any as Event; - (channel as MockChannel).handleEvent('message.new', event); + const firstChannel = (spy.calls.mostRecent().args[0] as Channel[])[0]; - expect(spy).toHaveBeenCalledWith( - event, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); + expect(firstChannel).toBe(channel); }); it('should handle if channel visibility changes', async () => { @@ -710,11 +711,13 @@ describe('ChannelService', () => { service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - mockChatClient.activeChannels[channel.cid] = channel; spyOn(channel, 'stopWatching'); - (channel as MockChannel).handleEvent('channel.hidden', { + mockChatClient.channel.and.returnValue(channel); + mockChatClient.dispatchEvent({ type: 'channel.hidden', - channel, + cid: channel.cid, + channel_type: channel.type, + channel_id: channel.id, }); let channels = spy.calls.mostRecent().args[0] as Channel[]; @@ -722,9 +725,12 @@ describe('ChannelService', () => { expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); expect(channel.stopWatching).not.toHaveBeenCalledWith(); - (channel as MockChannel).handleEvent('channel.visible', { + // @ts-expect-error white-box testing so we can wait for event handler promise to run + await service.channelManager?.channelVisibleHandler({ type: 'channel.visible', - channel, + cid: channel.cid, + channel_type: channel.type, + channel_id: channel.id, }); channels = spy.calls.mostRecent().args[0] as Channel[]; @@ -732,56 +738,17 @@ describe('ChannelService', () => { expect(channels.find((c) => c.cid === channel.cid)).not.toBeUndefined(); }); - it('should handle if channel visibility changes, if custom event handlers are provided', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const visibleSpy = jasmine.createSpy(); - const hiddenSpy = jasmine.createSpy(); - service.customChannelVisibleHandler = visibleSpy; - service.customChannelHiddenHandler = hiddenSpy; - const hiddenEvent = { - type: 'channel.hidden', - channel, - } as any as Event; - (channel as MockChannel).handleEvent('channel.hidden', hiddenEvent); - - expect(hiddenSpy).toHaveBeenCalledWith( - hiddenEvent, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); - - const visibleEvent = { - type: 'channel.visible', - channel, - } as any as Event; - (channel as MockChannel).handleEvent('channel.hidden', visibleEvent); - - expect(visibleSpy).toHaveBeenCalledWith( - visibleEvent, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); - }); - it('should remove channel from list, if deleted', async () => { await init(); let channel!: Channel; service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - mockChatClient.activeChannels[channel.cid] = channel; + mockChatClient.channel.and.returnValue(channel); spyOn(channel, 'stopWatching'); - (channel as MockChannel).handleEvent('channel.deleted', { + mockChatClient.dispatchEvent({ type: 'channel.deleted', - channel, + cid: channel.cid, }); const channels = spy.calls.mostRecent().args[0] as Channel[]; @@ -790,143 +757,19 @@ describe('ChannelService', () => { expect(channel.stopWatching).not.toHaveBeenCalledWith(); }); - it('should call #customChannelDeletedHandler, if channel is deleted and handler is provided', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const spy = jasmine.createSpy(); - service.customChannelDeletedHandler = spy; - const event = { - type: 'channel.deleted', - channel, - } as any as Event; - (channel as MockChannel).handleEvent('channel.deleted', event); - - expect(spy).toHaveBeenCalledWith( - event, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); - }); - - it('should update channel in list, if updated', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const spy = jasmine.createSpy(); - service.channels$.subscribe(spy); - (channel as MockChannel).handleEvent('channel.updated', { - type: 'channel.updated', - channel: { - cid: channel.cid, - name: 'New name', - }, - }); - - const channels = spy.calls.mostRecent().args[0] as Channel[]; - - const updatedChannel = channels.find((c) => c.cid === channel.cid); - - expect(updatedChannel!.data!.name).toBe('New name'); - expect(updatedChannel!.data!.own_capabilities).toBeDefined(); - expect(updatedChannel!.data!.hidden).toBeDefined(); - }); - - it('should emit changed channel if `capabilities.changed` dispatched', async () => { + it('should emit changed active channel if `capabilities.changed` dispatched', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = service.activeChannel!; const spy = jasmine.createSpy(); - service.channels$.subscribe(spy); - channel.data!.own_capabilities = ['send-message']; + service.activeChannel$.subscribe(spy); + spy.calls.reset(); + channel.data = { ...channel.data!, own_capabilities: ['send-message'] }; (channel as MockChannel).handleEvent('capabilities.changed', { type: 'capabilities.changed', cid: channel.cid, }); - const channels = spy.calls.mostRecent().args[0] as Channel[]; - - const updatedChannel = channels.find((c) => c.cid === channel.cid); - - expect(updatedChannel!.data!.own_capabilities).toEqual(['send-message']); - }); - - it('should call #customChannelUpdatedHandler, if updated and handler is provided', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const spy = jasmine.createSpy(); - service.customChannelUpdatedHandler = spy; - const event = { - type: 'channel.updated', - channel: { - cid: channel.cid, - name: 'New name', - }, - } as Event; - (channel as MockChannel).handleEvent('channel.updated', event); - - expect(spy).toHaveBeenCalledWith( - event, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); - }); - - it('should handle if channel is truncated', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const channelsSpy = jasmine.createSpy(); - service.channels$.subscribe(channelsSpy); - const messagesSpy = jasmine.createSpy(); - service.activeChannelMessages$.subscribe(messagesSpy); - (channel as MockChannel).handleEvent('channel.truncated', { - type: 'channel.truncated', - channel: { - cid: channel.cid, - name: 'New name', - }, - }); - - const channels = channelsSpy.calls.mostRecent().args[0] as Channel[]; - - expect( - channels.find((c) => c.cid === channel.cid)!.state.messages.length, - ).toBe(0); - - expect(messagesSpy).toHaveBeenCalledWith([]); - }); - - it('should call #customChannelTruncatedHandler, if channel is truncated and custom handler is provided', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const spy = jasmine.createSpy(); - service.customChannelTruncatedHandler = spy; - const event = { - type: 'channel.truncated', - channel: { - cid: channel.cid, - name: 'New name', - }, - } as Event; - (channel as MockChannel).handleEvent('channel.truncated', event); - - expect(spy).toHaveBeenCalledWith( - event, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); + expect(spy).toHaveBeenCalledWith(channel); }); it('should watch for reaction events', async () => { @@ -952,19 +795,6 @@ describe('ChannelService', () => { expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); }); - it('should watch for channel events', async () => { - const channel = generateMockChannels(1)[0]; - const unsubscribeSpy = jasmine.createSpy(); - spyOn(channel, 'on').and.returnValue({ unsubscribe: unsubscribeSpy }); - await init([channel], undefined, undefined, undefined, false); - - expect(channel.on).toHaveBeenCalledWith(jasmine.any(Function)); - - service.reset(); - - expect(unsubscribeSpy).toHaveBeenCalledWith(); - }); - it('should add the new channel to the top of the list, and start watching it, if user is added to a channel', fakeAsync(async () => { await init(); flush(); @@ -974,21 +804,19 @@ describe('ChannelService', () => { newChannel.type = 'messaging'; mockChatClient.channel.and.returnValue(newChannel); spyOn(newChannel, 'watch').and.callThrough(); - spyOn(newChannel, 'on').and.callThrough(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - events$.next({ - eventType: 'notification.added_to_channel', - event: { channel: newChannel } as any as Event, - }); + mockChatClient.dispatchEvent({ + type: 'notification.added_to_channel', + channel: newChannel, + } as any as Event); tick(); const channels = spy.calls.mostRecent().args[0] as Channel[]; const firstChannel = channels[0]; expect(firstChannel.cid).toBe(newChannel.cid); - expect(newChannel.watch).toHaveBeenCalledWith(); - expect(newChannel.on).toHaveBeenCalledWith(jasmine.any(Function)); + expect(newChannel.watch).toHaveBeenCalled(); })); it('should add the new channel to the top of the list, and start watching it, if a new message is received from the channel', fakeAsync(async () => { @@ -999,13 +827,12 @@ describe('ChannelService', () => { channel.type = 'messaging'; mockChatClient.channel.and.returnValue(channel); spyOn(channel, 'watch').and.callThrough(); - spyOn(channel, 'on').and.callThrough(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - events$.next({ - eventType: 'notification.message_new', - event: { channel: channel } as any as Event, - }); + mockChatClient.dispatchEvent({ + type: 'notification.message_new', + channel: channel, + } as any as Event); tick(); flush(); @@ -1013,8 +840,7 @@ describe('ChannelService', () => { const firstChannel = channels[0]; expect(firstChannel.cid).toBe(channel.cid); - expect(channel.watch).toHaveBeenCalledWith(); - expect(channel.on).toHaveBeenCalledWith(jasmine.any(Function)); + expect(channel.watch).toHaveBeenCalled(); })); it(`shouldn't add channels twice if two notification events were received for the same channel`, fakeAsync(async () => { @@ -1025,24 +851,22 @@ describe('ChannelService', () => { channel.type = 'messaging'; mockChatClient.channel.and.returnValue(channel); spyOn(channel, 'watch').and.callThrough(); - spyOn(channel, 'on').and.callThrough(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - events$.next({ - eventType: 'notification.added_to_channel', - event: { channel: channel } as any as Event, - }); - events$.next({ - eventType: 'notification.message_new', - event: { channel: channel } as any as Event, - }); + mockChatClient.dispatchEvent({ + channel: channel, + type: 'notification.added_to_channel', + } as any as Event); + mockChatClient.dispatchEvent({ + channel: channel, + type: 'notification.added_to_channel', + } as any as Event); tick(); flush(); const channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.filter((c) => c.cid === channel.cid).length).toBe(1); - expect(channel.on).toHaveBeenCalledOnceWith(jasmine.any(Function)); })); it('should remove channel form the list if user is removed from channel', async () => { @@ -1054,136 +878,48 @@ describe('ChannelService', () => { const spy = jasmine.createSpy(); service.channels$.subscribe(spy); mockChatClient.activeChannels[channel.cid] = channel; - spyOn(channel, 'stopWatching').and.callThrough(); spyOn(service, 'setAsActiveChannel'); - events$.next({ - eventType: 'notification.removed_from_channel', - event: { channel: channel } as any as Event, - }); + mockChatClient.dispatchEvent({ + channel: channel, + type: 'notification.removed_from_channel', + } as any as Event); let channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); expect(service.setAsActiveChannel).not.toHaveBeenCalled(); - expect(channel.stopWatching).toHaveBeenCalledWith(); - // Check that new messages won't readd the channel to the list - (channel as MockChannel).handleEvent('message.new', { + mockChatClient.dispatchEvent({ id: 'new-message', type: 'message.new', - }); + cid: channel.cid, + channel_type: channel.type, + channel_id: channel.id, + } as any as Event); channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); }); - it('should remove channel form the list if user is removed from channel, and emit new active channel', async () => { + it('should remove channel form the list if user is removed from channel, deselect active channel', async () => { await init(); let channel!: Channel; - let newActiveChannel!: Channel; service.channels$.pipe(first()).subscribe((channels) => { channel = channels![0]; - newActiveChannel = channels![1]; }); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - spyOn(service, 'setAsActiveChannel'); - events$.next({ - eventType: 'notification.removed_from_channel', - event: { channel: channel } as any as Event, + spyOn(service, 'deselectActiveChannel'); + mockChatClient.dispatchEvent({ + type: 'notification.removed_from_channel', + cid: channel.cid, }); - const channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); - expect(service.setAsActiveChannel).toHaveBeenCalledWith(newActiveChannel); - }); - - it('should call custom new message notification handler, if custom handler is provided', async () => { - await init(); - const spy = jasmine.createSpy(); - service.customNewMessageNotificationHandler = spy; - let channel!: Channel; - service.channels$ - .pipe(first()) - .subscribe((channels) => (channel = channels![1])); - const event = { - channel: channel, - } as any as Event; - const channelsSpy = jasmine.createSpy(); - service.channels$.subscribe(channelsSpy); - channelsSpy.calls.reset(); - events$.next({ - eventType: 'notification.message_new', - event: event, - }); - - expect(spy).toHaveBeenCalledWith( - { eventType: 'notification.message_new', event }, - jasmine.any(Function), - ); - - expect(channelsSpy).not.toHaveBeenCalled(); - }); - - it('should call custom added to channel notification handler, if custom handler is provided', async () => { - await init(); - const spy = jasmine - .createSpy() - .and.callFake((_: ClientEvent, setter: (channels: Channel[]) => []) => - setter([]), - ); - service.customAddedToChannelNotificationHandler = spy; - let channel!: Channel; - service.channels$ - .pipe(first()) - .subscribe((channels) => (channel = channels![1])); - const event = { - channel: channel, - } as any as Event; - const channelsSpy = jasmine.createSpy(); - service.channels$.subscribe(channelsSpy); - channelsSpy.calls.reset(); - events$.next({ - eventType: 'notification.added_to_channel', - event: event, - }); - - expect(spy).toHaveBeenCalledWith( - { eventType: 'notification.added_to_channel', event }, - jasmine.any(Function), - ); - - expect(channelsSpy).toHaveBeenCalledWith([]); - }); - - it('should call custom removed from channel notification handler, if custom handler is provided', async () => { - await init(); - const spy = jasmine.createSpy(); - service.customRemovedFromChannelNotificationHandler = spy; - let channel!: Channel; - service.channels$ - .pipe(first()) - .subscribe((channels) => (channel = channels![1])); - const event = { - channel: channel, - } as any as Event; - const channelsSpy = jasmine.createSpy(); - service.channels$.subscribe(channelsSpy); - channelsSpy.calls.reset(); - events$.next({ - eventType: 'notification.removed_from_channel', - event: event, - }); - - expect(spy).toHaveBeenCalledWith( - { eventType: 'notification.removed_from_channel', event }, - jasmine.any(Function), - ); - - expect(channelsSpy).not.toHaveBeenCalled(); + expect(service.deselectActiveChannel).toHaveBeenCalled(); }); it('should send message', async () => { @@ -2113,6 +1849,7 @@ describe('ChannelService', () => { jasmine.any(Object), jasmine.any(Object), jasmine.any(Object), + jasmine.any(Object), ); }); @@ -2140,6 +1877,7 @@ describe('ChannelService', () => { watch: true, message_limit: 25, }, + jasmine.any(Object), ); }); @@ -2260,19 +1998,15 @@ describe('ChannelService', () => { expect(activeChannel.watch).toHaveBeenCalledWith(); })); - it(`shouldn't deselect active channel if active channel is present after state reconnect`, fakeAsync(async () => { + it(`should reselect active channel if active channel is present after state reconnect, new messages are fetched`, fakeAsync(async () => { await init(); let channels!: Channel[]; service.channels$.subscribe((c) => (channels = c!)); const activeChannel = channels[0]; - const spy = jasmine.createSpy(); - service.activeChannel$.subscribe(spy); - spy.calls.reset(); const messagesSpy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(messagesSpy); messagesSpy.calls.reset(); mockChatClient.queryChannels.and.resolveTo(channels); - spyOn(service, 'deselectActiveChannel').and.callThrough(); const newMessage = generateMockMessages()[0]; newMessage.text = 'new message received while offline'; activeChannel.state.messages.push(newMessage); @@ -2280,34 +2014,29 @@ describe('ChannelService', () => { tick(); flush(); - expect(spy).not.toHaveBeenCalled(); - expect(service.deselectActiveChannel).not.toHaveBeenCalled(); + expect(service.activeChannel).toBe(activeChannel); expect(messagesSpy).toHaveBeenCalledWith( jasmine.arrayContaining([newMessage]), ); })); - it('should add new channel to channel list', () => { + it('should add new channel to channel list', async () => { + await init(); const channelsSpy = jasmine.createSpy(); service.channels$.subscribe(channelsSpy); channelsSpy.calls.reset(); const newChannel = generateMockChannels(1)[0]; newChannel.cid = 'my-new-channel'; - spyOn(service as any, 'watchForChannelEvents').and.callThrough(); service.setAsActiveChannel(newChannel); expect(channelsSpy).toHaveBeenCalledWith( jasmine.arrayContaining([newChannel]), ); - - //eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect((service as any).watchForChannelEvents).toHaveBeenCalledWith( - newChannel, - ); }); - it('should do nothing if same channel is selected twice', () => { + it('should do nothing if same channel is selected twice', async () => { + await init(); const activeChannel = generateMockChannels()[0]; service.setAsActiveChannel(activeChannel); @@ -2320,8 +2049,9 @@ describe('ChannelService', () => { expect(spy).not.toHaveBeenCalled(); }); - it('should set last read message id', () => { - const activeChannel = generateMockChannels()[0]; + it('should set last read message id', async () => { + await init(); + const activeChannel = generateMockChannels()[1]; activeChannel.id = 'next-active-channel'; activeChannel.state.read[user.id] = { last_read: new Date(), @@ -2336,7 +2066,8 @@ describe('ChannelService', () => { expect(service.activeChannelUnreadCount).toBe(5); }); - it(`should set last read message id to undefined if it's the last message`, () => { + it(`should set last read message id to undefined if it's the last message`, async () => { + await init(); const activeChannel = generateMockChannels()[0]; activeChannel.id = 'next-active-channel'; activeChannel.state.read[user.id] = { @@ -2357,8 +2088,9 @@ describe('ChannelService', () => { expect(service.activeChannelUnreadCount).toBe(0); }); - it('should be able to select empty channel as active channel', () => { - const channel = generateMockChannels()[0]; + it('should be able to select empty channel as active channel', async () => { + await init(); + const channel = generateMockChannels()[1]; channel.id = 'new-empty-channel'; channel.state.messages = []; channel.state.latestMessages = []; @@ -2579,38 +2311,6 @@ describe('ChannelService', () => { expect(activeChannel.markRead).toHaveBeenCalledWith(); }); - it('channel list setter should respect channel order', async () => { - await init(); - const currentChannels = service.channels; - const newChannel = generateMockChannels()[0]; - const newChannels = [currentChannels[0], newChannel, currentChannels[1]]; - // @ts-expect-error this is how we can differentiate between Channel and ChannelResponse - newChannels.forEach((c) => (c._client = {})); - const spy = jasmine.createSpy(); - service.channels$.subscribe(spy); - spy.calls.reset(); - - service['channelListSetter'](newChannels); - - expect(spy).toHaveBeenCalledOnceWith(newChannels); - }); - - it('channel list setter should watch for channel events', async () => { - await init(); - const currentChannels = service.channels; - const newChannel = generateMockChannels()[0]; - newChannel.cid = 'new-channel'; - const unsubscribeSpy = jasmine.createSpy(); - spyOn(newChannel, 'on').and.returnValue({ unsubscribe: unsubscribeSpy }); - const newChannels = [currentChannels[0], newChannel, currentChannels[1]]; - // @ts-expect-error this is how we can differentiate between Channel and ChannelResponse - newChannels.forEach((c) => (c._client = {})); - - service['channelListSetter'](newChannels); - - expect(newChannel.on).toHaveBeenCalledWith(jasmine.any(Function)); - }); - it('init with custom query', async () => { const mockChannels = generateMockChannels(); const customQuery = jasmine @@ -2623,7 +2323,7 @@ describe('ChannelService', () => { const hasMoreSpy = jasmine.createSpy(); service.hasMoreChannels$.subscribe(hasMoreSpy); - expect(result).toBe(mockChannels); + expect(result).toEqual(mockChannels); expect(customQuery).toHaveBeenCalledWith('first-page'); expect(service['shouldSetActiveChannel']).toBeFalse(); expect(service['messagePageSize']).toBe(30); diff --git a/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts index 6267b7b3..de48aecc 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts @@ -5,7 +5,6 @@ import { Channel, ChannelOptions, ChannelSort, - Event, GetRepliesAPIResponse, SendMessageAPIResponse, UserResponse, @@ -24,11 +23,17 @@ import { DefaultStreamChatGenerics, StreamMessage } from './types'; describe('ChannelService - threads', () => { let service: ChannelService; let mockChatClient: { + dispatchEvent: (event: Event) => void; queryChannels: jasmine.Spy; channel: jasmine.Spy; updateMessage: jasmine.Spy; deleteMessage: jasmine.Spy; userID: string; + pinMessage: jasmine.Spy; + unpinMessage: jasmine.Spy; + logger: () => void; + activeChannels: { [key: string]: Channel }; + on: (eventType: string, callback: (event: Event) => void) => void; }; let events$: Subject; let connectionState$: Subject<'online' | 'offline'>; @@ -43,6 +48,7 @@ describe('ChannelService - threads', () => { beforeEach(() => { user = mockCurrentUser(); connectionState$ = new Subject<'online' | 'offline'>(); + const eventHandlers: { [key: string]: (event: Event) => void } = {}; mockChatClient = { queryChannels: jasmine .createSpy() @@ -51,6 +57,17 @@ describe('ChannelService - threads', () => { updateMessage: jasmine.createSpy(), deleteMessage: jasmine.createSpy(), userID: user.id, + pinMessage: jasmine.createSpy(), + unpinMessage: jasmine.createSpy(), + activeChannels: {}, + logger: () => undefined, + on: (eventType: string, callback: (event: Event) => void) => { + eventHandlers[eventType] = callback; + return { unsubscribe: () => delete eventHandlers[eventType] }; + }, + dispatchEvent: (event: Event) => { + eventHandlers[event.type]?.(event); + }, }; events$ = new Subject(); TestBed.configureTestingModule({ @@ -76,7 +93,7 @@ describe('ChannelService - threads', () => { channels || generateMockChannels(), ); - await service.init(filters, sort, options); + await service.init({ filters, sort, options }); }; }); @@ -431,54 +448,6 @@ describe('ChannelService - threads', () => { expect(parentMessageSpy).toHaveBeenCalledWith(undefined); }); - it('should call #customChannelTruncatedHandler, if channel is truncated and custom handler is provided', async () => { - await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const spy = jasmine - .createSpy() - .and.callFake( - ( - _, - __, - ___, - ____, - threadListSetter: (list: StreamMessage[]) => void, - parentMessageSetter: (id: string | undefined) => void, - ) => { - threadListSetter([]); - parentMessageSetter(undefined); - }, - ); - service.customChannelTruncatedHandler = spy; - const event = { - type: 'channel.truncated', - channel: { - cid: channel.cid, - name: 'New name', - }, - } as any as Event; - const messagesSpy = jasmine.createSpy(); - service.activeThreadMessages$.subscribe(messagesSpy); - const parentMessageSpy = jasmine.createSpy(); - service.activeParentMessageId$.subscribe(parentMessageSpy); - messagesSpy.calls.reset(); - parentMessageSpy.calls.reset(); - (channel as MockChannel).handleEvent('channel.truncated', event); - - expect(spy).toHaveBeenCalledWith( - event, - channel, - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - jasmine.any(Function), - ); - - expect(messagesSpy).toHaveBeenCalledWith([]); - expect(parentMessageSpy).toHaveBeenCalledWith(undefined); - }); - it('should watch for reaction events', async () => { await init(); const spy = jasmine.createSpy(); diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index de989ac7..ec3a011d 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -10,17 +10,17 @@ import { filter, first, map, shareReplay, take } from 'rxjs/operators'; import { Attachment, Channel, - ChannelFilters, - ChannelOptions, - ChannelResponse, - ChannelSort, + ChannelManager, + ChannelManagerEventHandlerOverrides, + ChannelManagerOptions, Event, - EventTypes, FormatMessageResponse, MemberFilters, Message, MessageResponse, + promoteChannel, ReactionResponse, + Unsubscribe, UpdatedMessage, UserResponse, } from 'stream-chat'; @@ -32,15 +32,17 @@ import { getReadBy } from './read-by'; import { AttachmentUpload, AttachmentUploadErrorReason, + ChannelQueryConfig, + ChannelQueryConfigInput, ChannelQueryResult, ChannelQueryState, ChannelQueryType, + ChannelServiceOptions, DefaultStreamChatGenerics, MessageInput, MessageReactionType, StreamMessage, } from './types'; -import { ChannelQuery } from './channel-query'; /** * The `ChannelService` provides data and interaction for the channel list and message list. @@ -127,138 +129,6 @@ export class ChannelService< * This property isn't always updated, please use `channel.read` to display up-to-date read information */ activeChannelUnreadCount?: number; - /** - * Custom event handler to call if a new message received from a channel that is not being watched, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/) - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customNewMessageNotificationHandler?: ( - clientEvent: ClientEvent, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - ) => void; - /** - * Custom event handler to call when the user is added to a channel, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customAddedToChannelNotificationHandler?: ( - clientEvent: ClientEvent, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - ) => void; - /** - * Custom event handler to call when the user is removed from a channel, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customRemovedFromChannelNotificationHandler?: ( - clientEvent: ClientEvent, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - ) => void; - /** - * Custom event handler to call when a channel is deleted, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customChannelDeletedHandler?: ( - event: Event, - channel: Channel, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - messageListSetter: (messages: StreamMessage[]) => void, - threadListSetter: (messages: StreamMessage[]) => void, - parentMessageSetter: (message: StreamMessage | undefined) => void, - ) => void; - /** - * Custom event handler to call when a channel is updated, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customChannelUpdatedHandler?: ( - event: Event, - channel: Channel, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - messageListSetter: (messages: StreamMessage[]) => void, - threadListSetter: (messages: StreamMessage[]) => void, - parentMessageSetter: (message: StreamMessage | undefined) => void, - ) => void; - /** - * Custom event handler to call when a channel is truncated, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customChannelTruncatedHandler?: ( - event: Event, - channel: Channel, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - messageListSetter: (messages: StreamMessage[]) => void, - threadListSetter: (messages: StreamMessage[]) => void, - parentMessageSetter: (message: StreamMessage | undefined) => void, - ) => void; - /** - * Custom event handler to call when a channel becomes hidden, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customChannelHiddenHandler?: ( - event: Event, - channel: Channel, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - messageListSetter: (messages: StreamMessage[]) => void, - threadListSetter: (messages: StreamMessage[]) => void, - parentMessageSetter: (message: StreamMessage | undefined) => void, - ) => void; - /** - * Custom event handler to call when a channel becomes visible, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customChannelVisibleHandler?: ( - event: Event, - channel: Channel, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - messageListSetter: (messages: StreamMessage[]) => void, - threadListSetter: (messages: StreamMessage[]) => void, - parentMessageSetter: (message: StreamMessage | undefined) => void, - ) => void; - /** - * Custom event handler to call if a new message received from a channel that is being watched, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). - * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. - */ - customNewMessageHandler?: ( - event: Event, - channel: Channel, - channelListSetter: ( - channels: Channel[], - shouldStopWatchingRemovedChannels?: boolean, - ) => void, - messageListSetter: (messages: StreamMessage[]) => void, - threadListSetter: (messages: StreamMessage[]) => void, - parentMessageSetter: (message: StreamMessage | undefined) => void, - ) => void; /** * You can override the default file upload request - you can use this to upload files to your own CDN */ @@ -310,6 +180,9 @@ export class ChannelService< * @internal */ isMessageLoadingInProgress = false; + /** + * @internal + */ messagePageSize = 25; private channelsSubject = new BehaviorSubject[] | undefined>( undefined, @@ -325,7 +198,6 @@ export class ChannelService< >([]); private hasMoreChannelsSubject = new ReplaySubject(1); private activeChannelSubscriptions: { unsubscribe: () => void }[] = []; - private channelSubscriptions: { [key: string]: () => void } = {}; private activeParentMessageIdSubject = new BehaviorSubject< string | undefined >(undefined); @@ -350,79 +222,23 @@ export class ChannelService< [], ); private _shouldMarkActiveChannelAsRead = true; - private shouldSetActiveChannel: boolean | undefined; + private shouldSetActiveChannel = true; private clientEventsSubscription: Subscription | undefined; private isStateRecoveryInProgress = false; private channelQueryStateSubject = new BehaviorSubject< ChannelQueryState | undefined >(undefined); - private channelQuery?: - | ChannelQuery - | ((queryType: ChannelQueryType) => Promise>); - - private channelListSetter = ( - channels: Channel[], - shouldStopWatchingRemovedChannels = true, - ) => { - const currentChannels = this.channelsSubject.getValue() || []; - const deletedChannels = currentChannels.filter( - (c) => !channels?.find((channel) => channel.cid === c.cid), - ); - - for (let i = 0; i < channels.length; i++) { - const channel = channels[i]; - if (!this.channelSubscriptions[channel.cid]) { - this.watchForChannelEvents(channel); - } - if (deletedChannels.includes(channel)) { - if (shouldStopWatchingRemovedChannels) { - if (this.channelSubscriptions[channel.cid]) { - this.channelSubscriptions[channel.cid](); - delete this.channelSubscriptions.cid; - } - void this.chatClientService.chatClient.activeChannels[channel.cid] - ?.stopWatching() - .catch((err) => - this.chatClientService.chatClient.logger( - 'warn', - 'Failed to unwatch channel', - err as Record, - ), - ); - } - } - } - const nextChannels = channels; - this.channelsSubject.next(nextChannels); - if ( - !nextChannels.find( - (c) => c.cid === this.activeChannelSubject.getValue()?.cid, - ) - ) { - if (nextChannels.length > 0) { - this.setAsActiveChannel(nextChannels[0]); - } else { - this.activeChannelSubject.next(undefined); - } - } - }; - - private messageListSetter = (messages: StreamMessage[]) => { - this.activeChannelMessagesSubject.next(messages); - }; - - private threadListSetter = (messages: StreamMessage[]) => { - this.activeThreadMessagesSubject.next(messages); - }; - - private parentMessageSetter = (message: StreamMessage | undefined) => { - this.activeParentMessageIdSubject.next(message?.id); - }; + private customChannelQuery?: ( + queryType: ChannelQueryType, + ) => Promise>; + private channelManager?: ChannelManager; + private channelQueryConfig?: ChannelQueryConfig; private dismissErrorNotification?: () => void; private areReadEventsPaused = false; private markReadThrottleTime = 1050; private markReadTimeout?: ReturnType; private scheduledMarkReadRequest?: () => void; + private channelManagerSubscriptions: Unsubscribe[] = []; constructor( private chatClientService: ChatClientService, @@ -697,55 +513,62 @@ export class ChannelService< /** * Queries the channels with the given filters, sorts and options. More info about [channel querying](/chat/docs/javascript/query_channels/) can be found in the platform documentation. By default the first channel in the list will be set as active channel and will be marked as read. - * @param filters - * @param sort - * @param options - * @param shouldSetActiveChannel Decides if the first channel in the result should be made as an active channel or not. + * @param queryConfig the filter, sort and options for the query + * @param options behavior customization for the channel list and WebSocket event handling * @returns the list of channels found by the query */ init( - filters: ChannelFilters, - sort?: ChannelSort, - options?: ChannelOptions, - shouldSetActiveChannel: boolean = true, + queryConfig: ChannelQueryConfigInput, + options?: ChannelServiceOptions, ) { - this.channelQuery = new ChannelQuery( - this.chatClientService, - this, - filters, - sort || { last_message_at: -1 }, - { + this.channelQueryConfig = { + filters: queryConfig.filters, + sort: queryConfig.sort ?? { last_message_at: -1 }, + options: { limit: 25, state: true, presence: true, watch: true, message_limit: this.messagePageSize, - ...options, + ...queryConfig.options, }, - ); + }; return this._init({ - shouldSetActiveChannel, - messagePageSize: options?.message_limit ?? this.messagePageSize, + ...options, + messagePageSize: + queryConfig.options?.message_limit ?? this.messagePageSize, }); } - /** * Queries the channels with the given query function. More info about [channel querying](/chat/docs/javascript/query_channels/) can be found in the platform documentation. * @param query - * @param options - * @param options.shouldSetActiveChannel The `shouldSetActiveChannel` specifies if the first channel in the result should be selected as the active channel or not. Default is `true`. + * @param options behavior customization for the channel list and WebSocket event handling * @param options.messagePageSize How many messages should we load? The default is 25 * @returns the channels that were loaded */ initWithCustomQuery( query: (queryType: ChannelQueryType) => Promise>, - options: { shouldSetActiveChannel: boolean; messagePageSize: number } = { + options: ChannelServiceOptions & { messagePageSize: number } = { shouldSetActiveChannel: true, messagePageSize: this.messagePageSize, }, ) { - this.channelQuery = query; + this.messagePageSize = options?.messagePageSize ?? this.messagePageSize; + + this.shouldSetActiveChannel = + options?.shouldSetActiveChannel ?? this.shouldSetActiveChannel; + const eventHandlerOverrides = options?.eventHandlerOverrides; + const managerOptions = { ...options }; + delete managerOptions?.eventHandlerOverrides; + delete managerOptions?.shouldSetActiveChannel; + + this.customChannelQuery = query; + this.createChannelManager({ + eventHandlerOverrides, + options: managerOptions, + }); + return this._init(options); } @@ -754,22 +577,19 @@ export class ChannelService< */ reset() { this.deselectActiveChannel(); - this.channelsSubject.next(undefined); this.channelQueryStateSubject.next(undefined); this.clientEventsSubscription?.unsubscribe(); this.dismissErrorNotification?.(); this.dismissErrorNotification = undefined; - Object.keys(this.channelSubscriptions).forEach((cid) => { - this.channelSubscriptions[cid](); - }); - this.channelSubscriptions = {}; + this.channelQueryConfig = undefined; + this.destroyChannelManager(); } /** * Loads the next page of channels. The page size can be set in the [query option](/chat/docs/javascript/query_channels/#query-options) object. */ async loadMoreChannels() { - await this.queryChannels(false, 'next-page'); + await this.queryChannels('next-page'); } /** @@ -1111,46 +931,31 @@ export class ChannelService< * @param channel */ addChannel(channel: Channel) { + if (!this.channelManager) { + throw new Error('Channel service not initialized'); + } if (!this.channels.find((c) => c.cid === channel.cid)) { - this.channelsSubject.next([channel, ...this.channels]); - this.watchForChannelEvents(channel); + this.channelManager?.setChannels( + promoteChannel({ + channels: this.channels, + channelToMove: channel, + sort: this.channelQueryConfig?.sort ?? [], + }), + ); } } /** * * @param cid - * @param shouldStopWatching */ - removeChannel(cid: string, shouldStopWatching = true) { - const remainingChannels = this.channels.filter((c) => c.cid !== cid); - - if (shouldStopWatching) { - if (this.channelSubscriptions[cid]) { - this.channelSubscriptions[cid](); - delete this.channelSubscriptions.cid; - } - void this.chatClientService.chatClient.activeChannels[cid] - ?.stopWatching() - .catch((err) => - this.chatClientService.chatClient.logger( - 'warn', - 'Failed to unwatch channel', - err as Record, - ), - ); + removeChannel(cid: string) { + if (!this.channelManager) { + throw new Error('Channel service not initialized'); } + const remainingChannels = this.channels.filter((c) => c.cid !== cid); - if (remainingChannels.length < this.channels.length) { - this.channelsSubject.next(remainingChannels); - if (cid === this.activeChannelSubject.getValue()?.cid) { - if (remainingChannels.length > 0) { - this.setAsActiveChannel(remainingChannels[0]); - } else { - this.activeChannelSubject.next(undefined); - } - } - } + this.channelManager?.setChannels(remainingChannels); } private async sendMessageRequest( @@ -1323,14 +1128,7 @@ export class ChannelService< } this.isStateRecoveryInProgress = true; try { - // If channel list is not inited, we set the active channel - const shoulSetActiveChannel = - this.shouldSetActiveChannel && - !this.activeChannelSubject.getValue(); - await this.queryChannels( - shoulSetActiveChannel || false, - 'recover-state', - ); + await this.queryChannels('recover-state'); if (this.activeChannelSubject.getValue()) { // Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages void this.setAsActiveParentMessage(undefined); @@ -1354,48 +1152,7 @@ export class ChannelService< } break; } - case 'notification.message_new': { - if (this.customNewMessageNotificationHandler) { - this.customNewMessageNotificationHandler( - clientEvent, - this.channelListSetter, - ); - } else { - this.handleNewMessageNotification(clientEvent); - } - break; - } - case 'notification.added_to_channel': { - if (this.customAddedToChannelNotificationHandler) { - this.customAddedToChannelNotificationHandler( - clientEvent, - this.channelListSetter, - ); - } else { - this.handleAddedToChannelNotification(clientEvent); - } - break; - } - case 'notification.removed_from_channel': { - if (this.customRemovedFromChannelNotificationHandler) { - this.customRemovedFromChannelNotificationHandler( - clientEvent, - this.channelListSetter, - ); - } else { - this.handleRemovedFromChannelNotification(clientEvent); - } - break; - } case 'user.updated': { - const updatedChannels = this.channelsSubject.getValue()?.map((c) => { - if (this.chatClientService.chatClient.activeChannels[c.cid]) { - return this.chatClientService.chatClient.activeChannels[c.cid]; - } else { - return c; - } - }); - this.channelsSubject.next(updatedChannels); const activeChannel = this.activeChannelSubject.getValue(); if (activeChannel) { this.activeChannelSubject.next( @@ -1424,49 +1181,6 @@ export class ChannelService< } } - private handleRemovedFromChannelNotification(clientEvent: ClientEvent) { - const channelIdToBeRemoved = clientEvent.event.channel!.cid; - this.removeChannel(channelIdToBeRemoved, true); - } - - private handleNewMessageNotification(clientEvent: ClientEvent) { - if (clientEvent.event.channel) { - void this.addChannelFromNotification(clientEvent.event.channel); - } - } - - private handleAddedToChannelNotification(clientEvent: ClientEvent) { - if (clientEvent.event.channel) { - void this.addChannelFromNotification(clientEvent.event.channel); - } - } - - private async addChannelFromNotification( - channelResponse: ChannelResponse, - ) { - const newChannel = this.chatClientService.chatClient.channel( - channelResponse.type, - channelResponse.id, - ); - let currentChannels = this.channelsSubject.getValue() || []; - if (currentChannels.find((c) => c.cid === newChannel.cid)) { - return; - } - await newChannel.watch().catch((err) => { - this.chatClientService.chatClient.logger( - 'error', - 'Failed to add channel to channel list because watch request failed', - err as Record, - ); - }); - currentChannels = this.channelsSubject.getValue() || []; - if (currentChannels.find((c) => c.cid === newChannel.cid)) { - return; - } - this.watchForChannelEvents(newChannel); - this.channelsSubject.next([newChannel, ...currentChannels]); - } - private watchForActiveChannelEvents(channel: Channel) { this.activeChannelSubscriptions.push( channel.on('message.new', (event) => { @@ -1554,6 +1268,23 @@ export class ChannelService< this.ngZone.run(() => this.handleTypingStopEvent(e)), ), ); + this.activeChannelSubscriptions.push( + channel.on('capabilities.changed', (_) => { + this.activeChannelSubject.next(this.activeChannelSubject.getValue()); + }), + ); + this.activeChannelSubscriptions.push( + channel.on('channel.updated', (_) => { + this.activeChannelSubject.next(this.activeChannelSubject.getValue()); + }), + ); + this.activeChannelSubscriptions.push( + channel.on('channel.truncated', (_) => { + this.activeChannelSubject.next(this.activeChannelSubject.getValue()); + this.activeChannelMessagesSubject.next([]); + void this.setAsActiveParentMessage(undefined); + }), + ); } /** @@ -1771,234 +1502,115 @@ export class ChannelService< this.activeChannelSubscriptions = []; } - private async queryChannels( - shouldSetActiveChannel: boolean, - queryType: ChannelQueryType, - ) { - if (!this.channelQuery) { + private async queryChannels(queryType: ChannelQueryType) { + if (!this.channelManager) { throw new Error( - 'Query channels called before initializing ChannelQuery instance', + 'Query channels called before initializing ChannelService', ); } try { this.channelQueryStateSubject.next({ state: 'in-progress' }); - const { channels, hasMorePage } = await ('query' in this.channelQuery - ? this.channelQuery.query(queryType) - : this.channelQuery(queryType)); - const filteredChannels = channels.filter( - (channel, index) => - !channels.slice(0, index).find((c) => c.cid === channel.cid), - ); - filteredChannels.forEach((c) => { - if (!this.channelSubscriptions[c.cid]) { - this.watchForChannelEvents(c); + const previousActiveChannel = this.activeChannelSubject.getValue(); + if (queryType === 'recover-state') { + this.activeChannelSubject.next(undefined); + this.channelManager.setChannels([]); + } + + if (this.customChannelQuery) { + const result = await this.customChannelQuery(queryType); + const currentChannels = this.channels; + const filteredChannels = result.channels.filter( + (channel, index) => + !currentChannels.slice(0, index).find((c) => c.cid === channel.cid), + ); + this.channelManager.setChannels(filteredChannels); + this.hasMoreChannelsSubject.next(result.hasMorePage); + } else { + if (queryType === 'first-page' || queryType === 'recover-state') { + if (!this.channelQueryConfig) { + throw new Error('Channel query config not initialized'); + } + await this.channelManager.queryChannels( + { ...this.channelQueryConfig.filters }, + this.channelQueryConfig.sort, + this.channelQueryConfig.options, + ); + } else { + await this.channelManager.loadNext(); } - }); + } - this.channelsSubject.next(filteredChannels); - const currentActiveChannel = this.activeChannelSubject.getValue(); - if ( - currentActiveChannel && - !filteredChannels.find((c) => c.cid === currentActiveChannel?.cid) - ) { - this.deselectActiveChannel(); + if (this.channelManagerSubscriptions.length === 0) { + this.channelManagerSubscriptions.push( + this.channelManager.state.subscribeWithSelector( + (s) => ({ channels: s.channels }), + ({ channels }) => { + const activeChannel = this.activeChannel; + if ( + activeChannel && + !channels.find((c) => c.cid === activeChannel.cid) + ) { + this.deselectActiveChannel(); + } + this.channelsSubject.next(channels); + }, + ), + ); + if (!this.customChannelQuery) { + this.channelManagerSubscriptions.push( + this.channelManager.state.subscribeWithSelector( + (s) => ({ hasNext: s.pagination?.hasNext ?? true }), + ({ hasNext }) => this.hasMoreChannelsSubject.next(hasNext), + ), + ); + } + } + + const shouldSetActiveChannel = + queryType === 'next-page' ? false : this.shouldSetActiveChannel; + if (previousActiveChannel && queryType === 'recover-state') { + try { + if ( + !this.channels.find((c) => c.cid === previousActiveChannel?.cid) + ) { + await previousActiveChannel.watch(); + this.channelManager.setChannels( + promoteChannel({ + channels: this.channels, + channelToMove: previousActiveChannel, + sort: this.channelQueryConfig?.sort ?? [], + }), + ); + } + this.setAsActiveChannel(previousActiveChannel); + } catch (error) { + this.chatClientService.chatClient.logger( + 'warn', + 'Unable to refetch active channel after state recover', + error as Record, + ); + } } else if ( - filteredChannels.length > 0 && - !currentActiveChannel && + this.channels.length > 0 && + !previousActiveChannel && shouldSetActiveChannel ) { - this.setAsActiveChannel(filteredChannels[0]); + this.setAsActiveChannel(this.channels[0]); } - this.hasMoreChannelsSubject.next(hasMorePage); this.channelQueryStateSubject.next({ state: 'success' }); this.dismissErrorNotification?.(); - return channels; + return this.channels; } catch (error) { this.channelQueryStateSubject.next({ state: 'error', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment error, }); - throw error; - } - } - - private watchForChannelEvents(channel: Channel) { - if (this.channelSubscriptions[channel.cid]) { - this.channelSubscriptions[channel.cid](); - } - const unsubscribe = channel.on((event: Event) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const type = event.type as EventTypes | 'capabilities.changed'; - switch (type) { - case 'message.new': { - if (this.customNewMessageHandler) { - this.customNewMessageHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter, - this.threadListSetter, - this.parentMessageSetter, - ); - } else { - this.handleNewMessage(event, channel); - } - break; - } - case 'channel.hidden': { - if (this.customChannelHiddenHandler) { - this.customChannelHiddenHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter, - this.threadListSetter, - this.parentMessageSetter, - ); - } else { - this.handleChannelHidden(event); - } - break; - } - case 'channel.deleted': { - if (this.customChannelDeletedHandler) { - this.customChannelDeletedHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter, - this.threadListSetter, - this.parentMessageSetter, - ); - } else { - this.handleChannelDeleted(event); - } - break; - } - case 'channel.visible': { - if (this.customChannelVisibleHandler) { - this.customChannelVisibleHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter, - this.threadListSetter, - this.parentMessageSetter, - ); - } else { - this.handleChannelVisible(event, channel); - } - break; - } - case 'channel.updated': { - if (this.customChannelUpdatedHandler) { - this.customChannelUpdatedHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter, - this.threadListSetter, - this.parentMessageSetter, - ); - } else { - this.handleChannelUpdate(event); - } - break; - } - case 'channel.truncated': { - if (this.customChannelTruncatedHandler) { - this.customChannelTruncatedHandler( - event, - channel, - this.channelListSetter, - this.messageListSetter, - this.threadListSetter, - this.parentMessageSetter, - ); - } else { - this.handleChannelTruncate(event); - } - break; - } - case 'capabilities.changed': { - const cid = event.cid; - if (cid) { - const currentChannels = this.channelsSubject.getValue(); - const index = currentChannels?.findIndex((c) => c.cid === cid); - if (index !== -1 && index !== undefined) { - this.channelsSubject.next([...currentChannels!]); - if (cid === this.activeChannelSubject.getValue()?.cid) { - this.activeChannelSubject.next( - this.activeChannelSubject.getValue(), - ); - } - } - } - break; - } - } - }); - this.channelSubscriptions[channel.cid] = unsubscribe.unsubscribe; - } - - private handleNewMessage(_: Event, channel: Channel) { - const channelIndex = this.channels.findIndex((c) => c.cid === channel.cid); - this.channels.splice(channelIndex, 1); - this.channelsSubject.next([channel, ...this.channels]); - } - - private handleChannelHidden(event: Event) { - this.removeChannel(event.channel!.cid, false); - } - - private handleChannelDeleted(event: Event) { - this.removeChannel(event.channel!.cid, false); - } - - private handleChannelVisible(event: Event, channel: Channel) { - if (!this.channels.find((c) => c.cid === event.cid)) { - this.channelsSubject.next([...this.channels, channel]); - } - } - - private handleChannelUpdate(event: Event) { - const channelIndex = this.channels.findIndex( - (c) => c.cid === event.channel!.cid, - ); - if (channelIndex !== -1) { - const channel = this.channels[channelIndex]; - const notIncludedProperies = { - hidden: channel.data?.hidden || false, - own_capabilities: channel.data?.own_capabilities || [], - }; - channel.data = { - ...event.channel!, - ...notIncludedProperies, - }; - this.channelsSubject.next([...this.channels]); - if (event.channel?.cid === this.activeChannelSubject.getValue()?.cid) { - this.activeChannelSubject.next(channel); - } - } - } - - private handleChannelTruncate(event: Event) { - const channelIndex = this.channels.findIndex( - (c) => c.cid === event.channel!.cid, - ); - if (channelIndex !== -1) { - this.channels[channelIndex].state.messages = []; - this.channelsSubject.next([...this.channels]); - if (event.channel?.cid === this.activeChannelSubject.getValue()?.cid) { - const channel = this.activeChannelSubject.getValue()!; - channel.state.messages = []; - this.activeChannelSubject.next(channel); - this.activeChannelMessagesSubject.next([]); - this.activeParentMessageIdSubject.next(undefined); - this.activeThreadMessagesSubject.next([]); + if (queryType === 'recover-state') { + this.channelManager.setChannels([]); } + throw error; } } @@ -2206,20 +1818,28 @@ export class ChannelService< this.markReadTimeout = undefined; } - private async _init(settings: { - shouldSetActiveChannel: boolean; - messagePageSize: number; - }) { - this.shouldSetActiveChannel = settings.shouldSetActiveChannel; - this.messagePageSize = settings.messagePageSize; + private async _init( + options: ChannelServiceOptions & { messagePageSize: number }, + ) { + this.messagePageSize = options.messagePageSize; + + this.shouldSetActiveChannel = + options?.shouldSetActiveChannel ?? this.shouldSetActiveChannel; + const eventHandlerOverrides = options?.eventHandlerOverrides; + const managerOptions = { ...options }; + delete managerOptions?.eventHandlerOverrides; + delete managerOptions?.shouldSetActiveChannel; + + this.createChannelManager({ + eventHandlerOverrides, + options: managerOptions, + }); + this.clientEventsSubscription = this.chatClientService.events$.subscribe( (notification) => void this.handleNotification(notification), ); try { - const result = await this.queryChannels( - this.shouldSetActiveChannel, - 'first-page', - ); + const result = await this.queryChannels('first-page'); return result; } catch (error) { this.dismissErrorNotification = @@ -2230,4 +1850,37 @@ export class ChannelService< throw error; } } + + private createChannelManager({ + eventHandlerOverrides, + options, + }: { + eventHandlerOverrides?: ChannelManagerEventHandlerOverrides; + options?: ChannelManagerOptions; + }) { + this.channelManager = new ChannelManager({ + client: this.chatClientService.chatClient, + options: { + ...options, + allowNotLoadedChannelPromotionForEvent: { + 'message.new': false, + 'channel.visible': true, + 'notification.added_to_channel': true, + 'notification.message_new': true, + ...options?.allowNotLoadedChannelPromotionForEvent, + }, + }, + eventHandlerOverrides, + }); + this.channelManager.registerSubscriptions(); + } + + private destroyChannelManager() { + this.channelManager?.unregisterSubscriptions(); + this.channelManager = undefined; + this.channelManagerSubscriptions.forEach((unsubscribe) => unsubscribe()); + this.channelManagerSubscriptions = []; + this.channelsSubject.next(undefined); + this.hasMoreChannelsSubject.next(true); + } } diff --git a/projects/stream-chat-angular/src/lib/mocks/index.ts b/projects/stream-chat-angular/src/lib/mocks/index.ts index a6fe9ddd..50aaff0b 100644 --- a/projects/stream-chat-angular/src/lib/mocks/index.ts +++ b/projects/stream-chat-angular/src/lib/mocks/index.ts @@ -62,6 +62,7 @@ export const generateMockChannels = (length = 25) => { const channel = { cid: 'cid' + index.toString(), id: index.toString(), + type: 'messaging', data: { id: index.toString(), name: `Channel${index}`, @@ -117,6 +118,7 @@ export const generateMockChannels = (length = 25) => { pinnedMessages: [], threads: {}, read: {}, + membership: {}, members: { jack: { user: { id: 'jack', name: 'Jack' } }, sara: { user: { id: 'sara', name: 'Sara' } }, diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts index 349e7777..c4d124d9 100644 --- a/projects/stream-chat-angular/src/lib/types.ts +++ b/projects/stream-chat-angular/src/lib/types.ts @@ -4,7 +4,11 @@ import type { Attachment, Channel, ChannelFilters, + ChannelManagerEventHandlerOverrides, + ChannelManagerOptions, ChannelMemberResponse, + ChannelOptions, + ChannelSort, CommandResponse, Event, ExtendableGenerics, @@ -546,3 +550,26 @@ export type MessageTextContext = { isQuoted: boolean; shouldTranslate: boolean; }; + +export type ChannelServiceOptions< + T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = ChannelManagerOptions & { + shouldSetActiveChannel?: boolean; + eventHandlerOverrides?: ChannelManagerEventHandlerOverrides; +}; + +export type ChannelQueryConfig< + T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + filters: ChannelFilters; + sort: ChannelSort; + options: ChannelOptions; +}; + +export type ChannelQueryConfigInput< + T extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + filters: ChannelFilters; + sort?: ChannelSort; + options?: ChannelOptions; +}; diff --git a/projects/stream-chat-angular/src/public-api.ts b/projects/stream-chat-angular/src/public-api.ts index 5644d4eb..d764379e 100644 --- a/projects/stream-chat-angular/src/public-api.ts +++ b/projects/stream-chat-angular/src/public-api.ts @@ -62,7 +62,6 @@ export * from './lib/voice-recording/voice-recording.component'; export * from './lib/voice-recording/voice-recording-wavebar/voice-recording-wavebar.component'; export * from './lib/is-on-separate-date'; export * from './lib/message-reactions-selector/message-reactions-selector.component'; -export * from './lib/channel-query'; export * from './lib/virtualized-list.service'; export * from './lib/virtualized-message-list.service'; export * from './lib/user-list/user-list.component'; From 807ba5c06fbc2dc17995625a3cfe683e4048669f Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 27 Feb 2025 15:48:55 +0100 Subject: [PATCH 3/3] feat: recoverState method to allow SDK components to initiate state recovery --- .../stream-chat-angular/src/assets/i18n/en.ts | 1 + .../channel-list/channel-list.component.html | 9 +- .../channel-list.component.spec.ts | 5 +- .../channel-list/channel-list.component.ts | 8 +- .../channel-preview.component.ts | 3 +- .../src/lib/channel.service.spec.ts | 53 +++++ .../src/lib/channel.service.ts | 206 ++++++++++++------ .../src/lib/mocks/index.ts | 3 + 8 files changed, 205 insertions(+), 83 deletions(-) diff --git a/projects/stream-chat-angular/src/assets/i18n/en.ts b/projects/stream-chat-angular/src/assets/i18n/en.ts index 933ba382..534cf2ff 100644 --- a/projects/stream-chat-angular/src/assets/i18n/en.ts +++ b/projects/stream-chat-angular/src/assets/i18n/en.ts @@ -131,5 +131,6 @@ export const en = { 'You currently have {{count}} attachments, the maximum is {{max}}': 'You currently have {{count}} attachments, the maximum is {{max}}', 'and others': 'and others', + 'Reload channels': 'Reload channels', }, }; diff --git a/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.html b/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.html index ab9cab22..0113fa0b 100644 --- a/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.html +++ b/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.html @@ -46,8 +46,13 @@ } @else { @if (isError$ | async) { -
- +
+
} @if (isInitializing$ | async) { diff --git a/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.spec.ts b/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.spec.ts index ad3352c1..1a869412 100644 --- a/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.spec.ts @@ -84,10 +84,7 @@ describe('ChannelListComponent', () => { it('should display error indicator, if error happened', () => { expect(queryChatdownContainer()).toBeNull(); - channelServiceMock.channelQueryState$.next({ - state: 'error', - error: new Error('error'), - }); + channelServiceMock.shouldRecoverState$.next(true); fixture.detectChanges(); expect(queryChatdownContainer()).not.toBeNull(); diff --git a/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.ts b/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.ts index 7bc38452..333c92e3 100644 --- a/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.ts +++ b/projects/stream-chat-angular/src/lib/channel-list/channel-list.component.ts @@ -33,9 +33,7 @@ export class ChannelListComponent implements OnDestroy { this.theme$ = this.themeService.theme$; this.channels$ = this.channelService.channels$; this.hasMoreChannels$ = this.channelService.hasMoreChannels$; - this.isError$ = this.channelService.channelQueryState$.pipe( - map((s) => !this.isLoadingMoreChannels && s?.state === 'error'), - ); + this.isError$ = this.channelService.shouldRecoverState$; this.isInitializing$ = this.channelService.channelQueryState$.pipe( map((s) => !this.isLoadingMoreChannels && s?.state === 'in-progress'), ); @@ -56,6 +54,10 @@ export class ChannelListComponent implements OnDestroy { this.isLoadingMoreChannels = false; } + recoverState() { + void this.channelService.recoverState(); + } + trackByChannelId(_: number, item: Channel) { return item.cid; } diff --git a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts index 9cb33bb6..10aa0271 100644 --- a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts +++ b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; import { Channel, Event, FormatMessageResponse } from 'stream-chat'; @@ -41,7 +41,6 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { constructor( private channelService: ChannelService, - private ngZone: NgZone, private chatClientService: ChatClientService, messageService: MessageService, public customTemplatesService: CustomTemplatesService, diff --git a/projects/stream-chat-angular/src/lib/channel.service.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.spec.ts index 60441c79..aafb22b7 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -1841,6 +1841,9 @@ describe('ChannelService', () => { }); it('should reset state after connection recovered', async () => { + const spy = jasmine.createSpy(); + service.shouldRecoverState$.subscribe(spy); + spy.calls.reset(); await init(); mockChatClient.queryChannels.calls.reset(); events$.next({ eventType: 'connection.recovered' } as ClientEvent); @@ -1851,6 +1854,8 @@ describe('ChannelService', () => { jasmine.any(Object), jasmine.any(Object), ); + + expect(spy).not.toHaveBeenCalled(); }); it(`shouldn't do duplicate state reset after connection recovered`, async () => { @@ -2414,4 +2419,52 @@ describe('ChannelService', () => { expect(activeChannel.markRead).toHaveBeenCalledTimes(2); }); + + it('should signal if state recovery is needed - initial load', async () => { + const spy = jasmine.createSpy(); + service.shouldRecoverState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(false); + spy.calls.reset(); + const error = 'there was an error'; + + await expectAsync( + init(undefined, undefined, undefined, () => + mockChatClient.queryChannels.and.rejectWith(error), + ), + ).toBeRejectedWith(error); + + expect(spy).toHaveBeenCalledWith(true); + + spy.calls.reset(); + mockChatClient.queryChannels.and.resolveTo([]); + await service.recoverState(); + + expect(spy).toHaveBeenCalledWith(false); + }); + + it('should signal if state recovery is needed - failed state recover after connection.recovered', fakeAsync(() => { + void init(); + tick(); + const spy = jasmine.createSpy(); + service.shouldRecoverState$.subscribe(spy); + spy.calls.reset(); + mockChatClient.queryChannels.and.rejectWith( + new Error('there was an error'), + ); + events$.next({ eventType: 'connection.recovered' } as ClientEvent); + + tick(); + flush(); + + expect(spy).toHaveBeenCalledWith(true); + + spy.calls.reset(); + mockChatClient.queryChannels.and.resolveTo([]); + void service.recoverState(); + tick(); + flush(); + + expect(spy).toHaveBeenCalledWith(false); + })); }); diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index ec3a011d..19ec8328 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -6,7 +6,14 @@ import { ReplaySubject, Subscription, } from 'rxjs'; -import { filter, first, map, shareReplay, take } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + first, + map, + shareReplay, + take, +} from 'rxjs/operators'; import { Attachment, Channel, @@ -65,6 +72,12 @@ export class ChannelService< * The result of the latest channel query request. */ channelQueryState$: Observable; + /** + * Emits `true` when the state needs to be recovered after an error + * + * You can recover it by calling the `recoverState` method + */ + shouldRecoverState$: Observable; /** * Emits the currently active channel. * @@ -224,7 +237,7 @@ export class ChannelService< private _shouldMarkActiveChannelAsRead = true; private shouldSetActiveChannel = true; private clientEventsSubscription: Subscription | undefined; - private isStateRecoveryInProgress = false; + private isStateRecoveryInProgress$ = new BehaviorSubject(false); private channelQueryStateSubject = new BehaviorSubject< ChannelQueryState | undefined >(undefined); @@ -323,6 +336,20 @@ export class ChannelService< this.channelQueryState$ = this.channelQueryStateSubject .asObservable() .pipe(shareReplay(1)); + this.shouldRecoverState$ = combineLatest([ + this.channels$, + this.channelQueryState$, + this.isStateRecoveryInProgress$, + ]).pipe( + map(([channels, queryState, isStateRecoveryInProgress]) => { + return ( + (!channels || channels.length === 0) && + queryState?.state === 'error' && + !isStateRecoveryInProgress + ); + }), + distinctUntilChanged(), + ); } /** @@ -583,6 +610,7 @@ export class ChannelService< this.dismissErrorNotification = undefined; this.channelQueryConfig = undefined; this.destroyChannelManager(); + this.isStateRecoveryInProgress$.next(false); } /** @@ -1120,36 +1148,52 @@ export class ChannelService< } } - private async handleNotification(clientEvent: ClientEvent) { + /** + * Reloads all channels and messages. Useful if state is empty due to an error. + * + * The SDK will automatically call this after `connection.recovered` event. In other cases it's up to integrators to recover state. + * + * Use the `shouldRecoverState$` to know if state recover is necessary. + * @returns when recovery is completed + */ + async recoverState() { + if (this.isStateRecoveryInProgress$.getValue()) { + return; + } + this.isStateRecoveryInProgress$.next(true); + try { + await this.queryChannels('recover-state'); + if (this.activeChannelSubject.getValue()) { + // Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages + void this.setAsActiveParentMessage(undefined); + // Update and reselect message to quote + const messageToQuote = this.messageToQuoteSubject.getValue(); + this.setChannelState(this.activeChannelSubject.getValue()!); + let messages!: StreamMessage[]; + this.activeChannelMessages$ + .pipe(take(1)) + .subscribe((m) => (messages = m)); + const updatedMessageToQuote = messages.find( + (m) => m.id === messageToQuote?.id, + ); + if (updatedMessageToQuote) { + this.selectMessageToQuote(updatedMessageToQuote); + } + } + } finally { + this.isStateRecoveryInProgress$.next(false); + } + } + + private handleNotification(clientEvent: ClientEvent) { switch (clientEvent.eventType) { case 'connection.recovered': { - if (this.isStateRecoveryInProgress) { - return; - } - this.isStateRecoveryInProgress = true; - try { - await this.queryChannels('recover-state'); - if (this.activeChannelSubject.getValue()) { - // Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages - void this.setAsActiveParentMessage(undefined); - // Update and reselect message to quote - const messageToQuote = this.messageToQuoteSubject.getValue(); - this.setChannelState(this.activeChannelSubject.getValue()!); - let messages!: StreamMessage[]; - this.activeChannelMessages$ - .pipe(take(1)) - .subscribe((m) => (messages = m)); - const updatedMessageToQuote = messages.find( - (m) => m.id === messageToQuote?.id, - ); - if (updatedMessageToQuote) { - this.selectMessageToQuote(updatedMessageToQuote); - } - } - this.isStateRecoveryInProgress = false; - } catch { - this.isStateRecoveryInProgress = false; - } + void this.recoverState().catch((error) => + this.chatClientService.chatClient.logger( + 'warn', + `Failed to recover state after connection recovery: ${error}`, + ), + ); break; } case 'user.updated': { @@ -1511,12 +1555,6 @@ export class ChannelService< try { this.channelQueryStateSubject.next({ state: 'in-progress' }); - const previousActiveChannel = this.activeChannelSubject.getValue(); - if (queryType === 'recover-state') { - this.activeChannelSubject.next(undefined); - this.channelManager.setChannels([]); - } - if (this.customChannelQuery) { const result = await this.customChannelQuery(queryType); const currentChannels = this.channels; @@ -1548,6 +1586,7 @@ export class ChannelService< ({ channels }) => { const activeChannel = this.activeChannel; if ( + !this.isStateRecoveryInProgress$.getValue() && activeChannel && !channels.find((c) => c.cid === activeChannel.cid) ) { @@ -1567,37 +1606,21 @@ export class ChannelService< } } + if (queryType === 'recover-state') { + await this.maybeRestoreActiveChannelAfterRecovery(); + } + + const activeChannel = this.activeChannelSubject.getValue(); const shouldSetActiveChannel = queryType === 'next-page' ? false : this.shouldSetActiveChannel; - if (previousActiveChannel && queryType === 'recover-state') { - try { - if ( - !this.channels.find((c) => c.cid === previousActiveChannel?.cid) - ) { - await previousActiveChannel.watch(); - this.channelManager.setChannels( - promoteChannel({ - channels: this.channels, - channelToMove: previousActiveChannel, - sort: this.channelQueryConfig?.sort ?? [], - }), - ); - } - this.setAsActiveChannel(previousActiveChannel); - } catch (error) { - this.chatClientService.chatClient.logger( - 'warn', - 'Unable to refetch active channel after state recover', - error as Record, - ); - } - } else if ( + if ( this.channels.length > 0 && - !previousActiveChannel && + !activeChannel && shouldSetActiveChannel ) { this.setAsActiveChannel(this.channels[0]); } + this.channelQueryStateSubject.next({ state: 'success' }); this.dismissErrorNotification?.(); return this.channels; @@ -1608,8 +1631,16 @@ export class ChannelService< error, }); if (queryType === 'recover-state') { + this.deselectActiveChannel(); this.channelManager.setChannels([]); } + if (queryType !== 'next-page') { + this.dismissErrorNotification = + this.notificationService.addPermanentNotification( + 'streamChat.Error loading channels', + 'error', + ); + } throw error; } } @@ -1818,7 +1849,7 @@ export class ChannelService< this.markReadTimeout = undefined; } - private async _init( + private _init( options: ChannelServiceOptions & { messagePageSize: number }, ) { this.messagePageSize = options.messagePageSize; @@ -1838,17 +1869,7 @@ export class ChannelService< this.clientEventsSubscription = this.chatClientService.events$.subscribe( (notification) => void this.handleNotification(notification), ); - try { - const result = await this.queryChannels('first-page'); - return result; - } catch (error) { - this.dismissErrorNotification = - this.notificationService.addPermanentNotification( - 'streamChat.Error loading channels', - 'error', - ); - throw error; - } + return this.queryChannels('first-page'); } private createChannelManager({ @@ -1883,4 +1904,45 @@ export class ChannelService< this.channelsSubject.next(undefined); this.hasMoreChannelsSubject.next(true); } + + private async maybeRestoreActiveChannelAfterRecovery() { + const previousActiveChannel = this.activeChannelSubject.getValue(); + if (!previousActiveChannel) { + return; + } + try { + if (!this.channels.find((c) => c.cid === previousActiveChannel?.cid)) { + await previousActiveChannel.watch(); + // Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages + void this.setAsActiveParentMessage(undefined); + // Update and reselect message to quote + const messageToQuote = this.messageToQuoteSubject.getValue(); + this.setChannelState(previousActiveChannel); + let messages!: StreamMessage[]; + this.activeChannelMessages$ + .pipe(take(1)) + .subscribe((m) => (messages = m)); + const updatedMessageToQuote = messages.find( + (m) => m.id === messageToQuote?.id, + ); + if (updatedMessageToQuote) { + this.selectMessageToQuote(updatedMessageToQuote); + } + this.channelManager?.setChannels( + promoteChannel({ + channels: this.channels, + channelToMove: previousActiveChannel, + sort: this.channelQueryConfig?.sort ?? [], + }), + ); + } + } catch (error) { + this.chatClientService.chatClient.logger( + 'warn', + 'Unable to refetch active channel after state recover', + error as Record, + ); + this.deselectActiveChannel(); + } + } } diff --git a/projects/stream-chat-angular/src/lib/mocks/index.ts b/projects/stream-chat-angular/src/lib/mocks/index.ts index 50aaff0b..2c2aa50f 100644 --- a/projects/stream-chat-angular/src/lib/mocks/index.ts +++ b/projects/stream-chat-angular/src/lib/mocks/index.ts @@ -236,6 +236,7 @@ export type MockChannelService = { usersTypingInThread$: BehaviorSubject; jumpToMessage$: BehaviorSubject<{ id?: string; parentId?: string }>; channelQueryState$: BehaviorSubject; + shouldRecoverState$: BehaviorSubject; activeChannelLastReadMessageId?: string; activeChannelUnreadCount?: number; activeChannel?: Channel; @@ -319,6 +320,7 @@ export const mockChannelService = (): MockChannelService => { const channelQueryState$ = new BehaviorSubject( undefined, ); + const shouldRecoverState$ = new BehaviorSubject(false); return { activeChannelMessages$, @@ -343,6 +345,7 @@ export const mockChannelService = (): MockChannelService => { clearMessageJump, channelQueryState$, activeChannel, + shouldRecoverState$, }; };