diff --git a/docusaurus/docs/Angular/assets/channel-invites-screenshot.png b/docusaurus/docs/Angular/assets/channel-invites-screenshot.png new file mode 100644 index 00000000..c87cfd6c Binary files /dev/null and b/docusaurus/docs/Angular/assets/channel-invites-screenshot.png differ diff --git a/docusaurus/docs/Angular/assets/invite-button-screenshot.png b/docusaurus/docs/Angular/assets/invite-button-screenshot.png new file mode 100644 index 00000000..20fefa63 Binary files /dev/null and b/docusaurus/docs/Angular/assets/invite-button-screenshot.png differ diff --git a/docusaurus/docs/Angular/assets/invite-modal1-screenshot.png b/docusaurus/docs/Angular/assets/invite-modal1-screenshot.png new file mode 100644 index 00000000..aa85ba66 Binary files /dev/null and b/docusaurus/docs/Angular/assets/invite-modal1-screenshot.png differ diff --git a/docusaurus/docs/Angular/assets/invite-modal2-screenshot.png b/docusaurus/docs/Angular/assets/invite-modal2-screenshot.png new file mode 100644 index 00000000..57db6ca7 Binary files /dev/null and b/docusaurus/docs/Angular/assets/invite-modal2-screenshot.png differ diff --git a/docusaurus/docs/Angular/code-examples/channel-invites.mdx b/docusaurus/docs/Angular/code-examples/channel-invites.mdx new file mode 100644 index 00000000..8d9769cd --- /dev/null +++ b/docusaurus/docs/Angular/code-examples/channel-invites.mdx @@ -0,0 +1,454 @@ +--- +id: channel-invites +title: Channel invites +--- + +import InviteButton from "../assets/invite-button-screenshot.png"; +import InviteModal1 from "../assets/invite-modal1-screenshot.png"; +import InviteModal2 from "../assets/invite-modal2-screenshot.png"; +import Invites from "../assets/channel-invites-screenshot.png"; + +This guide gives you a step-by-step tutorial on how to use [channel invites](https://getstream.io/chat/docs/javascript/channel_invites/?language=javascript) in your chat application. + +## Invite button + +The [`ChannelHeader`](../components/ChannelHeaderComponent.mdx) component has an input called `channelActionsTemplate` that can be used to add action buttons to the channel header. + +Let's create a component for the invite button that we'll add to the channel header: + +``` +ng g c invite-button +``` + +### HTML template + +We create a simplistic UI with an "Invite users" button that opens a [modal](../components/ModalComponent.mdx) where users can search for other users in the application. The [`NotificationList` component](../components/NotificationListComponent.mdx) is used to display any error messages that may occur during the invite request. + +```html + + + + +``` + +### Styling + +We are using stream-chat theme variables to match the default chat theme. You can read more about theme variables in our [themeing guide](../concepts/themeing-and-css.mdx). + +```scss +.modal-content { + width: 600px; + padding: 30px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 15px; + + .title { + font-size: 23px; + font-weight: 700; + } + + input { + width: 200px; + padding: 10px; + border: none; + border-radius: var(--border-radius-md); + background-color: var(--grey-whisper); + } + + .invited-users { + text-align: center; + } + + .add { + margin-left: 5px; + } + + .notifications { + width: 100%; + } +} + +button { + background-color: var(--primary-color); + border: none; + border-radius: var(--border-radius-md); + color: white; + padding: 10px; + cursor: pointer; +} +``` + +### Component logic + +Let's break down the most important parts of the component's logic: + +- We define an input with the type `Channel` to access the current active channel - this will be provided by the [`ChannelHeader`](../components/ChannelHeaderComponent.mdx) component +- We check if the current user has the `update-channel-members` [capability](https://getstream.io/chat/docs/javascript/channel_capabilities/?language=javascript) to see if they can invite members (it's important to note that [not every channel can be extended with new members](https://getstream.io/chat/docs/javascript/creating_channels/?language=javascript#2.-creating-a-channel-for-a-list-of-members)) +- The `autocompleteUsers` method of the [`ChatClientService`](../services/ChatClientService.mdx) can be used to search for users in the application +- The `inviteMembers` method of the `Channel` can be used to invite one or more members to the channel +- The [`NotificationService`](../services/NotificationService.mdx) can be used to notify the user about the result of the invite request + +```typescript +import { + Component, + ElementRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewChild, +} from "@angular/core"; +import { fromEvent } from "rxjs"; +import { debounceTime, switchMap } from "rxjs/operators"; +import { Channel, UserResponse } from "stream-chat"; +import { ChatClientService, NotificationService } from "stream-chat-angular"; + +@Component({ + selector: "app-invite-button", + templateUrl: "./invite-button.component.html", + styleUrls: ["./invite-button.component.scss"], +}) +export class InviteButtonComponent implements OnInit, OnChanges { + @Input() channel?: Channel; + usersToInvite: UserResponse[] = []; + canInviteUsers = false; + isModalOpen = false; + autocompleteOptions: UserResponse[] = []; + @ViewChild("input", { static: true }) + private input!: ElementRef; + + constructor( + private chatClientService: ChatClientService, + private notificationService: NotificationService + ) {} + + ngOnInit(): void { + fromEvent(this.input.nativeElement, "input") + .pipe( + debounceTime(300), + switchMap(() => + this.chatClientService.autocompleteUsers( + this.input.nativeElement.value + ) + ) + ) + .subscribe((users) => (this.autocompleteOptions = users)); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.channel && this.channel) { + this.canInviteUsers = ( + this.channel.data?.own_capabilities as string[] + ).includes("update-channel-members"); + this.usersToInvite = []; + this.autocompleteOptions = []; + } + } + + async inviteMembers() { + try { + await this.channel?.inviteMembers(this.usersToInvite.map((u) => u.id)); + this.notificationService.addTemporaryNotification( + "User(s) successfully invited", + "success" + ); + this.usersToInvite = []; + this.autocompleteOptions = []; + this.isModalOpen = false; + } catch { + this.notificationService.addTemporaryNotification( + `User(s) couldn't be invited`, + "error" + ); + } + } + + addUser() { + const inputValue = this.input.nativeElement.value; + const user = this.autocompleteOptions.find( + (u) => u.id === inputValue || u.name === inputValue + ); + if (user) { + this.usersToInvite.push(user); + this.input.nativeElement.value = ""; + this.autocompleteOptions = []; + } + } +} +``` + +### Providing the invite button to the channel header + +Lastly, we provide the `InviteButton` component to the `ChannelHeader` + +```html +
+ + + + + + + + + + + +
+ + + + +``` + +This is how the channel header looks with the invite button present: + + + +And this is how the invite modal turned out: + + + + + +## Pending invitations + +The next step is to show the pending invitations to the user. + +### Invitation notification component + +First we create a component that will display a pending invitation: + +``` +ng g c invitation --inline-template --inline-style +``` + +Here are the most importnat parts of the component: + +- The component will be displayed inside the [`NotificationList`](../components/NotificationListComponent.mdx) component +- We create an input with `Channel` type, this will be provided by the `NotificationList` +- We create an input called `dismissFn`, this will also be provided by the `NotificationList` and can be used to dismiss the notification +- The [`ChatClientService`](../services/ChatClientService.mdx) can be used to get the current chat user's id, this will be necessary when accepting/rejecting the invite +- The invite can be accepted with the `acceptInvite` method of the `channel` +- The invite can be rejected with the `rejectInvite` method of the `channel` + +The component: + +```typescript +import { Component, Input, OnInit } from "@angular/core"; +import { Channel } from "stream-chat"; +import { ChatClientService, NotificationService } from "stream-chat-angular"; + +@Component({ + selector: "app-invitation", + template: ` +
+ You have been invited to the + {{ channelName }} channel. | + | + +
+ `, + styles: [ + "button {border: none; background-color: transparent; color: var(--blue); font-weight: bold; cursor: pointer}", + ], +}) +export class InvitationComponent implements OnInit { + @Input() channel?: Channel; + @Input() dismissFn!: Function; + + constructor( + private notificationService: NotificationService, + private chatClientService: ChatClientService + ) {} + + ngOnInit(): void {} + + accept() { + this.sendRequest("accept"); + } + + async decline() { + this.sendRequest("reject"); + } + + get channelName() { + return this.channel?.data?.name || this.channel?.id; + } + + private async sendRequest(answer: "accept" | "reject") { + const payload = { + user_id: this.chatClientService?.chatClient.user?.id, + }; + const request = + answer === "accept" + ? async () => await this.channel?.acceptInvite(payload) + : async () => await this.channel?.rejectInvite(payload); + try { + await request(); + this.dismissFn(); + this.notificationService.addTemporaryNotification( + `Invite from ${this.channelName} successfully ${answer}ed`, + "success" + ); + } catch { + this.notificationService.addTemporaryNotification( + `An error occured during ${answer}ing the invitation from ${this.channelName}`, + "error" + ); + } + } +} +``` + +### Displaying the invitations + +The next step will be to display the invitations. + +#### Invitation template + +We will have to create the invitation template that can be passed to the [`NotificationList`](../components/NotificationListComponent.mdx) component. + +Add this to your `app.component.html` file: + +```html + + + +``` + +Add a reference to the template in your `app.component.ts`: + +```typescript +@ViewChild('inviteTemplate') private inviteTemplate!: TemplateRef<{ + channel: Channel | ChannelResponse; +}>; +``` + +#### Displaying the invitations + +The `pendingInvites$` Observable on the [`ChatClientService`](../services/ChatClientService.mdx) can notify us about the pending invitations of the current user. Let's subscribe to this Observable and display the invites in the `ngOnInit` method of the `app.component.ts` + +```typescript +ngOnInit(): void { + this.chatService.pendingInvites$.pipe(pairwise()).subscribe((pair) => { + const [prevInvites, currentInvites] = pair; + const notShownInvites = currentInvites.filter( + (i) => !prevInvites.find((prevI) => prevI.cid === i.cid) + ); + notShownInvites.forEach((i) => + this.notificationService.addPermanentNotification( + this.inviteTemplate, + 'info', + undefined, + { channel: i } + ) + ); + }); +} +``` + +The above method will display all the pending invitations on page load and display every new invitation received later. + +This is how the invitation notifications look like: + + + +## Channel list + +### Channel filter + +If a user is invited to a channel they immediately become member of the channel (the membership applies even if the invite is rejected). +This means that if you use a channel filter that is based on membership (for example `{members: {$in: []}}`), channels with pending and recejted invites will be returned and displayed in the channel list as well. +If this is not what you need, you can use the [`joined`](https://getstream.io/chat/docs/javascript/query_channels/?language=javascript#channel-queryable-built-in-fields) flag to only list channels that the user was directly added to or the invitation was accepted by the user. + +The channel filter can be provided to the `init` method of the [`ChannelService`](../services/ChannelService.mdx), here is an example: + +```typescript +this.channelService.init({ + joined: true, +}); +``` + +### `notification.added_to_channel` event + +It's important to note that the filtering set above is not applied to [events](https://getstream.io/chat/docs/javascript/event_object/?language=javascript) which means that you'll have to override the [default channel list behavior](../services/ChannelService.mdx/#channels) if you don't want channels with pending invites to be added to the channel list when a `notification.added_to_channel` event is received. + +To override the default behavior create a custom event handler in `app.component.ts` that checks if the user was invited to the channel or added directly and only adds the channel to the list if the user was added directly: + +```typescript +private customAddedToChannelNotificationHandler( + notification: Notification, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void + ): void { + let channels!: Channel[]; + this.channelService.channels$ + .pipe(take(1)) + .subscribe((c) => (channels = c || [])); + if (notification.event.member?.invited) { + return; + } + channelListSetter([notification!.event!.channel!, ...channels]); + } +``` + +:::important +Make sure to add the following import to your code (your IDE might not be able to add this automatically): + +`import { Notification } from 'stream-chat-angular';` +::: + +Now register the handler to the [channel service](../services/ChannelService.mdx) in the constructor of `app.component.ts`: + +``` +this.channelService.customAddedToChannelNotificationHandler = + this.customAddedToChannelNotificationHandler.bind(this); +``` + +### `notification.invite_accepted` event + +The `notification.invite_accepted` event emitted by the [`ChatClientService`](../services/ChatClientService.mdx) signals that the user accepted an invitation to a channel, we should add the channel to the channel list, we can do this by reinitializing the channel list. + +Add this to the constructor of your `app.component.ts`: + +```typescript +this.chatService.notification$ + .pipe(filter((n) => n.eventType === "notification.invite_accepted")) + .subscribe(() => { + this.channelService.reset(); + void this.channelService.init({ + joined: true, + }); + }); +``` diff --git a/projects/stream-chat-angular/.eslintrc.json b/projects/stream-chat-angular/.eslintrc.json index ab588d88..cae3081d 100644 --- a/projects/stream-chat-angular/.eslintrc.json +++ b/projects/stream-chat-angular/.eslintrc.json @@ -57,6 +57,7 @@ "jsdoc/require-returns-type": 0, "jsdoc/newline-after-description": 0, "jsdoc/require-param-description": 0, + "jsdoc/require-param": 2, "jsdoc/no-types": 2, "jsdoc/no-defaults": 2, "jsdoc/require-asterisk-prefix": 2, @@ -100,6 +101,7 @@ "jsdoc/require-returns-type": 0, "jsdoc/newline-after-description": 0, "jsdoc/require-param-description": 0, + "jsdoc/require-param": 2, "jsdoc/no-types": 2, "jsdoc/no-defaults": 2, "jsdoc/require-asterisk-prefix": 2, diff --git a/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.html b/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.html index 14d685f2..6d66c3ba 100644 --- a/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.html +++ b/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.html @@ -20,4 +20,12 @@ translate:watcherCountParam) : ''}}

+ + + diff --git a/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.ts b/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.ts index 8c5a5e2e..5961f86d 100644 --- a/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.ts +++ b/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, Input, TemplateRef } from '@angular/core'; import { Channel } from 'stream-chat'; import { ChannelListToggleService } from '../channel-list/channel-list-toggle.service'; import { ChannelService } from '../channel.service'; @@ -12,6 +12,10 @@ import { ChannelService } from '../channel.service'; styles: [], }) export class ChannelHeaderComponent { + /** + * Template that can be used to add actions (such as edit, invite) to the channel header + */ + @Input() channelActionsTemplate?: TemplateRef<{ channel: Channel }>; activeChannel: Channel | undefined; canReceiveConnectEvents: boolean | undefined; diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index 45250759..514f327f 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -11,6 +11,7 @@ import { Channel, ChannelFilters, ChannelOptions, + ChannelResponse, ChannelSort, Event, FormatMessageResponse, @@ -98,7 +99,7 @@ export class ChannelService { */ usersTypingInThread$: Observable; /** - * Emits a map that contains the date of the latest message sent by the current user by channels (this is used to detect is slow mode countdown should be started) + * Emits a map that contains the date of the latest message sent by the current user by channels (this is used to detect if slow mode countdown should be started) */ latestMessageDateByUserByChannels$: Observable<{ [key: string]: Date }>; /** @@ -106,21 +107,21 @@ export class ChannelService { */ customNewMessageNotificationHandler?: ( notification: Notification, - channelListSetter: (channels: Channel[]) => void + channelListSetter: (channels: (Channel | ChannelResponse)[]) => 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](./ChannelService.mdx/#channels) */ customAddedToChannelNotificationHandler?: ( notification: Notification, - channelListSetter: (channels: Channel[]) => void + channelListSetter: (channels: (Channel | ChannelResponse)[]) => 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](./ChannelService.mdx/#channels) */ customRemovedFromChannelNotificationHandler?: ( notification: Notification, - channelListSetter: (channels: Channel[]) => void + channelListSetter: (channels: (Channel | ChannelResponse)[]) => 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](./ChannelService.mdx/#channels) @@ -128,7 +129,7 @@ export class ChannelService { customChannelDeletedHandler?: ( event: Event, channel: Channel, - channelListSetter: (channels: Channel[]) => void, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void, messageListSetter: (messages: StreamMessage[]) => void, threadListSetter: (messages: StreamMessage[]) => void, parentMessageSetter: (message: StreamMessage | undefined) => void @@ -139,7 +140,7 @@ export class ChannelService { customChannelUpdatedHandler?: ( event: Event, channel: Channel, - channelListSetter: (channels: Channel[]) => void, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void, messageListSetter: (messages: StreamMessage[]) => void, threadListSetter: (messages: StreamMessage[]) => void, parentMessageSetter: (message: StreamMessage | undefined) => void @@ -150,7 +151,7 @@ export class ChannelService { customChannelTruncatedHandler?: ( event: Event, channel: Channel, - channelListSetter: (channels: Channel[]) => void, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void, messageListSetter: (messages: StreamMessage[]) => void, threadListSetter: (messages: StreamMessage[]) => void, parentMessageSetter: (message: StreamMessage | undefined) => void @@ -161,7 +162,7 @@ export class ChannelService { customChannelHiddenHandler?: ( event: Event, channel: Channel, - channelListSetter: (channels: Channel[]) => void, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void, messageListSetter: (messages: StreamMessage[]) => void, threadListSetter: (messages: StreamMessage[]) => void, parentMessageSetter: (message: StreamMessage | undefined) => void @@ -172,7 +173,7 @@ export class ChannelService { customChannelVisibleHandler?: ( event: Event, channel: Channel, - channelListSetter: (channels: Channel[]) => void, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void, messageListSetter: (messages: StreamMessage[]) => void, threadListSetter: (messages: StreamMessage[]) => void, parentMessageSetter: (message: StreamMessage | undefined) => void @@ -183,7 +184,7 @@ export class ChannelService { customNewMessageHandler?: ( event: Event, channel: Channel, - channelListSetter: (channels: Channel[]) => void, + channelListSetter: (channels: (Channel | ChannelResponse)[]) => void, messageListSetter: (messages: StreamMessage[]) => void, threadListSetter: (messages: StreamMessage[]) => void, parentMessageSetter: (message: StreamMessage | undefined) => void @@ -218,8 +219,19 @@ export class ChannelService { private usersTypingInChannelSubject = new BehaviorSubject([]); private usersTypingInThreadSubject = new BehaviorSubject([]); - private channelListSetter = (channels: Channel[]) => { - this.channelsSubject.next(channels); + private channelListSetter = (channels: (Channel | ChannelResponse)[]) => { + const currentChannels = this.channelsSubject.getValue() || []; + const newChannels = channels.filter( + (c) => !currentChannels.find((channel) => channel.cid === c.cid) + ); + const deletedChannels = currentChannels.filter( + (c) => !channels?.find((channel) => channel.cid === c.cid) + ); + this.addChannelsFromNotification(newChannels as ChannelResponse[]); + this.removeChannelsFromChannelList(deletedChannels.map((c) => c.cid)); + if (!newChannels.length && !deletedChannels.length) { + this.channelsSubject.next(channels as Channel[]); + } }; private messageListSetter = (messages: StreamMessage[]) => { @@ -681,30 +693,30 @@ export class ChannelService { } } - private async handleNotification(notification: Notification) { + private handleNotification(notification: Notification) { switch (notification.eventType) { case 'notification.message_new': { - await this.ngZone.run(async () => { + this.ngZone.run(() => { if (this.customNewMessageNotificationHandler) { this.customNewMessageNotificationHandler( notification, this.channelListSetter ); } else { - await this.handleNewMessageNotification(notification); + this.handleNewMessageNotification(notification); } }); break; } case 'notification.added_to_channel': { - await this.ngZone.run(async () => { + this.ngZone.run(() => { if (this.customAddedToChannelNotificationHandler) { this.customAddedToChannelNotificationHandler( notification, this.channelListSetter ); } else { - await this.handleAddedToChannelNotification(notification); + this.handleAddedToChannelNotification(notification); } }); break; @@ -726,36 +738,48 @@ export class ChannelService { private handleRemovedFromChannelNotification(notification: Notification) { const channelIdToBeRemoved = notification.event.channel!.cid; - this.removeFromChannelList(channelIdToBeRemoved); + this.removeChannelsFromChannelList([channelIdToBeRemoved]); } - private async handleNewMessageNotification(notification: Notification) { - await this.addChannelFromNotification(notification); + private handleNewMessageNotification(notification: Notification) { + if (notification.event.channel) { + this.addChannelsFromNotification([notification.event.channel]); + } } - private async handleAddedToChannelNotification(notification: Notification) { - await this.addChannelFromNotification(notification); + private handleAddedToChannelNotification(notification: Notification) { + if (notification.event.channel) { + this.addChannelsFromNotification([notification.event.channel]); + } } - private async addChannelFromNotification(notification: Notification) { - const channel = this.chatClientService.chatClient.channel( - notification.event.channel?.type!, - notification.event.channel?.id - ); - await channel.watch(); - this.watchForChannelEvents(channel); + private addChannelsFromNotification(channelResponses: ChannelResponse[]) { + const newChannels: Channel[] = []; + channelResponses.forEach((channelResponse) => { + const channel = this.chatClientService.chatClient.channel( + channelResponse.type, + channelResponse.id + ); + void channel.watch(); + this.watchForChannelEvents(channel); + newChannels.push(channel); + }); this.channelsSubject.next([ - channel, + ...newChannels, ...(this.channelsSubject.getValue() || []), ]); } - private removeFromChannelList(cid: string) { - const channels = this.channels.filter((c) => c.cid !== cid); + private removeChannelsFromChannelList(cids: string[]) { + const channels = this.channels.filter((c) => !cids.includes(c.cid || '')); if (channels.length < this.channels.length) { this.channelsSubject.next(channels); - if (this.activeChannelSubject.getValue()!.cid === cid) { - this.setAsActiveChannel(channels[0]); + if (cids.includes(this.activeChannelSubject.getValue()?.cid || '')) { + if (channels.length > 0) { + this.setAsActiveChannel(channels[0]); + } else { + this.activeChannelSubject.next(undefined); + } } } } @@ -1059,11 +1083,11 @@ export class ChannelService { } private handleChannelHidden(event: Event) { - this.removeFromChannelList(event.channel!.cid); + this.removeChannelsFromChannelList([event.channel!.cid]); } private handleChannelDeleted(event: Event) { - this.removeFromChannelList(event.channel!.cid); + this.removeChannelsFromChannelList([event.channel!.cid]); } private handleChannelVisible(event: Event, channel: Channel) { diff --git a/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts b/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts index e5b9c857..c15c811b 100644 --- a/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/chat-client.service.spec.ts @@ -33,9 +33,13 @@ describe('ChatClientService', () => { }); it('should disconnect user', async () => { + const pendingInvitesSpy = jasmine.createSpy(); + service.pendingInvites$.subscribe(pendingInvitesSpy); + pendingInvitesSpy.calls.reset(); await service.disconnectUser(); expect(mockChatClient.disconnectUser).toHaveBeenCalledWith(); + expect(pendingInvitesSpy).toHaveBeenCalledWith([]); }); it('should init with user meta data', async () => { @@ -180,4 +184,83 @@ describe('ChatClientService', () => { expect(result.length).toBe(2); }); + + it('should initialize pending invites', async () => { + const channelsWithPendingInvites = [{ cid: 'cat-lovers' }]; + mockChatClient.queryChannels.and.resolveTo(channelsWithPendingInvites); + const invitesSpy = jasmine.createSpy(); + service.pendingInvites$.subscribe(invitesSpy); + invitesSpy.calls.reset(); + await service.init(apiKey, userId, userToken); + + expect(mockChatClient.queryChannels).toHaveBeenCalledWith( + { + invite: 'pending', + }, + {}, + { user_id: mockChatClient.user.id } + ); + + expect(invitesSpy).toHaveBeenCalledWith(channelsWithPendingInvites); + }); + + it('should emit pending invitations of user', () => { + const invitesSpy = jasmine.createSpy(); + service.pendingInvites$.subscribe(invitesSpy); + const event1 = { + id: 'mockevent', + type: 'notification.invited', + channel: { cid: 'what-i-ate-for-lunch' }, + member: { user: mockChatClient.user }, + } as any as Event; + mockChatClient.handleEvent(event1.type, event1); + + expect(invitesSpy).toHaveBeenCalledWith([event1.channel]); + + invitesSpy.calls.reset(); + const event2 = { + id: 'mockevent', + type: 'notification.invited', + channel: { cid: 'gardening' }, + member: { user: mockChatClient.user }, + } as any as Event; + mockChatClient.handleEvent(event2.type, event2); + + expect(invitesSpy).toHaveBeenCalledWith([event1.channel, event2.channel]); + + invitesSpy.calls.reset(); + const event3 = { + id: 'mockevent', + type: 'notification.invite_accepted', + channel: { cid: 'what-i-ate-for-lunch' }, + member: { user: mockChatClient.user }, + } as any as Event; + mockChatClient.handleEvent(event3.type, event3); + + expect(invitesSpy).toHaveBeenCalledWith([event2.channel]); + + invitesSpy.calls.reset(); + const event4 = { + id: 'mockevent', + type: 'notification.invite_rejected', + channel: { cid: 'gardening' }, + member: { user: mockChatClient.user }, + } as any as Event; + mockChatClient.handleEvent(event4.type, event4); + + expect(invitesSpy).toHaveBeenCalledWith([]); + + invitesSpy.calls.reset(); + const event5 = { + id: 'mockevent', + type: 'notification.invite_rejected', + channel: { cid: 'gardening' }, + member: { + user: { id: `not${mockChatClient.user.id}` }, + }, + } as any as Event; + mockChatClient.handleEvent(event5.type, event5); + + expect(invitesSpy).not.toHaveBeenCalled(); + }); }); diff --git a/projects/stream-chat-angular/src/lib/chat-client.service.ts b/projects/stream-chat-angular/src/lib/chat-client.service.ts index f17948f3..168b7836 100644 --- a/projects/stream-chat-angular/src/lib/chat-client.service.ts +++ b/projects/stream-chat-angular/src/lib/chat-client.service.ts @@ -1,6 +1,11 @@ import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; -import { OwnUserResponse, UserResponse } from 'stream-chat'; +import { + Channel, + ChannelResponse, + OwnUserResponse, + UserResponse, +} from 'stream-chat'; import { AppSettings, Event, StreamChat, TokenOrProvider } from 'stream-chat'; import { version } from '../assets/version'; import { NotificationService } from './notification.service'; @@ -36,11 +41,18 @@ export class ChatClientService { * Emits the current connection state of the user (`online` or `offline`) */ connectionState$: Observable<'offline' | 'online'>; + /** + * Emits the list of pending invites of the user. It emits every pending invitation during initialization and then extends the list when a new invite is received. More information can be found in the [channel invitations](../code-examples/channel-invites.mdx) guide. + */ + pendingInvites$: Observable<(ChannelResponse | Channel)[]>; private notificationSubject = new ReplaySubject(1); private connectionStateSubject = new ReplaySubject<'offline' | 'online'>(1); private appSettingsSubject = new BehaviorSubject( undefined ); + private pendingInvitesSubject = new BehaviorSubject< + (ChannelResponse | Channel)[] + >([]); constructor( private ngZone: NgZone, @@ -49,6 +61,7 @@ export class ChatClientService { this.notification$ = this.notificationSubject.asObservable(); this.connectionState$ = this.connectionStateSubject.asObservable(); this.appSettings$ = this.appSettingsSubject.asObservable(); + this.pendingInvites$ = this.pendingInvitesSubject.asObservable(); } /** @@ -63,16 +76,23 @@ export class ChatClientService { userTokenOrProvider: TokenOrProvider ) { this.chatClient = StreamChat.getInstance(apiKey); + this.chatClient.devToken; await this.ngZone.runOutsideAngular(async () => { const user = typeof userOrId === 'string' ? { id: userOrId } : userOrId; await this.chatClient.connectUser(user, userTokenOrProvider); this.chatClient.setUserAgent( `stream-chat-angular-${version}-${this.chatClient.getUserAgent()}` ); - this.chatClient.getAppSettings; }); + const channels = await this.chatClient.queryChannels( + { invite: 'pending' }, + {}, + { user_id: this.chatClient.user?.id } + ); + this.pendingInvitesSubject.next(channels); this.appSettingsSubject.next(undefined); this.chatClient.on((e) => { + this.updatePendingInvites(e); this.notificationSubject.next({ eventType: e.type, event: e, @@ -101,6 +121,7 @@ export class ChatClientService { * Disconnects the current user, and closes the WebSocket connection. Useful when disconnecting a chat user, use in combination with [`reset`](./ChannelService.mdx/#reset). */ async disconnectUser() { + this.pendingInvitesSubject.next([]); await this.chatClient.disconnectUser(); } @@ -141,4 +162,24 @@ export class ChatClientService { }); return result.users; } + + private updatePendingInvites(e: Event) { + if (e.member?.user?.id === this.chatClient.user?.id && e.channel) { + const pendingInvites = this.pendingInvitesSubject.getValue(); + if (e.type === 'notification.invited') { + this.pendingInvitesSubject.next([...pendingInvites, e.channel]); + } else if ( + e.type === 'notification.invite_accepted' || + e.type === 'notification.invite_rejected' + ) { + const index = pendingInvites.findIndex( + (i) => i?.cid === e.channel?.cid + ); + if (index !== -1) { + pendingInvites.splice(index, 1); + this.pendingInvitesSubject.next([...pendingInvites]); + } + } + } + } } diff --git a/projects/stream-chat-angular/src/lib/mocks/index.ts b/projects/stream-chat-angular/src/lib/mocks/index.ts index 64635aad..40c3e2ec 100644 --- a/projects/stream-chat-angular/src/lib/mocks/index.ts +++ b/projects/stream-chat-angular/src/lib/mocks/index.ts @@ -279,6 +279,7 @@ export type MockStreamChatClient = { getUserAgent: () => string; getAppSettings: jasmine.Spy; disconnectUser: jasmine.Spy; + queryChannels: jasmine.Spy; }; export const mockStreamChatClient = (): MockStreamChatClient => { @@ -288,6 +289,7 @@ export const mockStreamChatClient = (): MockStreamChatClient => { const flagMessage = jasmine.createSpy(); const setUserAgent = jasmine.createSpy(); const queryUsers = jasmine.createSpy(); + const queryChannels = jasmine.createSpy().and.returnValue([]); const getAppSettings = jasmine.createSpy().and.returnValue({ app: { file_upload_config: { @@ -336,6 +338,7 @@ export const mockStreamChatClient = (): MockStreamChatClient => { queryUsers, getAppSettings, appSettings$, + queryChannels, }; }; diff --git a/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.html b/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.html index 03d71e8f..af450fff 100644 --- a/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.html +++ b/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.html @@ -1,9 +1,21 @@
- {{ notification.text | translate: notification.translateParams }} -
+
+ {{ notification.text | translate: notification.translateParams }} +
+ + + +
diff --git a/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.ts b/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.ts index 79ab1fa4..9164c786 100644 --- a/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.ts +++ b/projects/stream-chat-angular/src/lib/notification-list/notification-list.component.ts @@ -1,9 +1,7 @@ import { Component } from '@angular/core'; import { Observable } from 'rxjs'; -import { - NotificationPayload, - NotificationService, -} from '../notification.service'; +import { NotificationService } from '../notification.service'; +import { NotificationPayload } from '../types'; /** * The `NotificationList` component displays the list of active notifications. @@ -20,7 +18,14 @@ export class NotificationListComponent { this.notifications$ = this.notificationService.notifications$; } - trackByItem(_: number, item: NotificationPayload) { - return item; + trackById(_: number, item: NotificationPayload) { + return item.id; + } + + getTemplateContext(notification: NotificationPayload) { + return { + ...notification.templateContext, + dismissFn: notification.dismissFn, + }; } } diff --git a/projects/stream-chat-angular/src/lib/notification.service.spec.ts b/projects/stream-chat-angular/src/lib/notification.service.spec.ts index f3e47727..86da5dac 100644 --- a/projects/stream-chat-angular/src/lib/notification.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/notification.service.spec.ts @@ -1,3 +1,4 @@ +import { TemplateRef } from '@angular/core'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { NotificationService } from './notification.service'; @@ -19,7 +20,7 @@ describe('NotificationService', () => { service.addTemporaryNotification(text, type); expect(spy).toHaveBeenCalledWith([ - { text, type, translateParams: undefined }, + jasmine.objectContaining({ text, type, translateParams: undefined }), ]); }); @@ -43,7 +44,9 @@ describe('NotificationService', () => { const translateParams = { timeout: 5000 }; service.addPermanentNotification(text, type, translateParams); - expect(spy).toHaveBeenCalledWith([{ text, type, translateParams }]); + expect(spy).toHaveBeenCalledWith([ + jasmine.objectContaining({ text, type, translateParams }), + ]); spy.calls.reset(); tick(5000); @@ -59,4 +62,35 @@ describe('NotificationService', () => { expect(spy).toHaveBeenCalledWith([]); }); + + it('should add HTML notification - temporary notification', () => { + const template = { template: 'template' } as any as TemplateRef; + const templateContext = { channelName: 'gardening' }; + service.addTemporaryNotification( + template, + 'success', + 5000, + undefined, + templateContext + ); + + expect(spy).toHaveBeenCalledWith([ + jasmine.objectContaining({ templateContext, template }), + ]); + }); + + it('should add HTML notification - permanent notification', () => { + const template = { template: 'template' } as any as TemplateRef; + const templateContext = { channelName: 'gardening' }; + service.addPermanentNotification( + template, + 'success', + undefined, + templateContext + ); + + expect(spy).toHaveBeenCalledWith([ + jasmine.objectContaining({ templateContext, template }), + ]); + }); }); diff --git a/projects/stream-chat-angular/src/lib/notification.service.ts b/projects/stream-chat-angular/src/lib/notification.service.ts index c1af5dbd..6bcba40c 100644 --- a/projects/stream-chat-angular/src/lib/notification.service.ts +++ b/projects/stream-chat-angular/src/lib/notification.service.ts @@ -1,13 +1,6 @@ -import { Injectable } from '@angular/core'; +import { Injectable, TemplateRef } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; - -export type NotificationType = 'success' | 'error'; - -export type NotificationPayload = { - type: NotificationType; - text: string; - translateParams?: Object; -}; +import { NotificationPayload, NotificationType } from './types'; /** * The `NotificationService` can be used to add or remove notifications. By default the [`NotificationList`](../components/NotificationListComponent.mdx) component displays the currently active notifications. @@ -28,58 +21,95 @@ export class NotificationService { /** * Displays a notification for the given amount of time. - * @param text The text of the notification + * @param content The text of the notification or the HTML template for the notification * @param type The type of the notification * @param timeout The number of milliseconds while the notification should be visible - * @param translateParams Translation parameters for the `text` + * @param translateParams Translation parameters for the `content` (for text notifications) + * @param templateContext The input of the notification template (for HTML notifications) * @returns A method to clear the notification (before the timeout). */ - addTemporaryNotification( - text: string, + addTemporaryNotification( + content: string | TemplateRef, type: NotificationType = 'error', timeout: number = 5000, - translateParams?: Object + translateParams?: Object, + templateContext?: T ) { - this.addNotification(text, type, translateParams); - const id = setTimeout(() => this.removeNotification(text), timeout); - - return () => { + const notification = this.createNotification( + content, + type, + translateParams, + templateContext + ); + const id = setTimeout( + () => this.removeNotification(notification.id), + timeout + ); + notification.dismissFn = () => { clearTimeout(id); - this.removeNotification(text); + this.removeNotification(notification.id); }; + this.notificationsSubject.next([ + ...this.notificationsSubject.getValue(), + notification, + ]); + + return notification.dismissFn; } /** * Displays a notification, that will be visible until it's removed. - * @param text The text of the notification + * @param content The text of the notification or the HTML template for the notification * @param type The type of the notification - * @param translateParams Translation parameters for the `text` + * @param translateParams Translation parameters for the `content` (for text notifications) + * @param templateContext The input of the notification template (for HTML notifications) * @returns A method to clear the notification. */ - addPermanentNotification( - text: string, + addPermanentNotification< + T = { + [key: string]: any; + dismissFn: () => {}; + } + >( + content: string | TemplateRef, type: NotificationType = 'error', - translateParams?: Object + translateParams?: Object, + templateContext?: T ) { - this.addNotification(text, type, translateParams); + const notification = this.createNotification( + content, + type, + translateParams, + templateContext + ); + this.notificationsSubject.next([ + ...this.notificationsSubject.getValue(), + notification, + ]); - return () => this.removeNotification(text); + return notification.dismissFn; } - private addNotification( - text: string, + private createNotification( + content: string | TemplateRef, type: NotificationType, - translateParams?: Object + translateParams?: Object, + templateContext?: T ) { - this.notificationsSubject.next([ - ...this.notificationsSubject.getValue(), - { text, type, translateParams }, - ]); + const id = new Date().getTime().toString() + Math.random().toString(); + return { + id, + [typeof content === 'string' ? 'text' : 'template']: content, + type, + translateParams, + templateContext, + dismissFn: () => this.removeNotification(id), + }; } - private removeNotification(text: string) { + private removeNotification(id: string) { const notifications = this.notificationsSubject.getValue(); - const index = notifications.findIndex((n) => n.text === text); + const index = notifications.findIndex((n) => n.id === id); if (index === -1) { return; } diff --git a/projects/stream-chat-angular/src/lib/notification/notification.component.spec.ts b/projects/stream-chat-angular/src/lib/notification/notification.component.spec.ts index 5cf941f6..84a2c2be 100644 --- a/projects/stream-chat-angular/src/lib/notification/notification.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/notification/notification.component.spec.ts @@ -45,4 +45,13 @@ describe('NotificationComponent', () => { notificationElement?.classList.contains('notification-error') ).toBeFalse(); }); + + it('should add dynamic CSS class if type is info', () => { + component.type = 'info'; + fixture.detectChanges(); + + expect( + queryNotification()?.classList.contains('notification-info') + ).toBeTrue(); + }); }); diff --git a/projects/stream-chat-angular/src/lib/notification/notification.component.ts b/projects/stream-chat-angular/src/lib/notification/notification.component.ts index 26059464..01e823e8 100644 --- a/projects/stream-chat-angular/src/lib/notification/notification.component.ts +++ b/projects/stream-chat-angular/src/lib/notification/notification.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { NotificationType } from '../notification.service'; +import { NotificationType } from '../types'; /** * The `Notification` component displays a notification within the [`NotificationList`](./NotificationListComponent.mdx) diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts index f163aac1..4339271e 100644 --- a/projects/stream-chat-angular/src/lib/types.ts +++ b/projects/stream-chat-angular/src/lib/types.ts @@ -1,3 +1,4 @@ +import { TemplateRef } from '@angular/core'; import type { Attachment, ChannelMemberResponse, @@ -101,3 +102,15 @@ export type MentionAutcompleteListItem = ( export type ComandAutocompleteListItem = CommandResponse & { autocompleteLabel: string; }; + +export type NotificationType = 'success' | 'error' | 'info'; + +export type NotificationPayload = { + id: string; + type: NotificationType; + text?: string; + translateParams?: Object; + template?: TemplateRef; + templateContext?: T; + dismissFn: Function; +};