diff --git a/docusaurus/docs/Angular/components/PaginatedListComponent.mdx b/docusaurus/docs/Angular/components/PaginatedListComponent.mdx new file mode 100644 index 00000000..6fb1ad44 --- /dev/null +++ b/docusaurus/docs/Angular/components/PaginatedListComponent.mdx @@ -0,0 +1,27 @@ +The `PaginatedListComponent` can display a list of arbitrary data with support for loading indicator and pagination. This is a utility component, you don't need to use it unless you're building a custom component. + +## Usage + +The paginated list component relies on data provided by the parent component. You can provide the HTML template for the list items. + +```html + +
+ {{ index }}. {{ item }} +
+ +``` + +## Customization + +You can provide the HTML template for the list items, see above example. + +[//]: # "Start of generated content" +[//]: # "End of generated content" diff --git a/docusaurus/docs/Angular/components/UserListComponent.mdx b/docusaurus/docs/Angular/components/UserListComponent.mdx new file mode 100644 index 00000000..011d55de --- /dev/null +++ b/docusaurus/docs/Angular/components/UserListComponent.mdx @@ -0,0 +1,21 @@ +The `UserListComponent` can display a list of Stream users with pagination. This can be useful if you want to build a user list in your application. + +## Usage + +The user list component relies on data provided by the parent component: + +```html + +``` + +## Customization + +The component is built on top of the [`PaginatedListComponent`](../../components/PaginatedListComponent.mdx), you can use that component to build your own user list component. + +[//]: # "Start of generated content" +[//]: # "End of generated content" diff --git a/package-lock.json b/package-lock.json index d8099356..1f735dce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@ctrl/ngx-emoji-mart": "^8.2.0", "@floating-ui/dom": "^1.6.3", "@ngx-translate/core": "^14.0.0", - "@stream-io/stream-chat-css": "4.18.0", + "@stream-io/stream-chat-css": "4.19.0", "@stream-io/transliterate": "^1.5.2", "angular-mentions": "1.4.0", "dayjs": "^1.11.10", @@ -30,7 +30,7 @@ "pretty-bytes": "^6.1.1", "rxjs": "~7.4.0", "starwars-names": "^1.6.0", - "stream-chat": "^8.26.0", + "stream-chat": "^8.40.1", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^9.0.1", @@ -68,7 +68,7 @@ "karma-jasmine-html-reporter": "~1.7.0", "lint-staged": "^11.1.2", "ng-packagr": "^15.2.2", - "prettier": "^2.4.0", + "prettier": "^2.8.8", "prettier-eslint": "^13.0.0", "semantic-release": "^18.0.0", "typedoc": "^0.25.13", @@ -4967,9 +4967,9 @@ "dev": true }, "node_modules/@stream-io/stream-chat-css": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-4.18.0.tgz", - "integrity": "sha512-FDTRBbYhIz/VqkGh7XCgvPd5BW7QoUBHTpp95KHP8S48MtA49a1pr8QmtPWAnj1jLz9GiUs+AAwSTvQ8bLVzDA==" + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-4.19.0.tgz", + "integrity": "sha512-aab4IoM5ZmCcmzg4r76084ArFHwma6CBrp0yjh5CMLy7bUoHru/3JvHKD5xZAVs2N4tGK+i2zKirhFfea7nOoQ==" }, "node_modules/@stream-io/transliterate": { "version": "1.5.2", @@ -19344,15 +19344,18 @@ } }, "node_modules/prettier": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", - "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "bin": { "prettier": "bin-prettier.js" }, "engines": { "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/prettier-eslint": { @@ -21639,9 +21642,9 @@ } }, "node_modules/stream-chat": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.26.0.tgz", - "integrity": "sha512-T6PZjDT+hKHEOhBmutlCNwzy5lDe3wxv6A2a2SGo2D7VRUT3ixCLvO0Dz609ErdzHyKs3pXUvdsZYA1QRtNafQ==", + "version": "8.40.1", + "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.40.1.tgz", + "integrity": "sha512-bkdgYjBK/4TGm3HP7/hnHUzui0ka2GjhzM4twBH3AIiVdzPLRrqNMavvOYZCI6Eio54ntylkb5EWXTJUHwxHpQ==", "dependencies": { "@babel/runtime": "^7.16.3", "@types/jsonwebtoken": "~9.0.0", @@ -21651,7 +21654,7 @@ "form-data": "^4.0.0", "isomorphic-ws": "^4.0.1", "jsonwebtoken": "~9.0.0", - "ws": "^7.4.4" + "ws": "^7.5.10" }, "engines": { "node": ">=16" @@ -21680,6 +21683,26 @@ "node": ">= 6" } }, + "node_modules/stream-chat/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -27112,9 +27135,9 @@ "dev": true }, "@stream-io/stream-chat-css": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-4.18.0.tgz", - "integrity": "sha512-FDTRBbYhIz/VqkGh7XCgvPd5BW7QoUBHTpp95KHP8S48MtA49a1pr8QmtPWAnj1jLz9GiUs+AAwSTvQ8bLVzDA==" + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-4.19.0.tgz", + "integrity": "sha512-aab4IoM5ZmCcmzg4r76084ArFHwma6CBrp0yjh5CMLy7bUoHru/3JvHKD5xZAVs2N4tGK+i2zKirhFfea7nOoQ==" }, "@stream-io/transliterate": { "version": "1.5.2", @@ -37672,9 +37695,9 @@ "dev": true }, "prettier": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", - "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true }, "prettier-eslint": { @@ -39419,9 +39442,9 @@ } }, "stream-chat": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.26.0.tgz", - "integrity": "sha512-T6PZjDT+hKHEOhBmutlCNwzy5lDe3wxv6A2a2SGo2D7VRUT3ixCLvO0Dz609ErdzHyKs3pXUvdsZYA1QRtNafQ==", + "version": "8.40.1", + "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-8.40.1.tgz", + "integrity": "sha512-bkdgYjBK/4TGm3HP7/hnHUzui0ka2GjhzM4twBH3AIiVdzPLRrqNMavvOYZCI6Eio54ntylkb5EWXTJUHwxHpQ==", "requires": { "@babel/runtime": "^7.16.3", "@types/jsonwebtoken": "~9.0.0", @@ -39431,7 +39454,7 @@ "form-data": "^4.0.0", "isomorphic-ws": "^4.0.1", "jsonwebtoken": "~9.0.0", - "ws": "^7.4.4" + "ws": "^7.5.10" }, "dependencies": { "axios": { @@ -39453,6 +39476,12 @@ "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } + }, + "ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "requires": {} } } }, diff --git a/package.json b/package.json index 30b6c4ae..77e30dea 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@ctrl/ngx-emoji-mart": "^8.2.0", "@floating-ui/dom": "^1.6.3", "@ngx-translate/core": "^14.0.0", - "@stream-io/stream-chat-css": "4.18.0", + "@stream-io/stream-chat-css": "4.19.0", "@stream-io/transliterate": "^1.5.2", "angular-mentions": "1.4.0", "dayjs": "^1.11.10", @@ -122,8 +122,8 @@ "ngx-float-ui": "^15.0.0", "pretty-bytes": "^6.1.1", "rxjs": "~7.4.0", - "stream-chat": "^8.26.0", "starwars-names": "^1.6.0", + "stream-chat": "^8.40.1", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^9.0.1", @@ -141,10 +141,10 @@ "@semantic-release/exec": "^6.0.2", "@semantic-release/git": "^10.0.1", "@types/jasmine": "~3.8.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", "@types/starwars-names": "^1.6.2", "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "copyfiles": "^2.4.1", "eslint": "^8.28.0", "eslint-config-prettier": "^8.3.0", @@ -161,7 +161,7 @@ "karma-jasmine-html-reporter": "~1.7.0", "lint-staged": "^11.1.2", "ng-packagr": "^15.2.2", - "prettier": "^2.4.0", + "prettier": "^2.8.8", "prettier-eslint": "^13.0.0", "semantic-release": "^18.0.0", "typedoc": "^0.25.13", diff --git a/projects/customizations-example/src/app/app.component.html b/projects/customizations-example/src/app/app.component.html index 799f7261..bbd6eab2 100644 --- a/projects/customizations-example/src/app/app.component.html +++ b/projects/customizations-example/src/app/app.component.html @@ -163,14 +163,12 @@ diff --git a/projects/sample-app/src/app/custom-message/custom-message.component.html b/projects/sample-app/src/app/custom-message/custom-message.component.html index a8ead17c..aff3b035 100644 --- a/projects/sample-app/src/app/custom-message/custom-message.component.html +++ b/projects/sample-app/src/app/custom-message/custom-message.component.html @@ -2,6 +2,6 @@
{{ message?.text }}
{{ message?.user?.name || message?.user?.id }} | - {{ message?.created_at | date: "long" }} + {{ message?.created_at | date : "long" }}
diff --git a/projects/stream-chat-angular/.eslintrc.json b/projects/stream-chat-angular/.eslintrc.json index 5d0527c9..87164cc5 100644 --- a/projects/stream-chat-angular/.eslintrc.json +++ b/projects/stream-chat-angular/.eslintrc.json @@ -54,7 +54,8 @@ "rules": { "@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-member-access": "off" + "@typescript-eslint/no-unsafe-member-access": "off", + "@angular-eslint/component-max-inline-declarations": "off" } }, { diff --git a/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.html b/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.html index a467b1e1..984f9015 100644 --- a/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.html +++ b/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.html @@ -256,10 +256,7 @@ >
{{ getEmojiByReaction(selectedReactionType!) }}
- - -
+ - - {{ user.name }} -
-
+ [users]="usersByReactions[reactionType]?.users || []" + [isLoading]="isLoading" + [hasMore]="!!usersByReactions[reactionType]?.next || false" + (loadMore)="loadNextPageOfReactions()" + > +
diff --git a/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.spec.ts b/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.spec.ts index 5f7c0117..7cb40e37 100644 --- a/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.spec.ts @@ -4,16 +4,19 @@ import { TestBed, tick, } from '@angular/core/testing'; -import { ReactionResponse } from 'stream-chat'; +import { QueryReactionsAPIResponse, ReactionResponse } from 'stream-chat'; import { AvatarComponent } from '../avatar/avatar.component'; import { MessageReactionsComponent } from './message-reactions.component'; -import { ChannelService } from '../channel.service'; import { SimpleChange } from '@angular/core'; import { AvatarPlaceholderComponent } from '../avatar-placeholder/avatar-placeholder.component'; import { MessageReactionsService } from '../message-reactions.service'; import { ModalComponent } from '../modal/modal.component'; import { BehaviorSubject } from 'rxjs'; +import { UserListComponent } from '../user-list/user-list.component'; +import { PaginatedListComponent } from '../paginated-list/paginated-list.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; describe('MessageReactionsComponent', () => { let component: MessageReactionsComponent; @@ -21,14 +24,15 @@ describe('MessageReactionsComponent', () => { let nativeElement: HTMLElement; let queryReactionList: () => HTMLElement | null; let queryEmojis: () => HTMLElement[]; - let queryReactionsCount: () => HTMLElement | null; let queryReactionCountsFromReactionList: () => HTMLElement[]; - const channelServiceMock = { - getMessageReactions: () => Promise.resolve([] as ReactionResponse[]), - }; const reactionsServiceMock = { reactions$: new BehaviorSubject({}), reactions: {}, + queryReactions: (_: string, __: string, ___?: string) => + Promise.resolve({ + reactions: [], + next: undefined, + } as unknown as QueryReactionsAPIResponse), }; beforeEach(async () => { @@ -49,11 +53,13 @@ describe('MessageReactionsComponent', () => { AvatarComponent, AvatarPlaceholderComponent, ModalComponent, + UserListComponent, + PaginatedListComponent, ], providers: [ - { provide: ChannelService, useValue: channelServiceMock }, { provide: MessageReactionsService, useValue: reactionsServiceMock }, ], + imports: [TranslateModule.forRoot()], }).compileComponents(); }); @@ -68,8 +74,6 @@ describe('MessageReactionsComponent', () => { nativeElement.querySelector('[data-testid="reaction-list"]'); queryEmojis = () => Array.from(nativeElement.querySelectorAll('[data-testclass="emoji"]')); - queryReactionsCount = () => - nativeElement.querySelector('[data-testid="reactions-count"]'); fixture.detectChanges(); queryReactionCountsFromReactionList = () => Array.from( @@ -79,7 +83,7 @@ describe('MessageReactionsComponent', () => { ); }); - it('should display message reactions', () => { + it('should display message reactions - deprecated', () => { component.messageReactionCounts = { angry: 1, haha: 2, @@ -97,32 +101,16 @@ describe('MessageReactionsComponent', () => { expect(reactionCounts[2].textContent).toContain('1'); }); - it('should display total count', () => { - component.messageReactionCounts = { - haha: 1, - love: 2, - wow: 1, - sad: 3, - }; - component.ngOnChanges({ - messageReactionCounts: {} as SimpleChange, - }); - fixture.detectChanges(); - - expect(queryReactionsCount()?.textContent?.replace(/ /g, '')).toBe('7'); - }); - it(`shouldn't display bubble, if there are no reactions`, () => { expect(queryReactionList()).toBeNull(); }); - it('should display reaction details', fakeAsync(() => { + it('should display reaction details - deprecated', fakeAsync(() => { component.messageReactionCounts = { wow: 3, sad: 2, }; component.messageId = 'id'; - component.latestReactions = []; component.ngOnChanges({ messageReactionCounts: {} as SimpleChange }); fixture.detectChanges(); @@ -131,10 +119,11 @@ describe('MessageReactionsComponent', () => { { type: 'wow', user: { id: 'benid', name: 'Ben' } }, { type: 'wow', user: { id: 'jackid' } }, { type: 'wow' }, - { type: 'sad', user: { id: 'jim' } }, - { type: 'sad', user: { id: 'ben', name: 'Ben' } }, ] as ReactionResponse[]; - spyOn(channelServiceMock, 'getMessageReactions').and.resolveTo(reactions); + const spy = spyOn(reactionsServiceMock, 'queryReactions').and.resolveTo({ + reactions, + next: undefined, + } as unknown as QueryReactionsAPIResponse); const wowEmoji = queryEmojis()[0]; wowEmoji.click(); @@ -152,36 +141,60 @@ describe('MessageReactionsComponent', () => { tick(); fixture.detectChanges(); - let users = nativeElement.querySelectorAll( - '[data-testclass="reaction-user-username"]' - ); + let userListComponent = fixture.debugElement.query( + By.css('[data-testid="wow-user-list"]') + ).componentInstance as UserListComponent; - expect(users.length).toBe(3); - expect(users[0].textContent).toBe('Ben'); - expect(users[1].textContent).toBe('Sara'); - expect(users[2].textContent).toBe(''); + expect(userListComponent.users.length).toBe(3); + spy.and.resolveTo({ + reactions: [ + { type: 'sad', user: { id: 'jim' } }, + { type: 'sad', user: { id: 'ben', name: 'Ben' } }, + ], + next: undefined, + } as unknown as QueryReactionsAPIResponse); nativeElement .querySelector( '[data-testid="reaction-details-selector-sad"]' ) ?.click(); + tick(); fixture.detectChanges(); - users = nativeElement.querySelectorAll( - '[data-testclass="reaction-user-username"]' - ); + userListComponent = fixture.debugElement.query( + By.css('[data-testid="sad-user-list"]') + ).componentInstance as UserListComponent; - expect(users.length).toBe(2); + expect(userListComponent.users.length).toBe(2); })); - it(`shouldn't display reaction details if there are more than 1200 reactions`, () => { - component.messageReactionCounts = { - wow: 3, - sad: 1198, + it('should query reactions with proper parameters', async () => { + component.messageId = 'my-message'; + const spy = spyOn(reactionsServiceMock, 'queryReactions').and.resolveTo({ + reactions: [], + next: 'next-page', + } as unknown as QueryReactionsAPIResponse); + + await component.reactionSelected('wow'); + + expect(spy).toHaveBeenCalledWith('my-message', 'wow', undefined); + + spy.calls.reset(); + + await component.loadNextPageOfReactions(); + + expect(spy).toHaveBeenCalledWith('my-message', 'wow', 'next-page'); + }); + + it('should bind pagination params to user list', fakeAsync(() => { + component.messageId = 'messageId'; + component.messageReactionGroups = { + wow: { + count: 4, + sum_scores: 4, + }, }; - component.messageId = 'id'; - component.latestReactions = []; - component.ngOnChanges({ messageReactionCounts: {} as SimpleChange }); + component.ngOnChanges({ messageReactionGroups: {} as SimpleChange }); fixture.detectChanges(); const reactions = [ @@ -189,16 +202,34 @@ describe('MessageReactionsComponent', () => { { type: 'wow', user: { id: 'benid', name: 'Ben' } }, { type: 'wow', user: { id: 'jackid' } }, { type: 'wow' }, - { type: 'sad', user: { id: 'jim' } }, - { type: 'sad', user: { id: 'ben', name: 'Ben' } }, ] as ReactionResponse[]; - spyOn(channelServiceMock, 'getMessageReactions').and.resolveTo(reactions); + spyOn(reactionsServiceMock, 'queryReactions').and.resolveTo({ + reactions: reactions, + next: 'next-page', + } as unknown as QueryReactionsAPIResponse); - const wowEmoji = queryEmojis()[0]; - wowEmoji.click(); + void component.reactionSelected('wow'); + fixture.detectChanges(); - expect(component.selectedReactionType).toBe(undefined); - }); + const userListComponent = fixture.debugElement.query( + By.css('[data-testid="wow-user-list"]') + ).componentInstance as UserListComponent; + + expect(userListComponent.isLoading).toBeTrue(); + expect(userListComponent.hasMore).toBeFalse(); + expect(userListComponent.users).toEqual([]); + + tick(); + fixture.detectChanges(); + + expect(userListComponent.isLoading).toBeFalse(); + expect(userListComponent.hasMore).toBeTrue(); + expect(userListComponent.users).toEqual([ + { id: 'saraid', name: 'Sara' }, + { id: 'benid', name: 'Ben' }, + { id: 'jackid' }, + ]); + })); it(`should call custom reaction details handler if that's provided`, () => { const messageReactionsService = TestBed.inject(MessageReactionsService); @@ -209,7 +240,6 @@ describe('MessageReactionsComponent', () => { sad: 2, }; component.messageId = 'id'; - component.latestReactions = []; component.ngOnChanges({ messageReactionCounts: {} as SimpleChange }); fixture.detectChanges(); @@ -228,15 +258,13 @@ describe('MessageReactionsComponent', () => { sad: 2, }; component.messageId = 'id'; - component.latestReactions = []; component.ngOnChanges({ messageId: {} as SimpleChange, messageReactionCounts: {} as SimpleChange, - latestReactions: {} as SimpleChange, }); fixture.detectChanges(); - spyOn(channelServiceMock, 'getMessageReactions').and.rejectWith( + spyOn(reactionsServiceMock, 'queryReactions').and.rejectWith( new Error('Failed to get reactions') ); @@ -252,13 +280,6 @@ describe('MessageReactionsComponent', () => { wow: 3, sad: 2, }; - component.latestReactions = [ - { type: 'wow', user: { id: 'saraid', name: 'Sara' } }, - { type: 'wow', user: { id: 'jackid' } }, - { type: 'wow' }, - { type: 'sad', user: { id: 'jim' } }, - { type: 'sad', user: { id: 'ben', name: 'Ben' } }, - ] as ReactionResponse[]; component.ownReactions = [ { type: 'wow', user: { id: 'jackid' } }, ] as ReactionResponse[]; @@ -293,4 +314,129 @@ describe('MessageReactionsComponent', () => { expect(reactionCounts[0].textContent).toContain('1'); expect(reactionCounts[1].textContent).toContain('2'); }); + + it('should display and order message reactions', () => { + component.messageReactionGroups = { + love: { + count: 12, + sum_scores: 12, + first_reaction_at: '2024-09-05T13:17:05.138248Z', + last_reaction_at: '2024-09-05T13:17:11.454912Z', + }, + sad: { + count: 10, + sum_scores: 10, + first_reaction_at: '2024-09-05T13:17:05.673605Z', + last_reaction_at: '2024-09-05T13:17:13.211086Z', + }, + wow: { + count: 20, + sum_scores: 20, + first_reaction_at: '2024-09-05T13:17:05.059252Z', + last_reaction_at: '2024-09-05T13:17:13.066055Z', + }, + haha: { + count: 9, + sum_scores: 9, + first_reaction_at: '2024-09-05T13:17:05.522053Z', + last_reaction_at: '2024-09-05T13:17:10.87445Z', + }, + like: { + count: 7, + sum_scores: 7, + first_reaction_at: '2024-09-05T13:17:04.977203Z', + last_reaction_at: '2024-09-05T13:17:14.856949Z', + }, + }; + component.ngOnChanges({ + messageReactionGroups: {} as SimpleChange, + }); + fixture.detectChanges(); + const reactionCounts = queryReactionCountsFromReactionList(); + + expect(component.existingReactions).toEqual([ + 'like', + 'wow', + 'love', + 'haha', + 'sad', + ]); + + expect(queryEmojis().length).toBe(5); + expect(reactionCounts[0].textContent).toContain('7'); + expect(reactionCounts[1].textContent).toContain('20'); + expect(reactionCounts[2].textContent).toContain('12'); + expect(reactionCounts[3].textContent).toContain('9'); + expect(reactionCounts[4].textContent).toContain('10'); + + component.messageReactionGroups = { + ...component.messageReactionGroups, + sad: { + count: 10, + sum_scores: 10, + first_reaction_at: '2024-09-05T12:17:05.673605Z', + last_reaction_at: '2024-09-05T13:17:13.211086Z', + }, + }; + component.ngOnChanges({ messageReactionGroups: {} as SimpleChange }); + + expect(component.existingReactions).toEqual([ + 'sad', + 'like', + 'wow', + 'love', + 'haha', + ]); + }); + + it('#messageReactionGroups should have a higher priority than #messageReactionCounts', () => { + component.messageReactionGroups = { + love: { + count: 12, + sum_scores: 12, + first_reaction_at: '2024-09-05T13:17:05.138248Z', + last_reaction_at: '2024-09-05T13:17:11.454912Z', + }, + sad: { + count: 10, + sum_scores: 10, + first_reaction_at: '2024-09-05T13:17:05.673605Z', + last_reaction_at: '2024-09-05T13:17:13.211086Z', + }, + wow: { + count: 20, + sum_scores: 20, + first_reaction_at: '2024-09-05T13:17:05.059252Z', + last_reaction_at: '2024-09-05T13:17:13.066055Z', + }, + haha: { + count: 9, + sum_scores: 9, + first_reaction_at: '2024-09-05T13:17:05.522053Z', + last_reaction_at: '2024-09-05T13:17:10.87445Z', + }, + like: { + count: 7, + sum_scores: 7, + first_reaction_at: '2024-09-05T13:17:04.977203Z', + last_reaction_at: '2024-09-05T13:17:14.856949Z', + }, + }; + component.messageReactionCounts = { + love: 13, + haha: 9, + }; + component.ngOnChanges({ + messageReactionGroups: {} as SimpleChange, + messageReactionCounts: {} as SimpleChange, + }); + + expect(component.existingReactions).toEqual([ + 'like', + 'wow', + 'love', + 'haha', + 'sad', + ]); + }); }); diff --git a/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.ts b/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.ts index b632c9ac..73f3d037 100644 --- a/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.ts +++ b/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.ts @@ -10,8 +10,11 @@ import { SimpleChanges, ViewChild, } from '@angular/core'; -import { ReactionResponse, UserResponse } from 'stream-chat'; -import { ChannelService } from '../channel.service'; +import { + ReactionGroupResponse, + ReactionResponse, + UserResponse, +} from 'stream-chat'; import { MessageReactionType, DefaultStreamChatGenerics } from '../types'; import { MessageReactionsService } from '../message-reactions.service'; import { CustomTemplatesService } from '../custom-templates.service'; @@ -34,10 +37,18 @@ export class MessageReactionsComponent /** * The number of reactions grouped by [reaction types](https://github.com/GetStream/stream-chat-angular/tree/master/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.ts) */ + @Input() messageReactionGroups: + | { [key: string]: ReactionGroupResponse } + | undefined = undefined; + /** + * The number of reactions grouped by [reaction types](https://github.com/GetStream/stream-chat-angular/tree/master/projects/stream-chat-angular/src/lib/message-reactions/message-reactions.component.ts) + * @deprecated use `messageReactionGroups` + */ @Input() messageReactionCounts: { [key in MessageReactionType]?: number } = {}; /** * List of reactions of a [message](../types/stream-message.mdx), used to display the users of a reaction type. + * @deprecated you can fetch the reactions using [`messageReactionsService.queryReactions()`](https://getstream.io/chat/docs/sdk/angular/services/MessageReactionsService/#queryreactions) */ @Input() latestReactions: ReactionResponse[] = []; /** @@ -52,14 +63,15 @@ export class MessageReactionsComponent reactions: ReactionResponse[] = []; shouldHandleReactionClick = true; existingReactions: string[] = []; - reactionsCount: number = 0; reactionOptions: string[] = []; + usersByReactions: { + [key: string]: { users: UserResponse[]; next?: string }; + } = {}; private subscriptions: Subscription[] = []; private isViewInited = false; constructor( private cdRef: ChangeDetectorRef, - private channelService: ChannelService, private messageReactionsService: MessageReactionsService, public customTemplatesService: CustomTemplatesService ) {} @@ -77,18 +89,20 @@ export class MessageReactionsComponent } ngOnChanges(changes: SimpleChanges) { - if (changes.messageReactionCounts) { + if (changes.messageReactionCounts || changes.messageReactionGroups) { + if (this.messageReactionCounts && !this.messageReactionGroups) { + this.messageReactionGroups = {}; + Object.keys(this.messageReactionCounts).forEach((k) => { + this.messageReactionGroups![k] = { + count: this.messageReactionCounts?.[k] ?? 0, + sum_scores: this.messageReactionCounts?.[k] ?? 0, + first_reaction_at: undefined, + last_reaction_at: undefined, + }; + }); + } this.setExistingReactions(); } - if (changes.messageReactionCounts && this.messageReactionCounts) { - const reactionsCount = Object.keys(this.messageReactionCounts).reduce( - (acc, key) => acc + (this.messageReactionCounts[key] || 0), - 0 - ); - this.shouldHandleReactionClick = - reactionsCount <= ChannelService.MAX_MESSAGE_REACTIONS_TO_FETCH || - !!this.messageReactionsService.customReactionClickHandler; - } } ngAfterViewInit(): void { @@ -103,10 +117,7 @@ export class MessageReactionsComponent return this.messageReactionsService.reactions[reactionType]; } - reactionSelected(reactionType: string) { - if (!this.shouldHandleReactionClick) { - return; - } + async reactionSelected(reactionType: string) { if (!this.messageId) { return; } @@ -117,96 +128,72 @@ export class MessageReactionsComponent }); } else { this.selectedReactionType = reactionType; - void this.fetchAllReactions(); + if (!this.usersByReactions[this.selectedReactionType]) { + this.usersByReactions[this.selectedReactionType] = { + users: [], + }; + await this.loadNextPageOfReactions(); + } } } - getUsersByReaction(reactionType: MessageReactionType) { - return this.latestReactions - .filter((r) => r.type === reactionType) - .map((r) => r.user?.name || r.user?.id) - .filter((i) => !!i) - .join(', '); - } - - getAllUsersByReaction( - reactionType?: MessageReactionType - ): UserResponse[] { - if (!reactionType) { - return []; + async loadNextPageOfReactions() { + if (!this.messageId || !this.selectedReactionType) { + return; } - const users = this.reactions - .filter((r) => r.type === reactionType) - .map((r) => r.user) - .filter((i) => !!i) as UserResponse[]; - - users.sort((u1, u2) => { - const name1 = u1.name?.toLowerCase(); - const name2 = u2.name?.toLowerCase(); - - if (!name1) { - return 1; - } - - if (!name2) { - return -1; - } - - if (name1 === name2) { - return 0; - } - - if (name1 < name2) { - return -1; - } else { - return 1; - } - }); - - return users; + this.isLoading = true; + try { + const response = await this.messageReactionsService.queryReactions( + this.messageId, + this.selectedReactionType, + this.usersByReactions[this.selectedReactionType].next + ); + this.usersByReactions[this.selectedReactionType].users = [ + ...this.usersByReactions[this.selectedReactionType].users, + ...(response.reactions + .map((r) => r.user) + .filter((u) => !!u) as UserResponse[]), + ]; + this.usersByReactions[this.selectedReactionType].next = response.next; + } catch (_) { + this.selectedReactionType = undefined; + } finally { + this.isLoading = false; + } + if (this.isViewInited) { + this.cdRef.detectChanges(); + } } trackByMessageReaction(_: number, item: MessageReactionType) { return item; } - trackByUserId(_: number, item: UserResponse) { - return item.id; - } - isOwnReaction(reactionType: MessageReactionType) { return !!this.ownReactions.find((r) => r.type === reactionType); } isOpenChange = (isOpen: boolean) => { this.selectedReactionType = isOpen ? this.selectedReactionType : undefined; - }; - - private async fetchAllReactions() { - if (!this.messageId) { - return; + if (!isOpen) { + this.usersByReactions = {}; } - this.isLoading = true; - try { - this.reactions = await this.channelService.getMessageReactions( - this.messageId - ); - } catch (error) { - this.selectedReactionType = undefined; - } finally { - this.isLoading = false; - this.cdRef.detectChanges(); - } - } + }; private setExistingReactions() { - this.existingReactions = Object.keys(this.messageReactionCounts) + this.existingReactions = Object.keys(this.messageReactionGroups ?? {}) .filter((k) => this.reactionOptions.indexOf(k) !== -1) - .filter((k) => this.messageReactionCounts[k]! > 0); - this.reactionsCount = this.existingReactions.reduce( - (total, reaction) => total + this.messageReactionCounts[reaction]!, - 0 - ); + .filter((k) => this.messageReactionGroups![k].count > 0) + .sort((r1, r2) => { + const date1 = this.messageReactionGroups![r1].first_reaction_at + ? new Date(this.messageReactionGroups![r1].first_reaction_at!) + : new Date(); + const date2 = this.messageReactionGroups![r2].first_reaction_at + ? new Date(this.messageReactionGroups![r2].first_reaction_at!) + : new Date(); + + return date1.getTime() - date2.getTime(); + }); } } diff --git a/projects/stream-chat-angular/src/lib/message/message.component.html b/projects/stream-chat-angular/src/lib/message/message.component.html index 4f03a47f..29311616 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.html +++ b/projects/stream-chat-angular/src/lib/message/message.component.html @@ -41,11 +41,7 @@
{{ "streamChat.Error ยท Unsent" | translate @@ -193,10 +186,7 @@
{{ (message?.errorStatusCode === 403 @@ -400,11 +390,7 @@
diff --git a/projects/stream-chat-angular/src/lib/paginated-list/paginated-list.component.spec.ts b/projects/stream-chat-angular/src/lib/paginated-list/paginated-list.component.spec.ts new file mode 100644 index 00000000..8debb47d --- /dev/null +++ b/projects/stream-chat-angular/src/lib/paginated-list/paginated-list.component.spec.ts @@ -0,0 +1,179 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { PaginatedListComponent } from './paginated-list.component'; +import { Component } from '@angular/core'; + +describe('PaginatedListComponentt', () => { + let hostComponent: TestHostComponent; + let hostFixture: ComponentFixture; + + @Component({ + selector: 'stream-test-componetn', + template: ` +
+ {{ index }}. {{ item }} +
+
`, + }) + class TestHostComponent { + items = ['apple', 'banana', 'orange']; + hasMore = false; + isLoading = false; + height = '20px'; + + loadMore = () => {}; + + trackBy = (_: number, item: string) => item; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [PaginatedListComponent, TestHostComponent], + imports: [TranslateModule.forRoot()], + }).compileComponents(); + + hostFixture = TestBed.createComponent(TestHostComponent); + hostComponent = hostFixture.componentInstance; + hostFixture.detectChanges(); + }); + + it('should display items', () => { + hostFixture.detectChanges(); + + expect( + hostFixture.nativeElement.querySelectorAll('[data-testid="item"]').length + ).toBe(3); + + const testItems = hostFixture.nativeElement.querySelectorAll( + '[data-testid="test-item"]' + ); + + hostComponent.items.forEach((item, index) => { + expect(testItems[index].textContent).toContain(`${index}. ${item}`); + }); + }); + + describe('load more button', () => { + const queryLoadMore = () => + hostFixture.nativeElement.querySelector( + '[data-testid="load-more-button"]' + ) as HTMLButtonElement; + + it('should display load more button if #hasMore is true', () => { + expect(queryLoadMore()).toBeNull(); + + hostComponent.hasMore = true; + hostFixture.detectChanges(); + + expect(queryLoadMore()).not.toBeNull(); + }); + + it('should disable load more button if loading is in progress', () => { + hostComponent.hasMore = true; + hostComponent.isLoading = true; + hostFixture.detectChanges(); + + expect(queryLoadMore().disabled).toBeTrue(); + + hostComponent.isLoading = false; + hostFixture.detectChanges(); + + expect(queryLoadMore().disabled).toBeFalse(); + }); + + it('should ask for more data if load more is clicked', () => { + const spy = jasmine.createSpy(); + hostComponent.loadMore = spy; + hostComponent.hasMore = true; + hostFixture.detectChanges(); + + queryLoadMore().click(); + hostFixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + it('should display loading indicator', () => { + hostComponent.isLoading = true; + hostFixture.detectChanges(); + + expect( + hostFixture.nativeElement.querySelector( + '[data-testid="loading-indicator"]' + ) + ).not.toBeNull(); + + hostComponent.isLoading = false; + hostFixture.detectChanges(); + + expect( + hostFixture.nativeElement.querySelector( + '[data-testid="loading-indicator"]' + ) + ).toBeNull(); + }); + + describe('infinite scrolling', () => { + let scrollContainer: HTMLElement; + + beforeEach(() => { + hostComponent.height = '200px'; + scrollContainer = hostFixture.nativeElement.querySelector( + '.stream-chat__paginated-list' + ) as HTMLElement; + scrollContainer.style.height = '500px'; + scrollContainer.style.overflowY = 'auto'; + hostFixture.detectChanges(); + }); + + it(`shouldn't display load more button if scrollbar is visible`, () => { + expect( + hostFixture.nativeElement.querySelector( + '[data-testid="load-more-button"]' + ) as HTMLButtonElement + ).toBeNull(); + }); + + it('should load next page if user scrolls to bottom', () => { + const spy = jasmine.createSpy(); + hostComponent.loadMore = spy; + hostComponent.hasMore = true; + hostFixture.detectChanges(); + + expect(spy).not.toHaveBeenCalledWith(); + + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight - scrollContainer.clientHeight, + }); + scrollContainer.dispatchEvent(new Event('scroll')); + hostFixture.detectChanges(); + + expect(spy).toHaveBeenCalledOnceWith(); + }); + + it(`shouldn't load next page if loading is already in progress`, () => { + const spy = jasmine.createSpy(); + hostComponent.loadMore = spy; + hostComponent.hasMore = true; + hostComponent.isLoading = true; + hostFixture.detectChanges(); + + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight - scrollContainer.clientHeight, + }); + scrollContainer.dispatchEvent(new Event('scroll')); + hostFixture.detectChanges(); + + expect(spy).not.toHaveBeenCalledWith(); + }); + }); +}); diff --git a/projects/stream-chat-angular/src/lib/paginated-list/paginated-list.component.ts b/projects/stream-chat-angular/src/lib/paginated-list/paginated-list.component.ts new file mode 100644 index 00000000..ad6397b5 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/paginated-list/paginated-list.component.ts @@ -0,0 +1,95 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ContentChild, + ElementRef, + EventEmitter, + Input, + NgZone, + Output, + TemplateRef, + TrackByFunction, + ViewChild, +} from '@angular/core'; + +/** + * The `PaginatedListComponent` is a utility element that can display a list of any items. It uses infinite scrolls to load more elements. Providing the data to display, is the responsibility of the parent component. + */ +@Component({ + selector: 'stream-paginated-list', + templateUrl: './paginated-list.component.html', + styles: [], +}) +export class PaginatedListComponent implements AfterViewInit { + /** + * The items to display + */ + @Input() items: T[] = []; + /** + * If `true`, the loading indicator will be displayed + */ + @Input() isLoading = false; + /** + * If `false` the component won't ask for more data vua the `loadMore` output + */ + @Input() hasMore = false; + /** + * The `trackBy` to use with the `NgFor` directive + * @param i + * @returns the track by id + */ + @Input() trackBy: TrackByFunction = (i) => i; + @ContentChild(TemplateRef) itemTempalteRef: TemplateRef | undefined; + /** + * The component will signal via this output when more items should be fetched + * + * The new items should be appended to the `items` array + */ + @Output() readonly loadMore = new EventEmitter(); + isScrollable = false; + isAtBottom = false; + @ViewChild('container') + private scrollContainer!: ElementRef; + + constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {} + + ngAfterViewInit(): void { + this.ngZone.runOutsideAngular(() => { + this.scrollContainer?.nativeElement?.addEventListener('scroll', () => + this.scrolled() + ); + }); + } + + private scrolled() { + if (!this.hasMore) { + return; + } + + const isScrollable = + this.scrollContainer.nativeElement.scrollHeight > + this.scrollContainer.nativeElement.clientHeight; + + if (this.isScrollable !== isScrollable) { + this.ngZone.run(() => { + this.isScrollable = isScrollable; + this.cdRef.detectChanges(); + }); + } + const isAtBottom = + Math.ceil(this.scrollContainer.nativeElement.scrollTop) + + this.scrollContainer.nativeElement.clientHeight + + 1 >= + this.scrollContainer.nativeElement.scrollHeight; + if (this.isAtBottom !== isAtBottom) { + this.ngZone.run(() => { + this.isAtBottom = isAtBottom; + if (this.isAtBottom && !this.isLoading) { + this.loadMore.emit(); + } + this.cdRef.detectChanges(); + }); + } + } +} diff --git a/projects/stream-chat-angular/src/lib/stream-chat.module.ts b/projects/stream-chat-angular/src/lib/stream-chat.module.ts index 80fea677..4522acd5 100644 --- a/projects/stream-chat-angular/src/lib/stream-chat.module.ts +++ b/projects/stream-chat-angular/src/lib/stream-chat.module.ts @@ -27,6 +27,8 @@ import { VoiceRecordingWavebarComponent } from './voice-recording/voice-recordin import { NgxFloatUiModule } from 'ngx-float-ui'; import { TranslateModule } from '@ngx-translate/core'; import { MessageReactionsSelectorComponent } from './message-reactions-selector/message-reactions-selector.component'; +import { PaginatedListComponent } from './paginated-list/paginated-list.component'; +import { UserListComponent } from './user-list/user-list.component'; @NgModule({ declarations: [ @@ -54,6 +56,8 @@ import { MessageReactionsSelectorComponent } from './message-reactions-selector/ VoiceRecordingComponent, VoiceRecordingWavebarComponent, MessageReactionsSelectorComponent, + UserListComponent, + PaginatedListComponent, ], imports: [ CommonModule, @@ -86,6 +90,8 @@ import { MessageReactionsSelectorComponent } from './message-reactions-selector/ VoiceRecordingComponent, VoiceRecordingWavebarComponent, MessageReactionsSelectorComponent, + UserListComponent, + PaginatedListComponent, ], }) export class StreamChatModule {} diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts index c88d16b9..ec51c9fb 100644 --- a/projects/stream-chat-angular/src/lib/types.ts +++ b/projects/stream-chat-angular/src/lib/types.ts @@ -12,6 +12,7 @@ import type { LiteralStringForUnion, MessageResponseBase, Mute, + ReactionGroupResponse, ReactionResponse, User, UserResponse, @@ -202,6 +203,9 @@ export type AvatarLocation = | 'quoted-message-sender' | 'autocomplete-item' | 'typing-indicator' + /** + * @deprecated this will be renamed to user-list in the next major release + */ | 'reaction'; export type AvatarContext = { @@ -306,9 +310,14 @@ export type MessageReactionsSelectorContext = { export type MessageReactionsContext = { messageId: string | undefined; + /** @deprecated use `messageReactionGroups` */ messageReactionCounts: { [key in MessageReactionType]?: number }; + /** @deprecated you can fetch the reactions using [`chatService.chatClient.queryReactions()`](https://getstream.io/chat/docs/javascript/send_reaction/?language=javascript&q=queryReactions#query-reactions) */ latestReactions: ReactionResponse[]; ownReactions: ReactionResponse[]; + messageReactionGroups: { + [key in MessageReactionType]: ReactionGroupResponse; + }; }; export type ModalContext = { diff --git a/projects/stream-chat-angular/src/lib/user-list/user-list.component.html b/projects/stream-chat-angular/src/lib/user-list/user-list.component.html new file mode 100644 index 00000000..77671064 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/user-list/user-list.component.html @@ -0,0 +1,24 @@ + + +
+ + {{ + user.name + }} +
+
+
diff --git a/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts b/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts new file mode 100644 index 00000000..75372af1 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserListComponent } from './user-list.component'; +import { PaginatedListComponent } from '../paginated-list/paginated-list.component'; +import { AvatarComponent } from '../avatar/avatar.component'; +import { By } from '@angular/platform-browser'; +import { StreamAvatarModule } from '../stream-avatar.module'; +import { UserResponse } from 'stream-chat'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('UserListComponent', () => { + let component: UserListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StreamAvatarModule, TranslateModule.forRoot()], + declarations: [UserListComponent, PaginatedListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(UserListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display users', () => { + component.users = [ + { id: 'jane', name: 'Jane', image: 'link/to/image' }, + { id: 'jack', name: 'Jack', image: 'link/to/image2' }, + ]; + fixture.detectChanges(); + + const userNames = (fixture.nativeElement as HTMLElement).querySelectorAll( + '[data-testclass="username"]' + ); + + expect(userNames.length).toBe(component.users.length); + + Array.from(userNames).forEach((user, index) => { + expect(user.textContent).toContain(component.users[index].name); + }); + + const avatars = fixture.debugElement + .queryAll(By.directive(AvatarComponent)) + .map((c) => c.componentInstance as AvatarComponent); + + expect(avatars.length).toBe(component.users.length); + + avatars.forEach((avatar, index) => { + expect(avatar.name).toBe(component.users[index].name); + expect(avatar.imageUrl).toBe(component.users[index].image); + expect(avatar.type).toBe('user'); + expect(avatar.location).toBe('reaction'); + }); + }); + + it('should provide data to paginated list component', () => { + const users = [ + { id: 'jane', name: 'Jane', image: 'link/to/image' }, + { id: 'jack', name: 'Jack', image: 'link/to/image2' }, + ]; + component.users = users; + component.hasMore = true; + component.isLoading = true; + const spy = jasmine.createSpy(); + component.loadMore.subscribe(spy); + fixture.detectChanges(); + + const paginatedListComponent = fixture.debugElement.query( + By.directive(PaginatedListComponent) + ).componentInstance as PaginatedListComponent; + + expect(paginatedListComponent.items).toBe(users); + + expect(paginatedListComponent.hasMore).toBe(true); + + expect(paginatedListComponent.isLoading).toBe(true); + + expect(spy).toHaveBeenCalledTimes(0); + + paginatedListComponent.loadMore.next(); + + expect(spy).toHaveBeenCalledTimes(1); + + expect(paginatedListComponent.trackBy).toBe(component.trackByUserId); + }); +}); diff --git a/projects/stream-chat-angular/src/lib/user-list/user-list.component.ts b/projects/stream-chat-angular/src/lib/user-list/user-list.component.ts new file mode 100644 index 00000000..e174e717 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/user-list/user-list.component.ts @@ -0,0 +1,36 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { UserResponse } from 'stream-chat'; +import { DefaultStreamChatGenerics } from '../types'; + +/** + * The `UserListComponent` can display a list of Stream users with pagination + */ +@Component({ + selector: 'stream-user-list', + templateUrl: './user-list.component.html', + styles: [], +}) +export class UserListComponent { + /** + * The users to display + */ + @Input() users: UserResponse[] = []; + /** + * If `true`, the loading indicator will be displayed + */ + @Input() isLoading = false; + /** + * If `false` the component won't ask for more data vua the `loadMore` output + */ + @Input() hasMore = false; + /** + * The component will signal via this output when more items should be fetched + * + * The new items should be appended to the `items` array + */ + @Output() readonly loadMore = new EventEmitter(); + + trackByUserId(_: number, item: UserResponse) { + return item.id; + } +} diff --git a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html index 6e706af9..704ac58e 100644 --- a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html +++ b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html @@ -40,9 +40,7 @@
{{ @@ -52,9 +50,7 @@
- {{ audioElement?.playbackRate | number: "1.1-1" }}x + {{ audioElement?.playbackRate | number : "1.1-1" }}x