Skip to content

Commit 1717c2c

Browse files
authored
Merge pull request #406 from GetStream/online-indicator
feat: Add online indicator for 1:1 channel avatars
2 parents 1e9ecdc + 3acfbda commit 1717c2c

File tree

5 files changed

+232
-14
lines changed

5 files changed

+232
-14
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
"@ngx-translate/core": "^13.0.0",
118118
"@ngx-translate/http-loader": "^6.0.0",
119119
"@popperjs/core": "^2.11.5",
120-
"@stream-io/stream-chat-css": "3.7.0",
120+
"@stream-io/stream-chat-css": "3.8.0",
121121
"@stream-io/transliterate": "^1.5.2",
122122
"angular-mentions": "^1.4.0",
123123
"dayjs": "^1.10.7",

projects/stream-chat-angular/src/lib/avatar/avatar.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,9 @@
3333
{{ initials }}
3434
</div>
3535
</ng-template>
36+
<div
37+
data-testid="online-indicator"
38+
*ngIf="isOnline"
39+
class="str-chat__avatar--online-indicator"
40+
></div>
3641
</div>

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

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { SimpleChange } from '@angular/core';
12
import { ComponentFixture, TestBed } from '@angular/core/testing';
2-
import { ChatClientService } from '../chat-client.service';
3+
import { Subject } from 'rxjs';
4+
import { ChatClientService, ClientEvent } from '../chat-client.service';
35
import { generateMockChannels } from '../mocks';
46
import { AvatarComponent } from './avatar.component';
57

@@ -10,10 +12,21 @@ describe('AvatarComponent', () => {
1012
const imageUrl = 'https://picsum.photos/200/300';
1113
let queryImg: () => HTMLImageElement | null;
1214
let queryFallbackImg: () => HTMLImageElement | null;
13-
let chatClientServiceMock: { chatClient: { user: { id: string } } };
15+
let queryOnlineIndicator: () => HTMLElement | null;
16+
let queryUsersMock: jasmine.Spy;
17+
let events$: Subject<ClientEvent>;
18+
let chatClientServiceMock: {
19+
chatClient: { user: { id: string }; queryUsers: jasmine.Spy };
20+
events$: Subject<ClientEvent>;
21+
};
1422

1523
beforeEach(() => {
16-
chatClientServiceMock = { chatClient: { user: { id: 'current-user' } } };
24+
queryUsersMock = jasmine.createSpy();
25+
events$ = new Subject();
26+
chatClientServiceMock = {
27+
chatClient: { user: { id: 'current-user' }, queryUsers: queryUsersMock },
28+
events$,
29+
};
1730
TestBed.configureTestingModule({
1831
declarations: [AvatarComponent],
1932
providers: [
@@ -26,6 +39,8 @@ describe('AvatarComponent', () => {
2639
queryFallbackImg = () =>
2740
nativeElement.querySelector('[data-testid=fallback-img]');
2841
queryImg = () => nativeElement.querySelector('[data-testid=avatar-img]');
42+
queryOnlineIndicator = () =>
43+
nativeElement.querySelector('[data-testid=online-indicator]');
2944
});
3045

3146
const waitForImgComplete = () => {
@@ -208,4 +223,150 @@ describe('AvatarComponent', () => {
208223

209224
expect(queryImg()).toBeNull();
210225
});
226+
227+
it('should display online indicator in 1:1 channels', async () => {
228+
const channel = generateMockChannels()[0];
229+
channel.state.members = {
230+
otheruser: {
231+
user_id: 'otheruser',
232+
user: { id: 'otheruser', name: 'Jack', image: 'url/to/img' },
233+
},
234+
[chatClientServiceMock.chatClient.user.id]: {
235+
user_id: chatClientServiceMock.chatClient.user.id,
236+
user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' },
237+
},
238+
};
239+
queryUsersMock.and.resolveTo({ users: [{ online: true }] });
240+
component.channel = channel;
241+
void component.ngOnChanges({ channel: {} as SimpleChange });
242+
await fixture.whenStable();
243+
fixture.detectChanges();
244+
245+
expect(queryOnlineIndicator()).not.toBeNull();
246+
});
247+
248+
it('should only display online indicator if user is online', async () => {
249+
const channel = generateMockChannels()[0];
250+
channel.state.members = {
251+
otheruser: {
252+
user_id: 'otheruser',
253+
user: { id: 'otheruser', name: 'Jack', image: 'url/to/img' },
254+
},
255+
[chatClientServiceMock.chatClient.user.id]: {
256+
user_id: chatClientServiceMock.chatClient.user.id,
257+
user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' },
258+
},
259+
};
260+
queryUsersMock.and.resolveTo({ users: [{ online: false }] });
261+
component.channel = channel;
262+
void component.ngOnChanges({ channel: {} as SimpleChange });
263+
await fixture.whenStable();
264+
fixture.detectChanges();
265+
266+
expect(queryOnlineIndicator()).toBeNull();
267+
});
268+
269+
it(`should update online indicator if user's presence changed`, async () => {
270+
const channel = generateMockChannels()[0];
271+
channel.state.members = {
272+
otheruser: {
273+
user_id: 'otheruser',
274+
user: { id: 'otheruser', name: 'Jack', image: 'url/to/img' },
275+
},
276+
[chatClientServiceMock.chatClient.user.id]: {
277+
user_id: chatClientServiceMock.chatClient.user.id,
278+
user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' },
279+
},
280+
};
281+
queryUsersMock.and.resolveTo({ users: [{ online: false }] });
282+
component.channel = channel;
283+
void component.ngOnChanges({ channel: {} as SimpleChange });
284+
await fixture.whenStable();
285+
fixture.detectChanges();
286+
287+
expect(queryOnlineIndicator()).toBeNull();
288+
289+
events$.next({
290+
eventType: 'user.presence.changed',
291+
event: {
292+
type: 'user.presence.changed',
293+
user: { id: 'otheruser', online: true },
294+
},
295+
});
296+
fixture.detectChanges();
297+
298+
expect(queryOnlineIndicator()).not.toBeNull();
299+
});
300+
301+
it(`should handle query users error when displaying the online indicator`, async () => {
302+
const channel = generateMockChannels()[0];
303+
channel.state.members = {
304+
otheruser: {
305+
user_id: 'otheruser',
306+
user: {
307+
id: 'otheruser',
308+
name: 'Jack',
309+
image: 'url/to/img',
310+
online: true,
311+
},
312+
},
313+
[chatClientServiceMock.chatClient.user.id]: {
314+
user_id: chatClientServiceMock.chatClient.user.id,
315+
user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' },
316+
},
317+
};
318+
queryUsersMock.and.rejectWith(new Error('Permission denied'));
319+
component.channel = channel;
320+
void component.ngOnChanges({ channel: {} as SimpleChange });
321+
await fixture.whenStable();
322+
fixture.detectChanges();
323+
324+
expect(queryOnlineIndicator()).not.toBeNull();
325+
});
326+
327+
it(`shouldn't display online indicator in not 1:1 channels`, async () => {
328+
const channel = generateMockChannels()[0];
329+
channel.state.members = {
330+
otheruser: {
331+
user_id: 'otheruser',
332+
user: { id: 'otheruser', name: 'Jack', image: 'url/to/img' },
333+
},
334+
thirduser: {
335+
user_id: 'thirduser',
336+
user: { id: 'thirduser', name: 'John', image: 'url/to/img' },
337+
},
338+
[chatClientServiceMock.chatClient.user.id]: {
339+
user_id: chatClientServiceMock.chatClient.user.id,
340+
user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' },
341+
},
342+
};
343+
queryUsersMock.and.resolveTo({ users: [{ online: true }] });
344+
component.channel = channel;
345+
void component.ngOnChanges({ channel: {} as SimpleChange });
346+
await fixture.whenStable();
347+
fixture.detectChanges();
348+
349+
expect(queryOnlineIndicator()).toBeNull();
350+
});
351+
352+
it(`shouldn't display online indicator if #showOnlineIndicator is false`, async () => {
353+
const channel = generateMockChannels()[0];
354+
channel.state.members = {
355+
otheruser: {
356+
user_id: 'otheruser',
357+
user: { id: 'otheruser', name: 'Jack', image: 'url/to/img' },
358+
},
359+
[chatClientServiceMock.chatClient.user.id]: {
360+
user_id: chatClientServiceMock.chatClient.user.id,
361+
user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' },
362+
},
363+
};
364+
queryUsersMock.and.resolveTo({ users: [{ online: true }] });
365+
component.channel = channel;
366+
void component.ngOnChanges({ channel: {} as SimpleChange });
367+
await fixture.whenStable();
368+
fixture.detectChanges();
369+
370+
expect(queryOnlineIndicator()).not.toBeNull();
371+
});
211372
});

projects/stream-chat-angular/src/lib/avatar/avatar.component.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Component, Input } from '@angular/core';
1+
import {
2+
Component,
3+
Input,
4+
NgZone,
5+
OnChanges,
6+
SimpleChanges,
7+
} from '@angular/core';
8+
import { Subscription } from 'rxjs';
9+
import { filter } from 'rxjs/operators';
210
import { Channel, User } from 'stream-chat';
311
import { ChatClientService } from '../chat-client.service';
412
import {
@@ -15,7 +23,7 @@ import {
1523
templateUrl: './avatar.component.html',
1624
styleUrls: ['./avatar.component.scss'],
1725
})
18-
export class AvatarComponent {
26+
export class AvatarComponent implements OnChanges {
1927
/**
2028
* An optional name of the image, used for fallback image or image title (if `imageUrl` is provided)
2129
*/
@@ -44,10 +52,54 @@ export class AvatarComponent {
4452
* The type of the avatar: channel if channel avatar is displayed, user if user avatar is displayed
4553
*/
4654
@Input() type: AvatarType | undefined;
55+
/**
56+
* If a channel avatar is displayed, and if the channel has exactly two members a green dot is displayed if the other member is online. Set this flag to `false` to turn off this behavior.
57+
*/
58+
@Input() showOnlineIndicator = true;
4759
isLoaded = false;
4860
isError = false;
61+
isOnline = false;
62+
private isOnlineSubscription?: Subscription;
4963

50-
constructor(private chatClientService: ChatClientService) {}
64+
constructor(
65+
private chatClientService: ChatClientService,
66+
private ngZone: NgZone
67+
) {}
68+
69+
async ngOnChanges(changes: SimpleChanges) {
70+
if (changes['channel']) {
71+
if (this.channel) {
72+
const otherMember = this.getOtherMemberIfOneToOneChannel();
73+
if (otherMember) {
74+
this.isOnlineSubscription = this.chatClientService.events$
75+
.pipe(filter((e) => e.eventType === 'user.presence.changed'))
76+
.subscribe((event) => {
77+
if (event.event.user?.id === otherMember.id) {
78+
this.ngZone.run(() => {
79+
this.isOnline = event.event.user?.online || false;
80+
});
81+
}
82+
});
83+
try {
84+
const response = await this.chatClientService.chatClient.queryUsers(
85+
{
86+
id: { $eq: otherMember.id },
87+
}
88+
);
89+
this.isOnline = response.users[0]?.online || false;
90+
} catch (error) {
91+
// Fallback if we can't query user -> for example due to permission problems
92+
this.isOnline = otherMember.online || false;
93+
}
94+
} else {
95+
this.isOnlineSubscription?.unsubscribe();
96+
}
97+
} else {
98+
this.isOnline = false;
99+
this.isOnlineSubscription?.unsubscribe();
100+
}
101+
}
102+
}
51103

52104
get initials() {
53105
let result: string = '';

0 commit comments

Comments
 (0)