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/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-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 3a651ad9..aafb22b7 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);
});
@@ -248,37 +269,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);
@@ -412,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];
@@ -430,6 +420,7 @@ describe('ChannelService', () => {
jasmine.any(Object),
jasmine.any(Object),
jasmine.any(Object),
+ jasmine.any(Object),
);
expect(service.channels.length).toEqual(prevChannelCount + 1);
@@ -700,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);
+ mockChatClient.dispatchEvent(event);
- const firtChannel = (spy.calls.mostRecent().args[0] as Channel[])[0];
-
- expect(firtChannel).toBe(channel);
- });
-
- 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 () => {
@@ -741,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[];
@@ -753,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[];
@@ -763,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[];
@@ -821,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 () => {
+ 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 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 () => {
- 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 () => {
@@ -983,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();
@@ -1005,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 () => {
@@ -1030,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();
@@ -1044,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 () => {
@@ -1056,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 () => {
@@ -1085,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 () => {
@@ -2136,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);
@@ -2144,7 +1852,10 @@ describe('ChannelService', () => {
jasmine.any(Object),
jasmine.any(Object),
jasmine.any(Object),
+ jasmine.any(Object),
);
+
+ expect(spy).not.toHaveBeenCalled();
});
it(`shouldn't do duplicate state reset after connection recovered`, async () => {
@@ -2171,6 +1882,7 @@ describe('ChannelService', () => {
watch: true,
message_limit: 25,
},
+ jasmine.any(Object),
);
});
@@ -2291,19 +2003,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);
@@ -2311,34 +2019,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);
@@ -2351,8 +2054,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(),
@@ -2367,7 +2071,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] = {
@@ -2388,8 +2093,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 = [];
@@ -2610,38 +2316,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
@@ -2654,7 +2328,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);
@@ -2745,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.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 a47f5f04..19ec8328 100644
--- a/projects/stream-chat-angular/src/lib/channel.service.ts
+++ b/projects/stream-chat-angular/src/lib/channel.service.ts
@@ -6,21 +6,28 @@ 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,
- ChannelFilters,
- ChannelOptions,
- ChannelResponse,
- ChannelSort,
+ ChannelManager,
+ ChannelManagerEventHandlerOverrides,
+ ChannelManagerOptions,
Event,
- EventTypes,
FormatMessageResponse,
MemberFilters,
Message,
MessageResponse,
+ promoteChannel,
ReactionResponse,
+ Unsubscribe,
UpdatedMessage,
UserResponse,
} from 'stream-chat';
@@ -32,16 +39,17 @@ import { getReadBy } from './read-by';
import {
AttachmentUpload,
AttachmentUploadErrorReason,
+ ChannelQueryConfig,
+ ChannelQueryConfigInput,
ChannelQueryResult,
ChannelQueryState,
ChannelQueryType,
+ ChannelServiceOptions,
DefaultStreamChatGenerics,
MessageInput,
MessageReactionType,
- NextPageConfiguration,
StreamMessage,
} from './types';
-import { ChannelQuery } from './channel-query';
/**
* The `ChannelService` provides data and interaction for the channel list and message list.
@@ -64,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.
*
@@ -128,138 +142,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
*/
@@ -311,6 +193,9 @@ export class ChannelService<
* @internal
*/
isMessageLoadingInProgress = false;
+ /**
+ * @internal
+ */
messagePageSize = 25;
private channelsSubject = new BehaviorSubject[] | undefined>(
undefined,
@@ -326,7 +211,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);
@@ -351,82 +235,23 @@ export class ChannelService<
[],
);
private _shouldMarkActiveChannelAsRead = true;
- private shouldSetActiveChannel: boolean | undefined;
+ private shouldSetActiveChannel = true;
private clientEventsSubscription: Subscription | undefined;
- private isStateRecoveryInProgress = false;
+ private isStateRecoveryInProgress$ = new BehaviorSubject(false);
private channelQueryStateSubject = new BehaviorSubject<
ChannelQueryState | undefined
>(undefined);
- private channelQuery?:
- | ChannelQuery
- | ((queryType: ChannelQueryType) => Promise>);
- private _customPaginator:
- | ((channelQueryResult: Channel[]) => NextPageConfiguration)
- | undefined;
-
- 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,
@@ -511,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(),
+ );
}
/**
@@ -533,24 +372,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.
@@ -719,56 +540,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,
},
- );
- this.channelQuery.customPaginator = this._customPaginator;
+ };
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);
}
@@ -777,22 +604,20 @@ 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();
+ this.isStateRecoveryInProgress$.next(false);
}
/**
* 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');
}
/**
@@ -1134,46 +959,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(
@@ -1338,87 +1148,55 @@ export class ChannelService<
}
}
- private async handleNotification(clientEvent: ClientEvent) {
- switch (clientEvent.eventType) {
- case 'connection.recovered': {
- if (this.isStateRecoveryInProgress) {
- return;
- }
- 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',
- );
- 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;
- }
- 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);
+ /**
+ * 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);
}
- break;
}
- case 'notification.removed_from_channel': {
- if (this.customRemovedFromChannelNotificationHandler) {
- this.customRemovedFromChannelNotificationHandler(
- clientEvent,
- this.channelListSetter,
- );
- } else {
- this.handleRemovedFromChannelNotification(clientEvent);
- }
+ } finally {
+ this.isStateRecoveryInProgress$.next(false);
+ }
+ }
+
+ private handleNotification(clientEvent: ClientEvent) {
+ switch (clientEvent.eventType) {
+ case 'connection.recovered': {
+ void this.recoverState().catch((error) =>
+ this.chatClientService.chatClient.logger(
+ 'warn',
+ `Failed to recover state after connection recovery: ${error}`,
+ ),
+ );
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(
@@ -1447,49 +1225,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) => {
@@ -1577,6 +1312,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);
+ }),
+ );
}
/**
@@ -1794,234 +1546,102 @@ 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);
+ 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();
}
- });
+ }
+
+ if (this.channelManagerSubscriptions.length === 0) {
+ this.channelManagerSubscriptions.push(
+ this.channelManager.state.subscribeWithSelector(
+ (s) => ({ channels: s.channels }),
+ ({ channels }) => {
+ const activeChannel = this.activeChannel;
+ if (
+ !this.isStateRecoveryInProgress$.getValue() &&
+ 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),
+ ),
+ );
+ }
+ }
- this.channelsSubject.next(filteredChannels);
- const currentActiveChannel = this.activeChannelSubject.getValue();
+ if (queryType === 'recover-state') {
+ await this.maybeRestoreActiveChannelAfterRecovery();
+ }
+
+ const activeChannel = this.activeChannelSubject.getValue();
+ const shouldSetActiveChannel =
+ queryType === 'next-page' ? false : this.shouldSetActiveChannel;
if (
- currentActiveChannel &&
- !filteredChannels.find((c) => c.cid === currentActiveChannel?.cid)
- ) {
- this.deselectActiveChannel();
- } else if (
- filteredChannels.length > 0 &&
- !currentActiveChannel &&
+ this.channels.length > 0 &&
+ !activeChannel &&
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);
+ if (queryType === 'recover-state') {
+ this.deselectActiveChannel();
+ this.channelManager.setChannels([]);
}
- }
- }
-
- 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 !== 'next-page') {
+ this.dismissErrorNotification =
+ this.notificationService.addPermanentNotification(
+ 'streamChat.Error loading channels',
+ 'error',
+ );
}
+ throw error;
}
}
@@ -2229,28 +1849,100 @@ export class ChannelService<
this.markReadTimeout = undefined;
}
- private async _init(settings: {
- shouldSetActiveChannel: boolean;
- messagePageSize: number;
- }) {
- this.shouldSetActiveChannel = settings.shouldSetActiveChannel;
- this.messagePageSize = settings.messagePageSize;
+ private _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),
);
+ return this.queryChannels('first-page');
+ }
+
+ 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);
+ }
+
+ private async maybeRestoreActiveChannelAfterRecovery() {
+ const previousActiveChannel = this.activeChannelSubject.getValue();
+ if (!previousActiveChannel) {
+ return;
+ }
try {
- const result = await this.queryChannels(
- this.shouldSetActiveChannel,
- 'first-page',
- );
- return result;
- } catch (error) {
- this.dismissErrorNotification =
- this.notificationService.addPermanentNotification(
- 'streamChat.Error loading channels',
- 'error',
+ 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,
);
- throw error;
+ 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 a6fe9ddd..2c2aa50f 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' } },
@@ -234,6 +236,7 @@ export type MockChannelService = {
usersTypingInThread$: BehaviorSubject;
jumpToMessage$: BehaviorSubject<{ id?: string; parentId?: string }>;
channelQueryState$: BehaviorSubject;
+ shouldRecoverState$: BehaviorSubject;
activeChannelLastReadMessageId?: string;
activeChannelUnreadCount?: number;
activeChannel?: Channel;
@@ -317,6 +320,7 @@ export const mockChannelService = (): MockChannelService => {
const channelQueryState$ = new BehaviorSubject(
undefined,
);
+ const shouldRecoverState$ = new BehaviorSubject(false);
return {
activeChannelMessages$,
@@ -341,6 +345,7 @@ export const mockChannelService = (): MockChannelService => {
clearMessageJump,
channelQueryState$,
activeChannel,
+ shouldRecoverState$,
};
};
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';