Skip to content

Commit 5e8c7dc

Browse files
committed
feat: recoverState method to allow SDK components to initiaite state recovery
1 parent fbc73bd commit 5e8c7dc

File tree

8 files changed

+154
-53
lines changed

8 files changed

+154
-53
lines changed

projects/stream-chat-angular/src/assets/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,6 @@ export const en = {
131131
'You currently have {{count}} attachments, the maximum is {{max}}':
132132
'You currently have {{count}} attachments, the maximum is {{max}}',
133133
'and others': 'and others',
134+
'Reload channels': 'Reload channels',
134135
},
135136
};

projects/stream-chat-angular/src/lib/channel-list/channel-list.component.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,13 @@
4646
</div>
4747
} @else {
4848
@if (isError$ | async) {
49-
<div data-testid="chatdown-container" class="str-chat__down">
50-
<ng-container *ngTemplateOutlet="loadingChannels" />
49+
<div
50+
data-testid="chatdown-container"
51+
class="str-chat__dow str-chat__channel-list-empty"
52+
>
53+
<button (click)="recoverState()" class="str-chat__cta-button">
54+
{{ "streamChat.Reload channels" | translate }}
55+
</button>
5156
</div>
5257
}
5358
@if (isInitializing$ | async) {

projects/stream-chat-angular/src/lib/channel-list/channel-list.component.spec.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,7 @@ describe('ChannelListComponent', () => {
8484
it('should display error indicator, if error happened', () => {
8585
expect(queryChatdownContainer()).toBeNull();
8686

87-
channelServiceMock.channelQueryState$.next({
88-
state: 'error',
89-
error: new Error('error'),
90-
});
87+
channelServiceMock.shouldRecoverState$.next(true);
9188
fixture.detectChanges();
9289

9390
expect(queryChatdownContainer()).not.toBeNull();

projects/stream-chat-angular/src/lib/channel-list/channel-list.component.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ export class ChannelListComponent implements OnDestroy {
3333
this.theme$ = this.themeService.theme$;
3434
this.channels$ = this.channelService.channels$;
3535
this.hasMoreChannels$ = this.channelService.hasMoreChannels$;
36-
this.isError$ = this.channelService.channelQueryState$.pipe(
37-
map((s) => !this.isLoadingMoreChannels && s?.state === 'error'),
38-
);
36+
this.isError$ = this.channelService.shouldRecoverState$;
3937
this.isInitializing$ = this.channelService.channelQueryState$.pipe(
4038
map((s) => !this.isLoadingMoreChannels && s?.state === 'in-progress'),
4139
);
@@ -56,6 +54,10 @@ export class ChannelListComponent implements OnDestroy {
5654
this.isLoadingMoreChannels = false;
5755
}
5856

57+
recoverState() {
58+
void this.channelService.recoverState();
59+
}
60+
5961
trackByChannelId(_: number, item: Channel<DefaultStreamChatGenerics>) {
6062
return item.cid;
6163
}

projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, Input, NgZone, OnDestroy, OnInit } from '@angular/core';
1+
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
22
import { Subscription } from 'rxjs';
33
import { filter } from 'rxjs/operators';
44
import { Channel, Event, FormatMessageResponse } from 'stream-chat';
@@ -41,7 +41,6 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy {
4141

4242
constructor(
4343
private channelService: ChannelService,
44-
private ngZone: NgZone,
4544
private chatClientService: ChatClientService,
4645
messageService: MessageService,
4746
public customTemplatesService: CustomTemplatesService,

projects/stream-chat-angular/src/lib/channel.service.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1841,6 +1841,9 @@ describe('ChannelService', () => {
18411841
});
18421842

18431843
it('should reset state after connection recovered', async () => {
1844+
const spy = jasmine.createSpy();
1845+
service.shouldRecoverState$.subscribe(spy);
1846+
spy.calls.reset();
18441847
await init();
18451848
mockChatClient.queryChannels.calls.reset();
18461849
events$.next({ eventType: 'connection.recovered' } as ClientEvent);
@@ -1851,6 +1854,8 @@ describe('ChannelService', () => {
18511854
jasmine.any(Object),
18521855
jasmine.any(Object),
18531856
);
1857+
1858+
expect(spy).not.toHaveBeenCalled();
18541859
});
18551860

18561861
it(`shouldn't do duplicate state reset after connection recovered`, async () => {
@@ -2414,4 +2419,52 @@ describe('ChannelService', () => {
24142419

24152420
expect(activeChannel.markRead).toHaveBeenCalledTimes(2);
24162421
});
2422+
2423+
it('should signal if state recovery is needed - initial load', async () => {
2424+
const spy = jasmine.createSpy();
2425+
service.shouldRecoverState$.subscribe(spy);
2426+
2427+
expect(spy).toHaveBeenCalledWith(false);
2428+
spy.calls.reset();
2429+
const error = 'there was an error';
2430+
2431+
await expectAsync(
2432+
init(undefined, undefined, undefined, () =>
2433+
mockChatClient.queryChannels.and.rejectWith(error),
2434+
),
2435+
).toBeRejectedWith(error);
2436+
2437+
expect(spy).toHaveBeenCalledWith(true);
2438+
2439+
spy.calls.reset();
2440+
mockChatClient.queryChannels.and.resolveTo([]);
2441+
await service.recoverState();
2442+
2443+
expect(spy).toHaveBeenCalledWith(false);
2444+
});
2445+
2446+
it('should signal if state recovery is needed - failed state recover after connection.recovered', fakeAsync(() => {
2447+
void init();
2448+
tick();
2449+
const spy = jasmine.createSpy();
2450+
service.shouldRecoverState$.subscribe(spy);
2451+
spy.calls.reset();
2452+
mockChatClient.queryChannels.and.rejectWith(
2453+
new Error('there was an error'),
2454+
);
2455+
events$.next({ eventType: 'connection.recovered' } as ClientEvent);
2456+
2457+
tick();
2458+
flush();
2459+
2460+
expect(spy).toHaveBeenCalledWith(true);
2461+
2462+
spy.calls.reset();
2463+
mockChatClient.queryChannels.and.resolveTo([]);
2464+
void service.recoverState();
2465+
tick();
2466+
flush();
2467+
2468+
expect(spy).toHaveBeenCalledWith(false);
2469+
}));
24172470
});

projects/stream-chat-angular/src/lib/channel.service.ts

Lines changed: 83 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import {
66
ReplaySubject,
77
Subscription,
88
} from 'rxjs';
9-
import { filter, first, map, shareReplay, take } from 'rxjs/operators';
9+
import {
10+
distinctUntilChanged,
11+
filter,
12+
first,
13+
map,
14+
shareReplay,
15+
take,
16+
} from 'rxjs/operators';
1017
import {
1118
Attachment,
1219
Channel,
@@ -65,6 +72,12 @@ export class ChannelService<
6572
* The result of the latest channel query request.
6673
*/
6774
channelQueryState$: Observable<ChannelQueryState | undefined>;
75+
/**
76+
* Emits `true` when the state needs to be recovered after an error
77+
*
78+
* You can recover it by calling the `recoverState` method
79+
*/
80+
shouldRecoverState$: Observable<boolean>;
6881
/**
6982
* Emits the currently active channel.
7083
*
@@ -224,7 +237,7 @@ export class ChannelService<
224237
private _shouldMarkActiveChannelAsRead = true;
225238
private shouldSetActiveChannel = true;
226239
private clientEventsSubscription: Subscription | undefined;
227-
private isStateRecoveryInProgress = false;
240+
private isStateRecoveryInProgress$ = new BehaviorSubject(false);
228241
private channelQueryStateSubject = new BehaviorSubject<
229242
ChannelQueryState | undefined
230243
>(undefined);
@@ -323,6 +336,20 @@ export class ChannelService<
323336
this.channelQueryState$ = this.channelQueryStateSubject
324337
.asObservable()
325338
.pipe(shareReplay(1));
339+
this.shouldRecoverState$ = combineLatest([
340+
this.channels$,
341+
this.channelQueryState$,
342+
this.isStateRecoveryInProgress$,
343+
]).pipe(
344+
map(([channels, queryState, isStateRecoveryInProgress]) => {
345+
return (
346+
(!channels || channels.length === 0) &&
347+
queryState?.state === 'error' &&
348+
!isStateRecoveryInProgress
349+
);
350+
}),
351+
distinctUntilChanged(),
352+
);
326353
}
327354

328355
/**
@@ -583,6 +610,7 @@ export class ChannelService<
583610
this.dismissErrorNotification = undefined;
584611
this.channelQueryConfig = undefined;
585612
this.destroyChannelManager();
613+
this.isStateRecoveryInProgress$.next(false);
586614
}
587615

588616
/**
@@ -1120,36 +1148,52 @@ export class ChannelService<
11201148
}
11211149
}
11221150

1123-
private async handleNotification(clientEvent: ClientEvent<T>) {
1151+
/**
1152+
* Reloads all channels and messages. Useful if state is empty due to an error.
1153+
*
1154+
* The SDK will automatically call this after `connection.recovered` event. In other cases it's up to integrators to recover state.
1155+
*
1156+
* Use the `shouldRecoverState$` to know if state recover is necessary.
1157+
* @returns when recovery is completed
1158+
*/
1159+
async recoverState() {
1160+
if (this.isStateRecoveryInProgress$.getValue()) {
1161+
return;
1162+
}
1163+
this.isStateRecoveryInProgress$.next(true);
1164+
try {
1165+
await this.queryChannels('recover-state');
1166+
if (this.activeChannelSubject.getValue()) {
1167+
// Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages
1168+
void this.setAsActiveParentMessage(undefined);
1169+
// Update and reselect message to quote
1170+
const messageToQuote = this.messageToQuoteSubject.getValue();
1171+
this.setChannelState(this.activeChannelSubject.getValue()!);
1172+
let messages!: StreamMessage<T>[];
1173+
this.activeChannelMessages$
1174+
.pipe(take(1))
1175+
.subscribe((m) => (messages = m));
1176+
const updatedMessageToQuote = messages.find(
1177+
(m) => m.id === messageToQuote?.id,
1178+
);
1179+
if (updatedMessageToQuote) {
1180+
this.selectMessageToQuote(updatedMessageToQuote);
1181+
}
1182+
}
1183+
} finally {
1184+
this.isStateRecoveryInProgress$.next(false);
1185+
}
1186+
}
1187+
1188+
private handleNotification(clientEvent: ClientEvent<T>) {
11241189
switch (clientEvent.eventType) {
11251190
case 'connection.recovered': {
1126-
if (this.isStateRecoveryInProgress) {
1127-
return;
1128-
}
1129-
this.isStateRecoveryInProgress = true;
1130-
try {
1131-
await this.queryChannels('recover-state');
1132-
if (this.activeChannelSubject.getValue()) {
1133-
// Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages
1134-
void this.setAsActiveParentMessage(undefined);
1135-
// Update and reselect message to quote
1136-
const messageToQuote = this.messageToQuoteSubject.getValue();
1137-
this.setChannelState(this.activeChannelSubject.getValue()!);
1138-
let messages!: StreamMessage<T>[];
1139-
this.activeChannelMessages$
1140-
.pipe(take(1))
1141-
.subscribe((m) => (messages = m));
1142-
const updatedMessageToQuote = messages.find(
1143-
(m) => m.id === messageToQuote?.id,
1144-
);
1145-
if (updatedMessageToQuote) {
1146-
this.selectMessageToQuote(updatedMessageToQuote);
1147-
}
1148-
}
1149-
this.isStateRecoveryInProgress = false;
1150-
} catch {
1151-
this.isStateRecoveryInProgress = false;
1152-
}
1191+
void this.recoverState().catch((error) =>
1192+
this.chatClientService.chatClient.logger(
1193+
'warn',
1194+
`Failed to recover state after connection recovery: ${error}`,
1195+
),
1196+
);
11531197
break;
11541198
}
11551199
case 'user.updated': {
@@ -1610,6 +1654,13 @@ export class ChannelService<
16101654
if (queryType === 'recover-state') {
16111655
this.channelManager.setChannels([]);
16121656
}
1657+
if (queryType !== 'next-page') {
1658+
this.dismissErrorNotification =
1659+
this.notificationService.addPermanentNotification(
1660+
'streamChat.Error loading channels',
1661+
'error',
1662+
);
1663+
}
16131664
throw error;
16141665
}
16151666
}
@@ -1818,7 +1869,7 @@ export class ChannelService<
18181869
this.markReadTimeout = undefined;
18191870
}
18201871

1821-
private async _init(
1872+
private _init(
18221873
options: ChannelServiceOptions<T> & { messagePageSize: number },
18231874
) {
18241875
this.messagePageSize = options.messagePageSize;
@@ -1838,17 +1889,7 @@ export class ChannelService<
18381889
this.clientEventsSubscription = this.chatClientService.events$.subscribe(
18391890
(notification) => void this.handleNotification(notification),
18401891
);
1841-
try {
1842-
const result = await this.queryChannels('first-page');
1843-
return result;
1844-
} catch (error) {
1845-
this.dismissErrorNotification =
1846-
this.notificationService.addPermanentNotification(
1847-
'streamChat.Error loading channels',
1848-
'error',
1849-
);
1850-
throw error;
1851-
}
1892+
return this.queryChannels('first-page');
18521893
}
18531894

18541895
private createChannelManager({

projects/stream-chat-angular/src/lib/mocks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export type MockChannelService = {
236236
usersTypingInThread$: BehaviorSubject<UserResponse[]>;
237237
jumpToMessage$: BehaviorSubject<{ id?: string; parentId?: string }>;
238238
channelQueryState$: BehaviorSubject<ChannelQueryState | undefined>;
239+
shouldRecoverState$: BehaviorSubject<boolean>;
239240
activeChannelLastReadMessageId?: string;
240241
activeChannelUnreadCount?: number;
241242
activeChannel?: Channel<DefaultStreamChatGenerics>;
@@ -319,6 +320,7 @@ export const mockChannelService = (): MockChannelService => {
319320
const channelQueryState$ = new BehaviorSubject<ChannelQueryState | undefined>(
320321
undefined,
321322
);
323+
const shouldRecoverState$ = new BehaviorSubject(false);
322324

323325
return {
324326
activeChannelMessages$,
@@ -343,6 +345,7 @@ export const mockChannelService = (): MockChannelService => {
343345
clearMessageJump,
344346
channelQueryState$,
345347
activeChannel,
348+
shouldRecoverState$,
346349
};
347350
};
348351

0 commit comments

Comments
 (0)