diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index f08c4e5f..d0df2e55 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -3,7 +3,7 @@ on: push: branches: - master - - feat/ngx-translate-v18-support + - feat/stream-chat-v10 pull_request: branches: - master diff --git a/README.md b/README.md index 4e3731b1..d015513d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Official Angular SDK for [Stream Chat](https://getstream.io/chat/sdk/react/) +# Official Angular SDK for [Stream Chat](https://getstream.io/chat/sdk/angular/) > The official Angular components for Stream Chat, a service for building chat applications. @@ -9,8 +9,9 @@ **Quick Links** - [Register](https://getstream.io/chat/trial/) to get an API key for Stream Chat -- [Angular Chat Tutorial](https://getstream.io/chat/angular/tutorial/) -- [Docs](https://getstream.io/chat/docs/sdk/angular/) +- [Angular Chat Tutorial](https://getstream.io/chat/sdk/angular/tutorial/) +- [Docs](https://getstream.io/chat/docs/sdk/angular/v8/) +- [Upgrade guide to v8](https://getstream.io/chat/docs/sdk/angular/v8/basics/upgrade-to-v8/) - [Chat UI Kit](https://getstream.io/chat/ui-kit/) - [Demo application](https://angular-chat-demo-getstreamio.vercel.app/) - [Codesandbox](https://codesandbox.io/p/devbox/stream-chat-angular-sample-app-rc3hyw) @@ -24,7 +25,7 @@ With our component library, you can build a variety of chat use cases, including ## Angular Chat Tutorial -The best way to get started is to follow the [Angular Chat Tutorial](https://getstream.io/chat/angular/tutorial/). It shows you how to use this SDK to build a fully functional chat application and includes common customizations. +The best way to get started is to follow the [Angular Chat Tutorial](https://getstream.io/chat/sdk/angular/tutorial/). It shows you how to use this SDK to build a fully functional chat application and includes common customizations. ## Free for Makers @@ -33,9 +34,9 @@ For complete pricing and details visit our [Chat Pricing Page](https://getstream ## Docs -The [docs](https://getstream.io/chat/docs/sdk/angular/) provide a brief description about the components and services in the library. +The [docs](https://getstream.io/chat/docs/sdk/angular/v8/) provide a brief description about the components and services in the library. -The Angular library is created using the [stream-chat-js](https://github.com/getstream/stream-chat-js) library. For the most common use cases our services should give a nice abstraction over this library, however you might need it for more advanced customization, the [documentation](https://getstream.io/chat/docs/js/) is on our website. +The Angular library is created using the [stream-chat-js](https://github.com/getstream/stream-chat-js) library. For the most common use cases our services should give a nice abstraction over this library, however you might need it for more advanced customization, the [documentation](https://getstream.io/chat/docs/javascript/) is on our website. ## Contributing @@ -52,6 +53,8 @@ Check out our current openings and apply via [Stream's website](https://getstrea ### Install with NPM +> v8 is currently a release candidate, published under the `rc` tag on npm. It is built on [`stream-chat`](https://github.com/getstream/stream-chat-js) v10, which is also published under the `rc` tag. If you are upgrading from v7, follow the [upgrade guide](https://getstream.io/chat/docs/sdk/angular/v8/basics/upgrade-to-v8/). + Supported Angular versions: **Angular 17-22** _Zoneless mode isn't yet supported_ @@ -61,7 +64,7 @@ _Zoneless mode isn't yet supported_ Run this command if you are using **Angular 22**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@21 --legacy-peer-deps +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@21 --legacy-peer-deps ``` > `ngx-float-ui` does not have an Angular 22 build yet, so install its latest (v21) with `--legacy-peer-deps` until an Angular 22 release is available. @@ -69,34 +72,34 @@ npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@21 Run this command if you are using **Angular 21**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@21 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@21 ``` Run this command if you are using **Angular 20**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@20 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@20 ``` Run this command if you are using **Angular 19**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@19 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@19 ``` Run this command if you are using **Angular 18**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@18 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@18 ``` Run this command if you are using **Angular 17**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core@17 ngx-float-ui@17 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core@17 ngx-float-ui@17 ``` -Supported node verisons: 18+ +Supported Node versions: 22.18+ ## Sample App @@ -112,7 +115,7 @@ STREAM_USER_ID= STREAM_USER_TOKEN= ``` -The easiest way to generate a token for testing purposes is to use our [token generator](https://getstream.io/chat/docs/react/token_generator/). +The easiest way to generate a token for testing purposes is to use our [token generator](https://getstream.io/chat/docs/javascript/tokens-and-authentication/). Run `npm install` to install dependencies. diff --git a/copy-generated-component-docs.ts b/copy-generated-component-docs.ts index 0b6f8e37..3135b660 100644 --- a/copy-generated-component-docs.ts +++ b/copy-generated-component-docs.ts @@ -1,4 +1,5 @@ const fs = require('fs'); +const { normalizeGeneratedDocs } = require('./normalize-generated-docs'); const generatedDocsPath = 'temp-component-docs/classes'; const componentDocsPath = process.argv[2]; @@ -32,9 +33,11 @@ const insertGeneratedParts = ( fileContent: string, generatedContent: string[] ) => { - generatedContent = generatedContent.map((l) => - l.replace(/•\s/g, '').replace(/▸\s/g, '') - ); + generatedContent = normalizeGeneratedDocs( + generatedContent + .map((l) => l.replace(/•\s/g, '').replace(/▸\s/g, '')) + .join('\n') + ).split('\n'); const lines: string[] = fileContent.split('\n'); const startOfGeneratedContentLineNumber = lines.indexOf( startOfGeneratedContentMark diff --git a/copy-generated-service-docs.ts b/copy-generated-service-docs.ts index f4006499..b417a442 100644 --- a/copy-generated-service-docs.ts +++ b/copy-generated-service-docs.ts @@ -1,4 +1,8 @@ const fs = require('fs'); +const { + normalizeGeneratedDocs, + servicesDocsBaseUrl, +} = require('./normalize-generated-docs'); const sourcePath = 'temp-service-docs/classes'; const serviceDocsTargetPath = process.argv[2]; @@ -30,26 +34,15 @@ fs.readdir(sourcePath, (err: any, files: string[]) => { throw err; } - // Remove the thre prefix from the title - const result = - `---` + - `\n` + - `title: ${file.replace('.md', '')}` + - `\n` + - `slug: /chat/docs/sdk/angular/services/${file.replace('.md', '')}/` + - `\n` + - `---` + - '\n\n' + + // Title and slug come from the sidebar config, so no frontmatter is added + const result = normalizeGeneratedDocs( data .replace(`# Class: ${file.replace('.md', '')}`, '') - .replace( - /\b(?!README)(\w+)\.md\b/g, - '/chat/docs/sdk/angular/services/$1' - ) + .replace(/\b(?!README)(\w+)\.md\b/g, `${servicesDocsBaseUrl}/$1/`) .replace(/•\s/g, '') .replace(/▸\s/g, '') .replace('', '') - .replace('\\', ''); + ); fs.writeFile(`${sourcePath}/${file}`, result, 'utf8', (err: any) => { if (err) { diff --git a/normalize-generated-docs.ts b/normalize-generated-docs.ts new file mode 100644 index 00000000..47cf6a2c --- /dev/null +++ b/normalize-generated-docs.ts @@ -0,0 +1,85 @@ +// typedoc-plugin-markdown splits generic types into multiple code spans with escaped angle brackets (`Promise`\<`void`\>) +// The docs site renders markdown as MDX, so these are merged into a single code span (`Promise`) +const typeGroupStart = /^(`\w+`\\?<|\\<|\\\{)/; + +const findEndOfTypeGroup = (line: string, start: number) => { + let depth = 0; + let i = start; + while (i < line.length) { + if (line.startsWith('=>', i)) { + i += 2; + continue; + } + if (line.startsWith('\\<', i) || line.startsWith('\\{', i)) { + depth++; + i += 2; + continue; + } + if (line[i] === '<' || line[i] === '{') { + depth++; + } else if ( + line.startsWith('\\>', i) || + line[i] === '>' || + line[i] === '}' + ) { + depth--; + const length = line[i] === '\\' ? 2 : 1; + if (depth === 0) { + return i + length; + } + i += length; + continue; + } + i += line[i] === '\\' ? 2 : 1; + } + return -1; +}; + +const normalizeGenericTypes = (line: string) => { + const isTableRow = line.trimStart().startsWith('|'); + let result = ''; + let i = 0; + while (i < line.length) { + const end = typeGroupStart.test(line.slice(i)) + ? findEndOfTypeGroup(line, i) + : -1; + if (end === -1) { + result += line[i]; + i++; + continue; + } + const type = line + .slice(i, end) + .replace(/`/g, '') + .replace(/\\([<>{}])/g, '$1') + .replace(/\\\|/g, isTableRow ? '\\|' : '|') + .replace(/ {2,}/g, ' '); + result += '`' + type + '`'; + i = end; + } + return ( + result + // Merge intersection types into a single code span + .replace(/`([^`]+)` & `([^`]+)`/g, '`$1 & $2`') + .replace(/\\_/g, '_') + ); +}; + +const docsBaseUrl = '/chat/docs/sdk/angular/v8'; + +// Links in the source code point to the unversioned docs, generated docs link to the docs version they belong to +const normalizeLinks = (line: string) => + line + .replace(/\]\(\/chat\/docs\/sdk\/angular\/(?!v\d)/g, `](${docsBaseUrl}/`) + // Heading anchors don't contain $ (for example #querystate$) + .replace(/(\]\([^)#\s]*#[\w-]+)\$\)/g, '$1)'); + +export const servicesDocsBaseUrl = `${docsBaseUrl}/services`; + +export const normalizeGeneratedDocs = (content: string) => + content + .split('\n') + // typedoc emits a lone backslash as a line break in some class descriptions + .filter((line) => line !== '\\') + .map((line) => normalizeLinks(normalizeGenericTypes(line))) + .join('\n'); diff --git a/package-lock.json b/package-lock.json index aa82624a..6ee438a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "replace": "^1.2.2", "rxjs": "~7.4.0", "starwars-names": "^1.6.0", - "stream-chat": "9.20.0", + "stream-chat": "10.0.0-rc.26", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^9.0.1", @@ -77,7 +77,7 @@ "typedoc": "^0.25.13", "typedoc-plugin-markdown": "^3.17.1", "typedoc-plugin-reference-excluder": "^1.1.3", - "typescript": "~5.2.0", + "typescript": "~5.4.5", "vercel": "^48.1.6", "wait-on": "^6.0.0", "webpack-bundle-analyzer": "^4.5.0" @@ -7109,6 +7109,30 @@ "@angular/core": ">=13.0.0" } }, + "node_modules/@stream-io/logger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@stream-io/logger/-/logger-2.0.0.tgz", + "integrity": "sha512-cH9Gi/lAeDjLWB2J9xhPtiNOyRNNO9M46uT7507wT3MCouZNdlXDu/vgiGVhu3I/Tg7sG4ePlG5aHefJ6xjgmw==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@stream-io/state-store": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@stream-io/state-store/-/state-store-1.1.6.tgz", + "integrity": "sha512-4jZ7vub9EBh+zrNsA8cyW/4U2EuiCkO3T9FgZrbgorO2iBE4k8gP9GAl6w8Ikv+ZcO5jR53CeG+AA48UG3LOVQ==", + "license": "SEE LICENSE IN LICENSE", + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "use-sync-external-store": "^1.5.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/@stream-io/stream-chat-css": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-5.14.0.tgz", @@ -7385,16 +7409,6 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", - "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7402,12 +7416,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "16.18.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz", @@ -7533,6 +7541,7 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -9591,7 +9600,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/autoprefixer": { "version": "10.4.18", @@ -9779,6 +9789,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -10030,12 +10041,6 @@ "node": "*" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -10521,6 +10526,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -11312,7 +11318,8 @@ "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -11600,15 +11607,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/edge-runtime": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", @@ -13134,15 +13132,16 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -13181,16 +13180,16 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -13713,6 +13712,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, @@ -13724,9 +13724,10 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -14630,15 +14631,6 @@ "node": ">=0.10.0" } }, - "node_modules/isomorphic-ws": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", - "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, "node_modules/issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -14948,49 +14940,6 @@ ], "license": "MIT" }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/karma": { "version": "6.3.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.4.tgz", @@ -15296,9 +15245,9 @@ "dev": true }, "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz", + "integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==", "license": "MIT" }, "node_modules/lint-staged": { @@ -15473,39 +15422,17 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -15513,12 +15440,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -20890,9 +20811,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/prr": { "version": "1.0.1", @@ -23323,54 +23248,56 @@ } }, "node_modules/stream-chat": { - "version": "9.20.0", - "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-9.20.0.tgz", - "integrity": "sha512-DGl3etjkG1/GxXbyUZQHPT6Z3bOwm5ppUNqrrWNYqRqf30UWqotmsRf8HwnilXtvZ8/p/ANZ11nHxbdy72jVZg==", + "version": "10.0.0-rc.26", + "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-10.0.0-rc.26.tgz", + "integrity": "sha512-jr0LHOPip3Vo+KShSWvlL9Dx2gauHyXr8FK3kXcB2fkObzBkaiDlFg8So20FelOz0ITi04wD1DKa5OpMlaXtFg==", + "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@types/jsonwebtoken": "^9.0.8", - "@types/ws": "^8.5.14", - "axios": "^1.6.0", - "base64-js": "^1.5.1", - "form-data": "^4.0.4", - "isomorphic-ws": "^5.0.0", - "jsonwebtoken": "^9.0.2", - "linkifyjs": "^4.3.2", - "ws": "^8.18.1" + "@stream-io/logger": "^2.0.0", + "@stream-io/state-store": "^1.1.6", + "axios": "^1.19.0", + "linkifyjs": "^4.3.3" }, "engines": { - "node": ">=18" + "node": ">=22.18.0" + } + }, + "node_modules/stream-chat/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" } }, "node_modules/stream-chat/node_modules/axios": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", - "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz", + "integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==", + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, - "node_modules/stream-chat/node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "node_modules/stream-chat/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "dependencies": { + "agent-base": "6", + "debug": "4" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "engines": { + "node": ">= 6" } }, "node_modules/stream-combiner2": { @@ -24393,9 +24320,9 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -25354,6 +25281,7 @@ "version": "7.4.6", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "dev": true, "engines": { "node": ">=8.3.0" }, @@ -29857,6 +29785,17 @@ "tslib": "^2.0.0" } }, + "@stream-io/logger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@stream-io/logger/-/logger-2.0.0.tgz", + "integrity": "sha512-cH9Gi/lAeDjLWB2J9xhPtiNOyRNNO9M46uT7507wT3MCouZNdlXDu/vgiGVhu3I/Tg7sG4ePlG5aHefJ6xjgmw==" + }, + "@stream-io/state-store": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@stream-io/state-store/-/state-store-1.1.6.tgz", + "integrity": "sha512-4jZ7vub9EBh+zrNsA8cyW/4U2EuiCkO3T9FgZrbgorO2iBE4k8gP9GAl6w8Ikv+ZcO5jR53CeG+AA48UG3LOVQ==", + "requires": {} + }, "@stream-io/stream-chat-css": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/@stream-io/stream-chat-css/-/stream-chat-css-5.14.0.tgz", @@ -30095,26 +30034,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "@types/jsonwebtoken": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", - "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", - "requires": { - "@types/ms": "*", - "@types/node": "*" - } - }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, - "@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" - }, "@types/node": { "version": "16.18.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz", @@ -30231,6 +30156,7 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", + "dev": true, "requires": { "@types/node": "*" } @@ -31571,7 +31497,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "autoprefixer": { "version": "10.4.18", @@ -31701,7 +31627,8 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true }, "base64id": { "version": "2.0.0", @@ -31872,11 +31799,6 @@ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" - }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -32799,7 +32721,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "depd": { "version": "2.0.0", @@ -33015,14 +32937,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, - "ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, "edge-runtime": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", @@ -34105,9 +34019,9 @@ "dev": true }, "follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==" }, "foreground-child": { "version": "3.1.1", @@ -34128,15 +34042,15 @@ } }, "form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" } }, "forwarded": { @@ -34527,9 +34441,9 @@ } }, "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "requires": { "function-bind": "^1.1.2" } @@ -35131,12 +35045,6 @@ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "dev": true }, - "isomorphic-ws": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", - "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", - "requires": {} - }, "issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -35374,42 +35282,6 @@ "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true }, - "jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "requires": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - } - }, - "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "requires": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "karma": { "version": "6.3.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.4.tgz", @@ -35640,9 +35512,9 @@ "dev": true }, "linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz", + "integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==" }, "lint-staged": { "version": "11.1.2", @@ -35783,35 +35655,17 @@ "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "dev": true }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" - }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true }, "lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true }, "lodash.merge": { "version": "4.6.2", @@ -35819,11 +35673,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" - }, "lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -39485,9 +39334,9 @@ } }, "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, "prr": { "version": "1.0.1", @@ -41206,36 +41055,43 @@ "dev": true }, "stream-chat": { - "version": "9.20.0", - "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-9.20.0.tgz", - "integrity": "sha512-DGl3etjkG1/GxXbyUZQHPT6Z3bOwm5ppUNqrrWNYqRqf30UWqotmsRf8HwnilXtvZ8/p/ANZ11nHxbdy72jVZg==", + "version": "10.0.0-rc.26", + "resolved": "https://registry.npmjs.org/stream-chat/-/stream-chat-10.0.0-rc.26.tgz", + "integrity": "sha512-jr0LHOPip3Vo+KShSWvlL9Dx2gauHyXr8FK3kXcB2fkObzBkaiDlFg8So20FelOz0ITi04wD1DKa5OpMlaXtFg==", "requires": { - "@types/jsonwebtoken": "^9.0.8", - "@types/ws": "^8.5.14", - "axios": "^1.6.0", - "base64-js": "^1.5.1", - "form-data": "^4.0.4", - "isomorphic-ws": "^5.0.0", - "jsonwebtoken": "^9.0.2", - "linkifyjs": "^4.3.2", - "ws": "^8.18.1" + "@stream-io/logger": "^2.0.0", + "@stream-io/state-store": "^1.1.6", + "axios": "^1.19.0", + "linkifyjs": "^4.3.3" }, "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, "axios": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", - "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz", + "integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==", "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, - "ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", - "requires": {} + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + } } } }, @@ -41958,9 +41814,9 @@ "requires": {} }, "typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==" + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==" }, "ua-parser-js": { "version": "0.7.28", @@ -42593,6 +42449,7 @@ "version": "7.4.6", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "dev": true, "requires": {} }, "xdg-app-paths": { diff --git a/package.json b/package.json index 07a138cc..e54308a3 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,10 @@ "preanalyze:sample-app": "npm run build:sample-app", "analyze:sample-app": "webpack-bundle-analyzer dist/sample-app/stats.json", "copy-css": "rm -rf projects/stream-chat-angular/src/assets/styles && copyfiles --up 5 \"node_modules/@stream-io/stream-chat-css/dist/v2/**/*\" projects/stream-chat-angular/src/assets/styles && copyfiles --up 5 \"node_modules/@stream-io/stream-chat-css/dist/assets/**/*\" projects/stream-chat-angular/src/assets/assets", - "generate-docs": "npm run typedoc:services && npm run typedoc:components && npm run copy-docs:v6", + "generate-docs": "npm run typedoc:services && npm run typedoc:components && npm run copy-docs:v8", "typedoc:services": "typedoc --plugin typedoc-plugin-markdown --plugin typedoc-plugin-reference-excluder --cleanOutputDir true --excludeConstructors true --hideBreadcrumbs true --hideInPageTOC true --excludePrivate true --out temp-service-docs --exclude '!**/*service.ts' --excludeNotDocumented --tsconfig projects/stream-chat-angular/tsconfig.lib.json projects/stream-chat-angular/src/public-api.ts", "typedoc:components": "typedoc --plugin typedoc-plugin-markdown --plugin typedoc-plugin-reference-excluder --cleanOutputDir true --excludeConstructors true --sort source-order --hideBreadcrumbs true --hideInPageTOC true --excludePrivate true --excludeNotDocumented --out temp-component-docs --exclude '!**/*component.ts' --tsconfig projects/stream-chat-angular/tsconfig.lib.json projects/stream-chat-angular/src/public-api.ts", - "copy-docs:v6": "ts-node copy-generated-service-docs.ts ../docs/data/docs/chat-sdk/angular/v7-latest/06-services & (ts-node remove-generated-component-docs-content ../docs/data/docs/chat-sdk/angular/v7-latest/05-components && ts-node copy-generated-component-docs.ts ../docs/data/docs/chat-sdk/angular/v7-latest/05-components)" + "copy-docs:v8": "ts-node copy-generated-service-docs.ts ../getstream.io/content/docs/chat-sdk/angular/v8-rc/06-services & (ts-node remove-generated-component-docs-content ../getstream.io/content/docs/chat-sdk/angular/v8-rc/05-components && ts-node copy-generated-component-docs.ts ../getstream.io/content/docs/chat-sdk/angular/v8-rc/05-components)" }, "lint-staged": { "**/*": [ @@ -50,7 +50,7 @@ "branches": [ "master", { - "name": "feat/ngx-translate-v18-support", + "name": "feat/stream-chat-v10", "prerelease": "rc", "channel": "rc" } @@ -116,9 +116,9 @@ "@ctrl/ngx-emoji-mart": "^8.2.0", "@floating-ui/dom": "^1.6.3", "@ngx-translate/core": "^17.0.0", + "@stream-io/angular-mentions": "1.5.0", "@stream-io/stream-chat-css": "5.14.0", "@stream-io/transliterate": "^1.5.2", - "@stream-io/angular-mentions": "1.5.0", "dayjs": "^1.11.10", "dotenv": "^16.4.5", "emoji-regex": "^10.3.0", @@ -129,7 +129,7 @@ "replace": "^1.2.2", "rxjs": "~7.4.0", "starwars-names": "^1.6.0", - "stream-chat": "9.20.0", + "stream-chat": "10.0.0-rc.26", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^9.0.1", @@ -172,7 +172,7 @@ "typedoc": "^0.25.13", "typedoc-plugin-markdown": "^3.17.1", "typedoc-plugin-reference-excluder": "^1.1.3", - "typescript": "~5.2.0", + "typescript": "~5.4.5", "vercel": "^48.1.6", "wait-on": "^6.0.0", "webpack-bundle-analyzer": "^4.5.0" diff --git a/projects/customizations-example/src/app/app.component.html b/projects/customizations-example/src/app/app.component.html index a3d3fca8..55233d96 100644 --- a/projects/customizations-example/src/app/app.component.html +++ b/projects/customizations-example/src/app/app.component.html @@ -255,7 +255,7 @@ let-latestMessageText="latestMessageText" let-latestMessage="latestMessage" > -
Channel title: {{ channel.name }}
+
Channel title: {{ channel.data?.custom?.name }}
Display title: {{ channelDisplayTitle }}
Unread count: {{ unreadCount }}
Latest message text: {{ latestMessageText }}
diff --git a/projects/customizations-example/src/app/app.component.ts b/projects/customizations-example/src/app/app.component.ts index 9d63f901..24d1c0d5 100644 --- a/projects/customizations-example/src/app/app.component.ts +++ b/projects/customizations-example/src/app/app.component.ts @@ -206,7 +206,7 @@ export class AppComponent implements AfterViewInit { inviteClicked(channel: Channel) { alert( `You can add channel actions to the channel header to manage the ${ - channel.data?.name || (channel.data?.id as string) + channel.data?.custom?.name || (channel.data?.id as string) } channel` ); } @@ -223,6 +223,7 @@ export class AppComponent implements AfterViewInit { type: 'image', image_url: 'https://picsum.photos/200/300', fallback: 'Just a random image', + custom: {}, }; attachmentService.addAttachment(customAttachment); } diff --git a/projects/customizations-example/src/app/icon/icon.component.ts b/projects/customizations-example/src/app/icon/icon.component.ts index bb3a96f2..bd7b4394 100644 --- a/projects/customizations-example/src/app/icon/icon.component.ts +++ b/projects/customizations-example/src/app/icon/icon.component.ts @@ -36,6 +36,7 @@ export class IconComponent { read: 'done_all', bin: 'delete', mic: 'mic', + poll: 'poll', }; return map[this.icon!]; diff --git a/projects/sample-app/src/app/app.component.ts b/projects/sample-app/src/app/app.component.ts index 48031bfd..b67b14b3 100644 --- a/projects/sample-app/src/app/app.component.ts +++ b/projects/sample-app/src/app/app.component.ts @@ -67,7 +67,7 @@ export class AppComponent implements AfterViewInit { return body.token; } : environment.userToken, - { timeout: 10000 } + { client: { axiosRequestConfig: { timeout: 10000 } } } ); chatService.chatClient.polls.registerSubscriptions(); void channelService.init( 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 aff3b035..596c90df 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" }} + {{ createdAt | date : "long" }}
diff --git a/projects/sample-app/src/app/custom-message/custom-message.component.ts b/projects/sample-app/src/app/custom-message/custom-message.component.ts index b972f7a2..6a4bc245 100644 --- a/projects/sample-app/src/app/custom-message/custom-message.component.ts +++ b/projects/sample-app/src/app/custom-message/custom-message.component.ts @@ -1,6 +1,6 @@ import { Component, Input } from '@angular/core'; -import { FormatMessageResponse } from 'stream-chat'; -import { ChatClientService } from 'stream-chat-angular'; +import { nsToDate } from 'stream-chat'; +import { ChatClientService, StreamMessage } from 'stream-chat-angular'; @Component({ selector: 'app-custom-message', @@ -8,7 +8,7 @@ import { ChatClientService } from 'stream-chat-angular'; styleUrls: ['./custom-message.component.scss'], }) export class CustomMessageComponent { - @Input() message: FormatMessageResponse | undefined; + @Input() message: StreamMessage | undefined; userId: string | undefined; constructor(private chatClientService: ChatClientService) { @@ -18,4 +18,8 @@ export class CustomMessageComponent { get isSentByCurrentUser() { return this.message?.user?.id === this.userId; } + + get createdAt() { + return this.message ? nsToDate(this.message.created_at) : undefined; + } } diff --git a/projects/stream-chat-angular/README.md b/projects/stream-chat-angular/README.md index 6f6a590a..479a8da0 100644 --- a/projects/stream-chat-angular/README.md +++ b/projects/stream-chat-angular/README.md @@ -1,4 +1,4 @@ -# Official Angular SDK for [Stream Chat](https://getstream.io/chat/sdk/react/) +# Official Angular SDK for [Stream Chat](https://getstream.io/chat/sdk/angular/) > The official Angular components for Stream Chat 💬, a service for building chat applications. @@ -9,9 +9,10 @@ **Quick Links** - [Register](https://getstream.io/chat/trial/) to get an API key for Stream Chat -- [Angular Chat Tutorial](https://getstream.io/chat/angular/tutorial/) +- [Angular Chat Tutorial](https://getstream.io/chat/sdk/angular/tutorial/) - [Demo Apps](https://getstream.io/chat/demos/) -- [Docs](https://getstream.io/chat/docs/sdk/angular/) +- [Docs](https://getstream.io/chat/docs/sdk/angular/v8/) +- [Upgrade guide to v8](https://getstream.io/chat/docs/sdk/angular/v8/basics/upgrade-to-v8/) - [Chat UI Kit](https://getstream.io/chat/ui-kit/) With our component library, you can build a variety of chat use cases, including: @@ -23,7 +24,7 @@ With our component library, you can build a variety of chat use cases, including ## Angular Chat Tutorial -The best way to get started is to follow the [Angular Chat Tutorial](https://getstream.io/chat/angular/tutorial/). It shows you how to use this SDK to build a fully functional chat application and includes common customizations. +The best way to get started is to follow the [Angular Chat Tutorial](https://getstream.io/chat/sdk/angular/tutorial/). It shows you how to use this SDK to build a fully functional chat application and includes common customizations. ## Free for Makers @@ -32,9 +33,9 @@ For complete pricing and details visit our [Chat Pricing Page](https://getstream ## Docs -The [docs](https://getstream.io/chat/docs/sdk/angular/) provide a brief description about the components and services in the library. +The [docs](https://getstream.io/chat/docs/sdk/angular/v8/) provide a brief description about the components and services in the library. -The Angular library is created using the [stream-chat-js](https://github.com/getstream/stream-chat-js) library. For the most common use cases our services should give a nice abstraction over this library, however you might need it for more advanced customization, the [documentation](https://getstream.io/chat/docs/js/) is on our website. +The Angular library is created using the [stream-chat-js](https://github.com/getstream/stream-chat-js) library. For the most common use cases our services should give a nice abstraction over this library, however you might need it for more advanced customization, the [documentation](https://getstream.io/chat/docs/javascript/) is on our website. ## Contributing @@ -51,6 +52,8 @@ Check out our current openings and apply via [Stream's website](https://getstrea ### Install with NPM +> v8 is currently a release candidate, published under the `rc` tag on npm. It is built on [`stream-chat`](https://github.com/getstream/stream-chat-js) v10, which is also published under the `rc` tag. If you are upgrading from v7, follow the [upgrade guide](https://getstream.io/chat/docs/sdk/angular/v8/basics/upgrade-to-v8/). + Supported Angular versions: **Angular 17-22** `stream-chat-angular` supports both `@ngx-translate/core` v17 and v18. Use **v18 on Angular 18+** and **v17 on Angular 17** (v18 requires Angular 18 or newer). @@ -58,7 +61,7 @@ Supported Angular versions: **Angular 17-22** Run this command if you are using **Angular 22**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@21 --legacy-peer-deps +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@21 --legacy-peer-deps ``` > `ngx-float-ui` does not have an Angular 22 build yet, so install its latest (v21) with `--legacy-peer-deps` until an Angular 22 release is available. @@ -66,34 +69,34 @@ npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@21 Run this command if you are using **Angular 21**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@21 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@21 ``` Run this command if you are using **Angular 20**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@20 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@20 ``` Run this command if you are using **Angular 19**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@19 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@19 ``` Run this command if you are using **Angular 18**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core ngx-float-ui@18 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core ngx-float-ui@18 ``` Run this command if you are using **Angular 17**: ```shell -npm install stream-chat-angular stream-chat @ngx-translate/core@17 ngx-float-ui@17 +npm install stream-chat-angular@rc stream-chat@rc @ngx-translate/core@17 ngx-float-ui@17 ``` -Supported node verisons: 18+ +Supported Node versions: 22.18+ ## Sample App @@ -129,12 +132,8 @@ STREAM_USER_TOKEN= ``` -Run `npm start:customizations-example` and navigate to `http://localhost:4200/`. +Run `npm run start:customizations-example` and navigate to `http://localhost:4200/`. ## Local development Run `npm install` in the root of the project. You can use the `npm run start:dev` command to start the SampleApp with automatic reloading. - -``` - -``` diff --git a/projects/stream-chat-angular/migration-guide.md b/projects/stream-chat-angular/migration-guide.md new file mode 100644 index 00000000..ede2fb3c --- /dev/null +++ b/projects/stream-chat-angular/migration-guide.md @@ -0,0 +1,363 @@ +# Migration guide: stream-chat v9 → v10 + +This release of `stream-chat-angular` is built on `stream-chat` (the JavaScript client, also called the LLC) v10. Most of the SDK's own API (components, services, templates) is unchanged, but some types and behaviors changed because v10 changed its data model. + +This guide lists every breaking change in `stream-chat-angular`. If your app calls the `stream-chat` client directly (`chatService.chatClient`, `channel.*`, `client.*`), you also need the [stream-chat v10 breaking changes](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md); the most common ones are summarized in [Direct usage of the stream-chat client](#direct-usage-of-the-stream-chat-client). + +## Contents + +1. [Installation and requirements](#installation-and-requirements) +2. [Client initialization (`ChatClientService.init`)](#client-initialization-chatclientserviceinit) +3. [Connection state](#connection-state) +4. [Channel list (`ChannelService.init`, custom queries, filters, sort)](#channel-list) +5. [Custom data moved under `custom`](#custom-data-moved-under-custom) +6. [`StreamMessage` and other message types](#streammessage-and-other-message-types) +7. [Message list, pagination, and jumping to messages](#message-list-pagination-and-jumping-to-messages) +8. [Threads](#threads) +9. [Custom event handlers and setters](#custom-event-handlers-and-setters) +10. [Attachments and voice recordings](#attachments-and-voice-recordings) +11. [Message input and drafts](#message-input-and-drafts) +12. [Polls](#polls) +13. [Reactions](#reactions) +14. [Moderation (bounced and blocked messages)](#moderation-bounced-and-blocked-messages) +15. [Other type changes](#other-type-changes) +16. [Translations](#translations) +17. [Behavior changes](#behavior-changes) +18. [New APIs](#new-apis) +19. [Direct usage of the stream-chat client](#direct-usage-of-the-stream-chat-client) + +## Installation and requirements + +- Install `stream-chat` v10. The SDK's peer dependency changed from `^9.20.0` to `^10.0.0`: + + ```bash + npm install stream-chat@^10 + ``` + +- `stream-chat` v10 requires Node.js `>=22.18.0` for installation and builds (`engines.node`). +- TypeScript: `stream-chat` v10 type definitions work with TypeScript 5.2–5.4 (the range supported by Angular 17), and later versions. + +## Client initialization (`ChatClientService.init`) + +### Client options + +The `clientOptions` parameter is now the `stream-chat` v10 configuration tree (`DeepPartial`). Client options moved under the `client` key, and axios options under `client.axiosRequestConfig`. `trackPendingChannelInvites` stays a top-level option. + +```ts +// Before +chatService.init(apiKey, userId, token, { + timeout: 10000, + trackPendingChannelInvites: true, +}); + +// After +chatService.init(apiKey, userId, token, { + client: { axiosRequestConfig: { timeout: 10000 } }, + trackPendingChannelInvites: true, +}); +``` + +See the [StreamChatOptions keys mapping](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#streamchat-options) for every option. + +### User parameter + +The `userOrId` parameter type changed from `string | OwnUserResponse | UserResponse` to `string | ClientUser` (`ClientUser` is exported by `stream-chat`: a partial `OwnUserResponse` with a required `id`). In practice you can pass `{ id, name, image }` objects as before. Custom user fields go under `custom`, see [Custom data moved under `custom`](#custom-data-moved-under-custom). + +### State recovery after reconnect + +After a reconnect, the SDK refreshes the channel list (same as before). In addition, `stream-chat` v10 now reloads the active channel and the open thread, so they catch up with the messages sent while the user was offline. + +The SDK no longer sets `client.recoverStateOnReconnect = false` (the option doesn't exist in v10); connection recovery is on by default. Don't turn it off (`client.connectionRecovery.enabled: false`): then `stream-chat` doesn't dispatch the `connection.recovered` event, and the SDK doesn't refresh the channel list after a reconnect either. + +## Connection state + +- `stream-chat` v10 no longer emits the `connection.changed` event. `ChatClientService.connectionState$` (`'online' | 'offline'`) and the "Connection failure, reconnecting now..." notification still work: they now use the WebSocket's health state. +- `connectionState$` now switches to `'offline'` only if the connection stays unhealthy for 5 seconds. A shorter outage emits nothing. +- If you listened to `connection.changed` on `ChatClientService.events$` or `chatClient.on(...)`, use `connectionState$` instead, or `chatClient.wsConnection.state` (see [connection.changed](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#connection-changed-removed)). + +## Channel list + +### Filters + +`ChannelFilters` is now the `stream-chat` v10 type. The v10 types require custom fields to be prefixed with `custom.`, and allow only one operator per key (combine more with `$and`). + +```ts +// Before +channelService.init({ type: "messaging", color: "green" }); + +// After +channelService.init({ type: "messaging", "custom.color": "green" }); +``` + +### Sort + +`ChannelService.init`'s `sort` parameter (and `ChannelQuery`'s constructor) now takes the v10 sort format: an array of `{ field, direction }` objects (`SortParamRequest[]` from `stream-chat`). The object format (`{ last_message_at: -1 }`) is no longer accepted, and the `ChannelSort` type no longer exists (it was exported by `stream-chat` v9). + +```ts +import { SortParamRequest } from "stream-chat"; + +// Before +channelService.init(filters, { last_message_at: -1 }); + +// After +const sort: SortParamRequest[] = [{ field: "last_message_at", direction: -1 }]; +channelService.init(filters, sort); +``` + +The order of the array defines the sort priority, the same as the key order of the old object format. + +### Custom channel queries (`initWithCustomQuery`) + +In v10, `client.queryChannels` returns the raw API response and doesn't create `Channel` instances. If your custom query function called `queryChannels`, switch to `queryChannelsAndHydrate` (filter and sort move into the request object): + +```ts +// Before +channelService.initWithCustomQuery(async () => { + const channels = await client.queryChannels(filters, { last_message_at: -1 }, { limit: 25 }); + return { channels, hasMorePage: channels.length === 25 }; +}); + +// After +channelService.initWithCustomQuery(async () => { + const channels = await client.queryChannelsAndHydrate({ + filter_conditions: filters, + sort: [{ field: "last_message_at", direction: -1 }], + limit: 25, + }); + return { channels, hasMorePage: channels.length === 25 }; +}); +``` + +## Custom data moved under `custom` + +In v10, every custom field lives under the `custom` object of the entity (channel, message, user, attachment, reaction, poll, event). Update your templates and code that read or write custom data. + +### Channel name and image + +`name` and `image` are custom channel fields, so they moved too: + +```html + +{{ channel.data?.name }} + + +{{ channel.data?.custom?.name }} +``` + +The SDK's `DefaultChannelData` augmentation now types the `custom` object (`channel.data.custom.name`, `channel.data.custom.image`). If you augment `CustomChannelData` yourself, your fields are typed under `channel.data.custom` as well. + +### Messages + +Custom message fields are read from `message.custom` (for example `message.custom.priority`). `ChannelService.sendMessage`'s `customData` parameter is sent as `custom` (you don't need to change how you call it). `MessageInput.customData` returned by `beforeSendMessage` works the same way. + +### Users and reactions + +Custom user fields are under `user.custom`. `ChannelService.addReaction`'s `customData` parameter is sent as `reaction.custom`. + +## `StreamMessage` and other message types + +`StreamMessage` is now based on the v10 `LocalMessage` type instead of `FormatMessageResponse` (removed in v10). The SDK doesn't convert the date fields: they are in the v10 format, `TimestampNS` (unix nanoseconds, a branded `number`), the same as in `stream-chat`. Use the `nsToDate` helper exported by `stream-chat` if you need a `Date` (and `dateToNs` for the other direction). + +```ts +import { nsToDate } from "stream-chat"; + +// Before +const sentAt = message.created_at.toLocaleString(); + +// After +const sentAt = nsToDate(message.created_at).toLocaleString(); +``` + +In templates, convert the date in the component (or with your own pipe) before passing it to Angular's `date` pipe: the pipe treats numbers as milliseconds, so it would display a wrong date for a `TimestampNS`. + +| Change | Before | After | +| ------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------- | +| Base type | `FormatMessageResponse` | `LocalMessage` | +| Date fields (`created_at`, `updated_at`, `deleted_at`, `pinned_at`, `pin_expires`, `message_text_updated_at`) | `Date` (or `null`) | `TimestampNS` (unix nanoseconds), optional fields are `undefined` (never `null`) | +| `quoted_message` | `MessageResponseBase & { translation }` | `MessageResponse & { translation }` (dates are `TimestampNS`) | +| Custom fields | top-level | under `custom` | +| `moderation_details` | moderation result (v1) | removed, use `moderation` (see [Moderation](#moderation-bounced-and-blocked-messages)) | +| `__html` | set on message previews | removed | + +`DateParserService` (and custom date parsers) still work with `Date` objects; the SDK converts the message dates before calling it. The date separator template context (`DateSeparatorContext.date`) is also a `Date`. + +Other message-related types that are no longer exported by `stream-chat`: `FormatMessageResponse` (use `LocalMessage`), `MessageResponseBase` (use `MessageResponse`), `DraftMessagePayload` (use `MessageRequest`), `CommandResponse` (use `Command`). See the [type rename table](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#type-rename-table). + +## Message list, pagination, and jumping to messages + +`stream-chat` v10 removed `channel.state.messages`, `latestMessages`, `messageSets`, `threads`, `pinnedMessages` and `loadMessageIntoState`. Messages now live in `channel.messagePaginator` (thread replies in `thread.messagePaginator`, pinned messages in `channel.pinnedMessagesPaginator`). The SDK's observables (`activeChannelMessages$`, `activeThreadMessages$`, `activeChannelPinnedMessages$`) work as before, but code that read the channel state directly has to change: + +| Before | After | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `channelService.activeChannel.state.messages` | `channelService.activeChannelMessages$` or `channel.messagePaginator.items` | +| `channel.state.latestMessages` | `channel.messagePaginator.anchoredHeadItems` (latest loaded messages), `channel.messagePaginator.latestMessage` (latest message) | +| `channel.state.pinnedMessages` | `channelService.activeChannelPinnedMessages$` or `channel.pinnedMessagesPaginator.items` | +| `channel.state.threads[parentId]` | `channelService.activeThreadMessages$` or `client.threads.get(parentId)?.messagePaginator.items` | +| `channel.state.loadMessageIntoState(id)` | `channelService.jumpToMessage(id)` | +| `channel.lastMessage()` | `channel.messagePaginator.latestMessage` | +| `channel.state.messages = []` / `addMessageSorted` / `removeMessage` | `channel.messagePaginator.removeItem({ id })` / `ingestItem(message)` (v10 `LocalMessage`) | + +### `loadMoreMessages` and `loadMoreThreadReplies` + +- The return type changed from `false | Promise` (`loadMoreMessages`) / `false | Promise` (`loadMoreThreadReplies`) to `false | Promise`. +- They return `false` (no request) if there's nothing more to load in the given direction, a page is already loading, or no message is loaded yet. If the request fails, the returned promise rejects. +- `loadMoreThreadReplies('newer')` now loads newer replies (it previously always returned `false`). This happens after jumping to an older reply. + +### `jumpToMessage` + +The method signature is unchanged, but it now uses `stream-chat` v10's message paginator: + +- If the message isn't found or can't be loaded, the promise rejects and the "Message not found" notification is displayed (same as before). +- Jumping to a thread reply also loads the parent message into the channel's message list if needed, then opens the thread and jumps to the reply. + +### Channel preview + +`ChannelPreviewComponent.latestMessage` (and `ChannelPreviewInfoContext.latestMessage`) changed from `FormatMessageResponse` to `LocalMessage` (dates are `TimestampNS`). The latest message now comes from `channel.messagePaginator`, so custom channel preview templates no longer need `message.new` / `message.updated` / `message.deleted` listeners to stay up to date. + +## Threads + +- In v10, channels don't store thread replies, replies are stored by `Thread` instances held by `client.threads`. The SDK creates the `Thread` (`client.threads.ensure`) when you call `setAsActiveParentMessage`. +- `channel.getReplies` was removed in v10. The SDK no longer calls it; if your code did, use `client.threads.get(parentId)?.messagePaginator` (or the raw `client.getReplies({ parent_id }, options)`). +- `setAsActiveParentMessage(message, loadMessagesFrom)`: the second parameter is ignored (deprecated). Replies are always loaded by the thread. +- The SDK calls `client.threads.registerSubscriptions()` in `ChannelService.init`, that's required for threads to receive WebSocket events. Don't call `client.threads.unregisterSubscriptions()`, it disposes the reply lists of the threads. + +## Custom event handlers and setters + +The custom handler hooks (`customNewMessageHandler`, `customChannelUpdatedHandler`, `customChannelTruncatedHandler`, `customChannelDeletedHandler`, `customChannelHiddenHandler`, `customChannelVisibleHandler`, `customNewMessageNotificationHandler`, `customAddedToChannelNotificationHandler`, `customRemovedFromChannelNotificationHandler`) keep their signatures, with these changes: + +- The `event` parameter (and `ClientEvent.event`) is the v10 `Event` type. It's a union of the event types: fields only exist on the events that have them, so narrow by `event.type` first: + + ```ts + if (event.type === "notification.message_new") { + const channel = event.channel; // typed + } + ``` + + `EventTypes` was renamed to `EventType`. Custom event fields are under `event.custom`. + +- `messageListSetter` and `threadListSetter` still work, but the message lists are owned by `stream-chat`'s message paginators: the next paginator update (for example the next message) overwrites the list you set. To permanently change a message list, update the paginator instead (`channel.messagePaginator.removeItem` / `ingestItem`). +- `channel.updated`: the SDK no longer copies `event.channel` into `channel.data` (`stream-chat` v10 does it, keeping `hidden`, `own_capabilities` and `member_count`). +- `channel.truncated`: the SDK no longer clears `channel.state.messages`; `stream-chat` v10 truncates the message list. +- The SDK no longer handles `message.updated`, `message.deleted` and `reaction.*` events itself: the message lists are updated from the message paginators. + +## Attachments and voice recordings + +In v10, attachment metadata moved under `attachment.custom`. This includes the fields added by the SDK: + +| Before | After | +| --------------------------------- | ---------------------------------------- | +| `attachment.mime_type` | `attachment.custom.mime_type` | +| `attachment.file_size` | `attachment.custom.file_size` | +| `attachment.duration` | `attachment.custom.duration` | +| `attachment.waveform_data` | `attachment.custom.waveform_data` | +| `attachment.img_url` (deprecated) | `attachment.custom.img_url` (deprecated) | +| `attachment.isCustomAttachment` | `attachment.custom.isCustomAttachment` | + +- Every attachment needs a `custom` object (it can be empty). Add `custom: {}` to attachments you create, for example with `attachmentService.addAttachment`: + + ```ts + attachmentService.addAttachment({ + type: "image", + image_url: "https://...", + custom: {}, + }); + ``` + +- `AttachmentUpload.extraData` is merged into the attachment, so put custom metadata in `extraData.custom`. +- `GalleryAttachment` (gallery context of custom attachment templates) now has a required `custom` property. +- `DefaultAttachmentData` has two new fields, `duration` and `waveform_data`. +- If you have custom attachment templates, read the fields from their new location. +- Custom upload/delete handlers (`customImageUploadRequest`, `customFileUploadRequest`, `customImageDeleteRequest`, `customFileDeleteRequest`) are unchanged. The default upload requests now use `channel.uploadImage` / `uploadFile` (`channel.sendImage` / `sendFile` were removed in v10). + +## Message input and drafts + +- `MessageInputComponent.messageDraftChange` emits `MessageRequest | undefined` (was `DraftMessagePayload | undefined`, removed in v10). The fields are the same (`text`, `attachments`, `mentioned_users`, `poll_id`, `parent_id`, `quoted_message_id`). +- `MessageInputComponent.loadDraft` takes the v10 `DraftResponse`: `draft.message.mentioned_users` is now a list of users (it was a list of ids), and dates are `TimestampNS`. To save a draft with `stream-chat` v10: `channel.createDraft({ message: draft })`. +- Slash commands come from `channel.config.availableCommands` (was `channel.getConfig().commands`). `ComandAutocompleteListItem` is now based on `Command` (was `CommandResponse`). + +## Polls + +- Poll types renamed in v10, and the SDK's poll components use the new names (relevant for custom poll templates and if you use the poll components directly): + + | Before | After | + | ------------------------ | ------------------------ | + | `PollOption` | `PollOptionResponseData` | + | `PollVote`, `PollAnswer` | `PollVoteResponseData` | + + Affected inputs: `PollVoteComponent.vote`, `UpsertAnswerComponent.answer`, `PollVoteResultsListComponent.option`, `PollOptionSelectorComponent.option`. + +- `VotingVisibility` is now a string union type (`'anonymous' | 'public'`), not an enum. +- Poll, option, vote and answer dates are `TimestampNS` (unix nanoseconds), convert them with `nsToDate`. +- Sort options of poll queries use `[{ field, direction }]` (for example `[{ field: 'created_at', direction: -1 }]`). + +## Reactions + +- `MessageReactionsComponent.messageReactionGroups` uses the v10 `ReactionGroupResponse`: `first_reaction_at` / `last_reaction_at` are `TimestampNS`. +- Custom reaction fields are under `reaction.custom`. +- `ChannelService.getMessageReactions` (deprecated) and `MessageReactionsService.queryReactions` work as before, they use the v10 requests internally. + +## Moderation (bounced and blocked messages) + +v10 messages no longer have `moderation_details`. Bounced messages are detected with `message.type === 'error' && message.moderation?.action === 'bounce'`, and blocked messages with `message.type === 'error' && message.moderation?.action === 'remove'`. If you have a custom message template or `MessageBouncePrompt` customization that checked `moderation_details`, switch to `moderation`. + +## Other type changes + +| Type / API | Before | After | +| ------------------------------------------------ | --------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `ChatClientService.appSettings$` / `AppSettings` | `AppSettings` from `stream-chat` | `AppSettings` from `stream-chat-angular` (`Partial`) | +| `ChannelService.activeChannelMessages` getter | `(StreamMessage \| MessageResponse \| FormatMessageResponse)[]` | `(StreamMessage \| MessageResponse \| LocalMessage)[]` | +| `ChannelService.selectMessageToQuote` | `StreamMessage \| MessageResponse \| undefined` | also accepts `LocalMessage` | +| `MessageTextContext.message` | `StreamMessage \| MessageResponseBase \| undefined` | `StreamMessage \| MessageResponse \| undefined` | +| `ChannelSort` | from `stream-chat` | removed, use `SortParamRequest[]` from `stream-chat` | +| `getChannelDisplayText(channel, user)` | `user: UserResponse` | `user?: { id?: string }` | +| `listUsers(users)` | `users: UserResponse[]` | `users: { id: string; name?: string }[]` | + +## Translations + +New translation key: `streamChat.Error loading messages` (displayed if the messages of a channel or thread can't be loaded). If you provide your own translations, add this key. + +## Behavior changes + +These changes don't require code changes, but your app may behave differently: + +- **Reconnect:** after a reconnect, the SDK refreshes the channel list (same as before), and `stream-chat` reloads the active channel and the open thread. The open thread now stays open (previously it was closed because its replies weren't reloaded). +- **Message list updates:** messages arriving via WebSocket can be displayed up to 500 ms later: `stream-chat` v10 batches message list updates. Messages sent by the current user are displayed immediately. +- **Message objects:** when a message changes (for example a reaction is added), only that message gets a new object in `activeChannelMessages$`. Attachments are kept if only their (signed) URLs changed, so images and videos aren't reloaded. +- **Hard deleted user messages:** in v10, `user.messages.deleted` events with `hard_delete` remove the messages instead of displaying a "This message was deleted" placeholder. +- **Read events:** the active channel is marked as read with `channel.markReadViaReporter()` (v9's `markRead` behavior). If read events are disabled for the channel, the SDK doesn't send read requests, and mark as unread fails with an error notification. +- **Typing events** are only sent if typing events are enabled for the channel (`channel.config.typingEvents.enabled`). + +## New APIs + +- `ChannelService.activeChannelMessagesPaginationState$` and `activeThreadMessagesPaginationState$`: pagination state of the message lists (`MessageListPaginationState`: `hasMoreOlder`, `hasMoreNewer`, `isLoading`, `error`). +- `ChannelService.jumpToFirstUnreadMessage()`: jumps to the first unread message of the active channel (uses `stream-chat`'s unread state). +- `ChannelService.reloadActiveChannelMessages()`: retries loading the messages of the active channel (for example after an error). +- `MessageListPaginationState` and `AppSettings` types. + +## Direct usage of the stream-chat client + +If you use `chatService.chatClient` or `Channel` objects directly, these are the v10 changes you're most likely to hit (see the [stream-chat v10 breaking changes](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md) for the full list): + +| Before (v9) | After (v10) | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `client.activeChannels[cid]` | `client.channelManager.get(cid)` | +| `client.queryChannels(filters, sort, options)` | `client.queryChannelsAndHydrate({ filter_conditions, sort: [{ field, direction }], ...options })` | +| `client.queryUsers(filters, sort, options)` | `client.queryUsers({ payload: { filter_conditions, sort, ...options } })` | +| `client.flagMessage(id)` | `client.moderation.flagMessage({ message_id: id })` | +| `client.updateMessage(message)` | `client.updateMessage({ id }, { message })` | +| `client.deleteMessage(id, hard)` | `client.deleteMessage({ id }, { hard })` | +| `client.pinMessage(message)` / `unpinMessage(message)` | `client.pinMessage({ id })` / `unpinMessage({ id })` | +| `client.queryReactions(id, filter, sort, options)` | `client.queryReactions({ id }, { filter, sort, ...options })` | +| `client.on('connection.changed', ...)` | `client.wsConnection.state.subscribeWithSelector(({ isHealthy }) => ({ isHealthy }), ...)` | +| `client.logger(...)` | `chatLoggerSystem` | +| `channel.sendMessage(message)` | `channel.sendMessage({ message })` | +| `channel.sendReaction(id, reaction)` / `deleteReaction(id, type)` | `channel.sendReaction({ id }, { reaction })` / `deleteReaction({ id, type })` | +| `channel.markRead()` | `channel.markReadViaReporter()` (`markRead()` is now the raw request) | +| `channel.getConfig()` | `channel.serverConfig` (server config), `channel.config` (resolved config) | +| `channel.sendImage` / `sendFile` | `channel.uploadImage({ file })` / `uploadFile({ file })` | +| `channel.queryMembers(filters, sort, options)` | `channel.queryMembers({ payload: { filter_conditions, sort, ...options } })` | +| `channel.getReplies(parentId, options)` | `client.threads` / `thread.messagePaginator`, or `client.getReplies({ parent_id }, options)` | +| `channel.state.*` message storage | `channel.messagePaginator` (see [Message list](#message-list-pagination-and-jumping-to-messages)) | +| Date fields as `string` / `Date` | `TimestampNS` (unix nanoseconds), convert with `nsToDate` / `dateToNs` | +| Custom fields at the top level | under `custom` | +| Sort as `{ field: direction }` | `[{ field, direction }]` | diff --git a/projects/stream-chat-angular/package.json b/projects/stream-chat-angular/package.json index 35267bc1..426c44f1 100644 --- a/projects/stream-chat-angular/package.json +++ b/projects/stream-chat-angular/package.json @@ -1,6 +1,6 @@ { "name": "stream-chat-angular", - "version": "7.2.0", + "version": "8.0.0-rc.3", "description": "Angular components to create chat conversations or livestream style chat", "author": "GetStream", "license": "SEE LICENSE IN LICENSE", @@ -24,7 +24,7 @@ "@breezystack/lamejs": "^1.2.7", "@ngx-translate/core": "^17.0.0 || ^18.0.0", "rxjs": "^7.4.0", - "stream-chat": "^9.20.0" + "stream-chat": "^10.0.0" }, "peerDependenciesMeta": { "@angular/forms": { diff --git a/projects/stream-chat-angular/src/assets/i18n/en.ts b/projects/stream-chat-angular/src/assets/i18n/en.ts index 2b59a8c2..9123936d 100644 --- a/projects/stream-chat-angular/src/assets/i18n/en.ts +++ b/projects/stream-chat-angular/src/assets/i18n/en.ts @@ -104,6 +104,7 @@ export const en = { 'user is typing': '{{ user }} is typing', 'users are typing': '{{ users }} are typing', 'Error loading channels': 'Error loading channels', + 'Error loading messages': 'Error loading messages', 'See original (automatically translated)': 'See original (automatically translated)', 'See translation': 'See translation', diff --git a/projects/stream-chat-angular/src/assets/version.ts b/projects/stream-chat-angular/src/assets/version.ts index 8bc2d056..22afecdd 100644 --- a/projects/stream-chat-angular/src/assets/version.ts +++ b/projects/stream-chat-angular/src/assets/version.ts @@ -1 +1 @@ -export const version = '7.2.0'; +export const version = '8.0.0-rc.3'; diff --git a/projects/stream-chat-angular/src/lib/attachment-configuration.service.spec.ts b/projects/stream-chat-angular/src/lib/attachment-configuration.service.spec.ts index 533be094..42a53e36 100644 --- a/projects/stream-chat-angular/src/lib/attachment-configuration.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/attachment-configuration.service.spec.ts @@ -18,7 +18,7 @@ describe('AttachmentConfigurationService', () => { it('should provide the correct configuration for image attachments', () => { let attachment: Attachment = { - img_url: 'http://url/to/img', + custom: { img_url: 'http://url/to/img' }, thumb_url: 'different/url', }; const htmlElement = { @@ -38,6 +38,7 @@ describe('AttachmentConfigurationService', () => { }); attachment = { + custom: {}, thumb_url: 'http://url/to/img?oh=1200&ow=800', }; @@ -52,6 +53,7 @@ describe('AttachmentConfigurationService', () => { }); attachment = { + custom: {}, image_url: 'http://url/to/img?oh=1&ow=1', }; @@ -70,6 +72,7 @@ describe('AttachmentConfigurationService', () => { const spy = jasmine.createSpy(); service.customImageAttachmentConfigurationHandler = spy; const attachment: Attachment = { + custom: {}, image_url: 'http://url/to/img', thumb_url: 'different/url', }; @@ -88,6 +91,7 @@ describe('AttachmentConfigurationService', () => { it('should provide the correct configuration for gallery image attachments', () => { const attachment: Attachment = { + custom: {}, image_url: 'http://url/to/img', }; const htmlElement = { @@ -112,6 +116,7 @@ describe('AttachmentConfigurationService', () => { it('should provide the correct configuration for image attachments inside the carousel', () => { const attachment: Attachment = { + custom: {}, image_url: 'http://url/to/img', }; const htmlElement = { @@ -136,6 +141,7 @@ describe('AttachmentConfigurationService', () => { it('should provide the correct configuration for video attachments', () => { let attachment: Attachment = { + custom: {}, asset_url: 'http://url/to/video', thumb_url: 'http://url/to/poster', }; @@ -156,6 +162,7 @@ describe('AttachmentConfigurationService', () => { }); attachment = { + custom: {}, asset_url: 'http://url/to/video', thumb_url: 'http://url/to/poster?oh=1080&ow=1920', }; @@ -172,6 +179,7 @@ describe('AttachmentConfigurationService', () => { }); attachment = { + custom: {}, asset_url: 'http://url/to/video', }; @@ -191,6 +199,7 @@ describe('AttachmentConfigurationService', () => { const spy = jasmine.createSpy(); service.customVideoAttachmentConfigurationHandler = spy; const attachment: Attachment = { + custom: {}, image_url: 'http://url/to/video', thumb_url: 'different/url', }; @@ -205,6 +214,7 @@ describe('AttachmentConfigurationService', () => { it('should provide the correct configuration for GIFs', () => { let attachment: Attachment = { + custom: {}, image_url: 'link/to/GIF', }; @@ -215,42 +225,57 @@ describe('AttachmentConfigurationService', () => { }); attachment = { + custom: {}, image_url: 'link/to/GIF', giphy: { fixed_height_downsampled: { height: '200', width: '400', url: 'link/to/smaller/GIF', + frames: '', + size: '', }, original: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_height: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_height_still: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_width: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_width_downsampled: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_width_still: { height: '', url: '', width: '', + frames: '', + size: '', }, }, }; @@ -267,42 +292,57 @@ describe('AttachmentConfigurationService', () => { service.customGiphyAttachmentConfigurationHandler = spy; const attachment: Attachment = { + custom: {}, image_url: 'link/to/GIF', giphy: { fixed_height_downsampled: { height: '200', width: '400', url: 'link/to/smaller/GIF', + frames: '', + size: '', }, original: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_height: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_height_still: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_width: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_width_downsampled: { height: '', url: '', width: '', + frames: '', + size: '', }, fixed_width_still: { height: '', url: '', width: '', + frames: '', + size: '', }, }, }; @@ -314,6 +354,7 @@ describe('AttachmentConfigurationService', () => { it('should provide correct configuration for scraped images', () => { let attachment: Attachment = { + custom: {}, image_url: 'http://url/to/img', thumb_url: 'different/url', }; @@ -325,6 +366,7 @@ describe('AttachmentConfigurationService', () => { }); attachment = { + custom: {}, thumb_url: 'different/url', }; @@ -339,6 +381,7 @@ describe('AttachmentConfigurationService', () => { const spy = jasmine.createSpy(); service.customScrapedImageAttachmentConfigurationHandler = spy; const attachment = { + custom: {}, image_url: 'link/to/url', }; service.getScrapedImageAttachmentConfiguration(attachment); @@ -348,6 +391,7 @@ describe('AttachmentConfigurationService', () => { it('should provide integer values for image resize and make sure that each dimension is at least the size restriction', () => { const attachment = { + custom: {}, image_url: 'http://url/to/img?ow=3534&oh=4417', }; const htmlElement = { @@ -372,6 +416,7 @@ describe('AttachmentConfigurationService', () => { } as unknown as HTMLElement; const attachment: Attachment = { + custom: {}, asset_url: 'http://url/to/video', thumb_url: 'http://url/to/poster', }; @@ -383,6 +428,7 @@ describe('AttachmentConfigurationService', () => { it('should override existing "h" and "w" URL params', () => { const attachment: Attachment = { + custom: {}, image_url: 'http://url/to/img?crop=*&h=*&oh=0&ow=0&resize=*&ro=0&w=*', }; const htmlElement = { @@ -403,7 +449,7 @@ describe('AttachmentConfigurationService', () => { }); it('should handle if image attachments have invalid or missing urls', () => { - const attachment: Attachment = {}; + const attachment: Attachment = { custom: {} }; const htmlElement = { 'max-width': '300px', 'max-height': '300px', diff --git a/projects/stream-chat-angular/src/lib/attachment-configuration.service.ts b/projects/stream-chat-angular/src/lib/attachment-configuration.service.ts index b38cda14..f1b990e9 100644 --- a/projects/stream-chat-angular/src/lib/attachment-configuration.service.ts +++ b/projects/stream-chat-angular/src/lib/attachment-configuration.service.ts @@ -66,7 +66,10 @@ export class AttachmentConfigurationService { const defaultOriginalDimension = 1000000; const urlString = - attachment.img_url || attachment.thumb_url || attachment.image_url || ''; + attachment.custom?.img_url || + attachment.thumb_url || + attachment.image_url || + ''; let url: URL; try { url = new URL(urlString); diff --git a/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.spec.ts b/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.spec.ts index 880bd201..57f4d685 100644 --- a/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.spec.ts @@ -142,9 +142,10 @@ describe('AttachmentListComponent', () => { expect(queryAttachments().length).toBe(0); component.attachments = [ - { type: 'image', image_url: 'http://url1' }, - { type: 'file', asset_url: 'http://url3' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'file', asset_url: 'http://url3' }, { + custom: {}, type: 'video', asset_url: 'http://url6', }, @@ -192,15 +193,15 @@ describe('AttachmentListComponent', () => { it('should filter custom attachments', () => { const messageService = TestBed.inject(MessageService); messageService.filterCustomAttachment = (attachment: Attachment) => - // @ts-expect-error custom property - !attachment.customLink; - const imageAttachment = { + !(attachment.custom as Record).customLink; + const imageAttachment: Attachment = { type: 'image', image_url: 'url/to/image', + custom: {}, }; - const customImageAttachment = { + const customImageAttachment: Attachment = { type: 'image', - customLink: 'load/from/here', + custom: { customLink: 'load/from/here' } as Attachment['custom'], }; component.attachments = [imageAttachment, customImageAttachment]; component.ngOnChanges({ attachments: {} as SimpleChange }); @@ -209,8 +210,9 @@ describe('AttachmentListComponent', () => { }); it('should display scraped videos', () => { - const scrapedVideoAttachments = [ + const scrapedVideoAttachments: Attachment[] = [ { + custom: {}, type: 'video', author_name: 'YouTube', title: 'Rachmaninoff - Prelude in C Sharp Minor (Op. 3 No. 2)', @@ -222,6 +224,7 @@ describe('AttachmentListComponent', () => { og_scrape_url: 'https://www.youtube.com/watch?v=sCtixpIWBto', }, { + custom: {}, type: 'video', author_name: 'Twitch', title: 'ledo_drito - Twitch', @@ -246,7 +249,7 @@ describe('AttachmentListComponent', () => { expect(scrapedVideos.length).toBe(2); scrapedVideos.forEach((video, index) => { - expect(video.href).toBe(scrapedVideoAttachments[index].og_scrape_url); + expect(video.href).toBe(scrapedVideoAttachments[index].og_scrape_url!); }); }); @@ -264,9 +267,9 @@ describe('AttachmentListComponent', () => { it('should create gallery', () => { component.attachments = [ - { type: 'image', image_url: 'http://url1' }, - { type: 'file', asset_url: 'http://url3' }, - { type: 'image', image_url: 'http://url2' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'file', asset_url: 'http://url3' }, + { custom: {}, type: 'image', image_url: 'http://url2' }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -284,8 +287,8 @@ describe('AttachmentListComponent', () => { it('should display gallery', () => { component.attachments = [ - { type: 'image', image_url: 'http://url1' }, - { type: 'image', image_url: 'http://url2' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'image', image_url: 'http://url2' }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -300,10 +303,10 @@ describe('AttachmentListComponent', () => { ).toBeNull(); component.attachments = [ - { type: 'image', image_url: 'http://url1' }, - { type: 'image', image_url: 'http://url2' }, - { type: 'image', thumb_url: 'http://url3' }, - { type: 'image', image_url: 'http://url4' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'image', image_url: 'http://url2' }, + { custom: {}, type: 'image', thumb_url: 'http://url3' }, + { custom: {}, type: 'image', image_url: 'http://url4' }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -320,11 +323,11 @@ describe('AttachmentListComponent', () => { ).not.toBeNull(); component.attachments = [ - { type: 'image', image_url: 'http://url1' }, - { type: 'image', image_url: 'http://url2' }, - { type: 'image', thumb_url: 'http://url3' }, - { type: 'image', image_url: 'http://url4' }, - { type: 'image', image_url: 'http://url5' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'image', image_url: 'http://url2' }, + { custom: {}, type: 'image', thumb_url: 'http://url3' }, + { custom: {}, type: 'image', image_url: 'http://url4' }, + { custom: {}, type: 'image', image_url: 'http://url5' }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -344,7 +347,8 @@ describe('AttachmentListComponent', () => { }); it('should display attachment actions and apply CSS class', () => { - const attachment = { + const attachment: Attachment = { + custom: {}, type: 'giphy', title: 'cats', title_link: 'https://giphy.com/gifs/cat-funny-costume-3mq6k5fqe5g8o', @@ -399,7 +403,8 @@ describe('AttachmentListComponent', () => { }); it('should send attachment action, if clicked', () => { - const attachment = { + const attachment: Attachment = { + custom: {}, type: 'giphy', title: 'cats', title_link: 'https://giphy.com/gifs/cat-funny-costume-3mq6k5fqe5g8o', @@ -460,7 +465,11 @@ describe('AttachmentListComponent', () => { it('should display image by img_url', () => { const imageUrl = 'http://image/url'; component.attachments = [ - { type: 'image', img_url: imageUrl, thumb_url: 'thumb/url' }, + { + type: 'image', + custom: { img_url: imageUrl }, + thumb_url: 'thumb/url', + }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -471,7 +480,12 @@ describe('AttachmentListComponent', () => { it('should display image by thumb_url', () => { const thumbUrl = 'http://thumb/url'; component.attachments = [ - { type: 'image', image_url: undefined, thumb_url: thumbUrl }, + { + custom: {}, + type: 'image', + image_url: undefined, + thumb_url: thumbUrl, + }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -484,7 +498,7 @@ describe('AttachmentListComponent', () => { component.attachments = [ { type: 'image', - img_url: undefined, + custom: { img_url: undefined }, thumb_url: undefined, image_url: imageUrl, }, @@ -498,7 +512,7 @@ describe('AttachmentListComponent', () => { it('should set alt text for image', () => { const fallback = 'Fallback is image can not be displayed'; component.attachments = [ - { type: 'image', image_url: 'http://url1', fallback }, + { custom: {}, type: 'image', image_url: 'http://url1', fallback }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -508,7 +522,12 @@ describe('AttachmentListComponent', () => { it('should display add necessary CSS class for SVG images', () => { component.attachments = [ - { type: 'image', image_url: 'http://image/url', fallback: 'image.svg' }, + { + custom: {}, + type: 'image', + image_url: 'http://image/url', + fallback: 'image.svg', + }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -518,7 +537,12 @@ describe('AttachmentListComponent', () => { ).not.toBeNull(); component.attachments = [ - { type: 'image', image_url: 'http://image/url', fallback: 'image.jpg' }, + { + custom: {}, + type: 'image', + image_url: 'http://image/url', + fallback: 'image.jpg', + }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -533,7 +557,7 @@ describe('AttachmentListComponent', () => { it('should display file link', () => { const title = 'contract.pdf'; const asset_url = 'http://url/to/contract'; - component.attachments = [{ type: 'file', title, asset_url }]; + component.attachments = [{ custom: {}, type: 'file', title, asset_url }]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); const link = queryFileLinks()[0]; @@ -546,11 +570,13 @@ describe('AttachmentListComponent', () => { it('should add CSS class for files', () => { component.attachments = [ { + custom: {}, type: 'file', title: 'contract.pdf', asset_url: 'http://url/to/contract', }, { + custom: {}, type: 'file', title: 'contract2.pdf', asset_url: 'http://url/to/contract2', @@ -575,7 +601,7 @@ describe('AttachmentListComponent', () => { it('should sanitize file link', () => { const asset_url = 'javascript:alert(document.domain)'; component.attachments = [ - { type: 'file', title: 'contract.pdf', asset_url }, + { custom: {}, type: 'file', title: 'contract.pdf', asset_url }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -588,7 +614,7 @@ describe('AttachmentListComponent', () => { const title = 'contract.pdf'; const asset_url = 'http://url/to/contract'; component.attachments = [ - { type: 'file', title, asset_url, file_size: 3272969 }, + { type: 'file', title, asset_url, custom: { file_size: 3272969 } }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -598,7 +624,12 @@ describe('AttachmentListComponent', () => { expect(fileSize?.textContent).toContain('3.27 MB'); component.attachments = [ - { type: 'file', title, asset_url, file_size: '3272969' }, + { + type: 'file', + title, + asset_url, + custom: { file_size: '3272969' as unknown as number }, + }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -619,6 +650,7 @@ describe('AttachmentListComponent', () => { const imageUrl = 'https://getstream.io/images/og/OG_Home.png'; component.attachments = [ { + custom: {}, author_name: 'GetStream', image_url: imageUrl, og_scrape_url: 'https://getstream.io', @@ -638,6 +670,7 @@ describe('AttachmentListComponent', () => { const thumbUrl = 'https://getstream.io/images/og/OG_Home.png'; component.attachments = [ { + custom: {}, type: 'image', author_name: 'GetStream', image_url: undefined, @@ -656,6 +689,7 @@ describe('AttachmentListComponent', () => { it(`shouldn't display url preview if there are other attachments`, () => { component.attachments = [ { + custom: {}, author_name: 'GetStream', image_url: 'https://getstream.io/images/og/OG_Home.png', og_scrape_url: 'https://getstream.io', @@ -664,6 +698,7 @@ describe('AttachmentListComponent', () => { type: 'image', }, { + custom: {}, type: 'image', image_url: 'http://url/to/flower.png', }, @@ -678,6 +713,7 @@ describe('AttachmentListComponent', () => { const title = 'Stream'; component.attachments = [ { + custom: {}, author_name: 'GetStream', image_url: undefined, og_scrape_url: 'https://getstream.io', @@ -701,6 +737,7 @@ describe('AttachmentListComponent', () => { 'Build scalable in-app chat or activity feeds in days. Product teams trust Stream to launch faster, iterate more often, and ship a better user experience.'; component.attachments = [ { + custom: {}, author_name: 'GetStream', image_url: undefined, og_scrape_url: 'https://getstream.io', @@ -724,6 +761,7 @@ describe('AttachmentListComponent', () => { const titleLink = 'https://getstream.io'; component.attachments = [ { + custom: {}, type: 'image', author_name: 'GetStream', image_url: undefined, @@ -745,6 +783,7 @@ describe('AttachmentListComponent', () => { const scrapeUrl = 'https://getstream.io'; component.attachments = [ { + custom: {}, author_name: 'GetStream', image_url: undefined, og_scrape_url: scrapeUrl, @@ -768,7 +807,8 @@ describe('AttachmentListComponent', () => { const imageModalSpy = jasmine.createSpy(); component.imageModalStateChange.subscribe(imageModalSpy); - const attachment = { + const attachment: Attachment = { + custom: {}, type: 'image', image_url: 'http://url1', }; @@ -790,12 +830,14 @@ describe('AttachmentListComponent', () => { }); it('should open image viewer modal - image gallery', () => { - const attachments = [ + const attachments: Attachment[] = [ { + custom: {}, type: 'image', image_url: 'http://url1', }, { + custom: {}, type: 'image', image_url: 'http://url2', }, @@ -819,12 +861,14 @@ describe('AttachmentListComponent', () => { }); it('should open image viewer modal with preselected image', () => { - const attachments = [ + const attachments: Attachment[] = [ { + custom: {}, type: 'image', image_url: 'http://url1', }, { + custom: {}, type: 'image', image_url: 'http://url2', }, @@ -848,16 +892,19 @@ describe('AttachmentListComponent', () => { }); it('should move to next and previous images', () => { - const attachments = [ + const attachments: Attachment[] = [ { + custom: {}, type: 'image', image_url: 'http://url1', }, { + custom: {}, type: 'image', image_url: 'http://url2', }, { + custom: {}, type: 'image', thumb_url: 'http://url3', }, @@ -901,7 +948,8 @@ describe('AttachmentListComponent', () => { const imageModalSpy = jasmine.createSpy(); component.imageModalStateChange.subscribe(imageModalSpy); imageModalSpy.calls.reset(); - const attachment = { + const attachment: Attachment = { + custom: {}, type: 'image', image_url: 'http://url1', }; @@ -918,7 +966,8 @@ describe('AttachmentListComponent', () => { }); it(`shouldn't display video links as video attachments`, () => { - const attachment = { + const attachment: Attachment = { + custom: {}, asset_url: 'https://www.youtube.com/watch?v=m4-HM_sCvtQ', author_name: 'YouTube', image_url: 'https://i.ytimg.com/vi/m4-HM_sCvtQ/mqdefault.jpg', @@ -964,14 +1013,16 @@ describe('AttachmentListComponent', () => { // Single image, link image, video, giphy component.attachments = [ - { type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, { + custom: {}, title: 'BBC - Homepage', title_link: 'https://www.bbc.com/', og_scrape_url: 'https://www.bbc.com/', image_url: 'https://assets/images/favicons/favicon-194x194.png', }, { + custom: {}, image_url: 'https://getstream.io/images/og/OG_Home.png', og_scrape_url: 'https://getstream.io/', text: 'Build scalable in-app chat or activity feeds in days. Product teams trust Stream to launch faster, iterate more often, and ship a better user experience.', @@ -981,6 +1032,7 @@ describe('AttachmentListComponent', () => { type: 'image', }, { + custom: {}, thumb_url: 'https://media3.giphy.com/media/Eq5pb4dR4DJQc/giphy.gif?cid=c4b036756eqt4bhl28q4lm1xxpqk5a1cwspozzn9q8f0za10&rid=giphy.gif&ct=g', title: 'cats', @@ -988,6 +1040,7 @@ describe('AttachmentListComponent', () => { type: 'giphy', }, { + custom: {}, type: 'video', asset_url: 'http://url6', }, @@ -1016,11 +1069,11 @@ describe('AttachmentListComponent', () => { // Gallery component.attachments = [ - { type: 'image', image_url: 'http://url1' }, - { type: 'image', image_url: 'http://url2' }, - { type: 'image', image_url: 'http://url3' }, - { type: 'image', image_url: 'http://url4' }, - { type: 'image', image_url: 'http://url5' }, + { custom: {}, type: 'image', image_url: 'http://url1' }, + { custom: {}, type: 'image', image_url: 'http://url2' }, + { custom: {}, type: 'image', image_url: 'http://url3' }, + { custom: {}, type: 'image', image_url: 'http://url4' }, + { custom: {}, type: 'image', image_url: 'http://url5' }, ]; component.ngOnChanges({ attachments: {} as SimpleChange }); fixture.detectChanges(); @@ -1045,7 +1098,8 @@ describe('AttachmentListComponent', () => { }); // Image carousel - const attachment = { + const attachment: Attachment = { + custom: {}, type: 'image', image_url: 'http://url1', }; @@ -1069,8 +1123,9 @@ describe('AttachmentListComponent', () => { }); it('should display video attachment with thumb url', () => { - const attachments = [ + const attachments: Attachment[] = [ { + custom: {}, type: 'video', asset_url: 'http://url/to/video', thumb_url: 'http://url/to/thumb', @@ -1081,8 +1136,8 @@ describe('AttachmentListComponent', () => { fixture.detectChanges(); const videoElements = queryVideos(); - expect(videoElements[0].src).toContain(attachments[0].asset_url); - expect(videoElements[0].poster).toContain(attachments[0].thumb_url); + expect(videoElements[0].src).toContain(attachments[0].asset_url!); + expect(videoElements[0].poster).toContain(attachments[0].thumb_url!); }); }); @@ -1098,11 +1153,13 @@ describe('AttachmentListComponent with custom attachments', () => { class="custom-attachment-container" *ngFor="let attachment of attachments" > - + @@ -1145,11 +1202,13 @@ describe('AttachmentListComponent with custom attachments', () => { hostFixture.nativeElement.querySelectorAll('.payment-link').length ).toBe(0); - const customAttachment = { + const customAttachment: Attachment = { type: 'custom', - subtype: 'payment', - value: '30$', - link: 'pay/me/or/else', + custom: { + subtype: 'payment', + value: '30$', + link: 'pay/me/or/else', + } as Attachment['custom'], }; hostComponent.attachments = [customAttachment]; hostFixture.detectChanges(); @@ -1163,11 +1222,13 @@ describe('AttachmentListComponent with custom attachments', () => { const customTemplatesService = TestBed.inject(CustomTemplatesService); customTemplatesService.customAttachmentListTemplate$.next(undefined); - const customAttachment = { + const customAttachment: Attachment = { type: 'custom', - subtype: 'payment', - value: '30$', - link: 'pay/me/or/else', + custom: { + subtype: 'payment', + value: '30$', + link: 'pay/me/or/else', + } as Attachment['custom'], }; hostComponent.attachments = [customAttachment]; hostFixture.detectChanges(); diff --git a/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.ts b/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.ts index 68113c2b..00725235 100644 --- a/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.ts +++ b/projects/stream-chat-angular/src/lib/attachment-list/attachment-list.component.ts @@ -168,12 +168,13 @@ export class AttachmentListComponent implements OnChanges, OnInit, OnDestroy { hasFileSize(attachment: Attachment) { return ( - attachment.file_size && Number.isFinite(Number(attachment.file_size)) + attachment.custom?.file_size && + Number.isFinite(Number(attachment.custom.file_size)) ); } getFileSize(attachment: Attachment) { - return prettybytes(Number(attachment.file_size!)); + return prettybytes(Number(attachment.custom?.file_size)); } getModalContext(): ModalContext { @@ -220,7 +221,7 @@ export class AttachmentListComponent implements OnChanges, OnInit, OnDestroy { } trackByImageUrl(_: number, item: Attachment) { - return item.image_url || item.img_url || item.thumb_url; + return item.image_url || item.custom?.img_url || item.thumb_url; } getAttachmentContext(attachment: Attachment): AttachmentContext { @@ -311,6 +312,7 @@ export class AttachmentListComponent implements OnChanges, OnInit, OnDestroy { return [ { type: 'gallery', + custom: {}, images, }, ]; @@ -324,7 +326,7 @@ export class AttachmentListComponent implements OnChanges, OnInit, OnDestroy { private getAttachmentUrl(attachment: Attachment) { return ( attachment.image_url || - attachment.img_url || + attachment.custom?.img_url || attachment.asset_url || attachment.thumb_url ); diff --git a/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.spec.ts b/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.spec.ts index a52a47e7..289d9dd7 100644 --- a/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BehaviorSubject, Subject } from 'rxjs'; +import { Attachment } from 'stream-chat'; import { AttachmentUpload, CustomAttachmentPreviewListContext } from '../types'; import { AttachmentPreviewListComponent } from './attachment-preview-list.component'; @@ -317,11 +318,13 @@ describe('AttachmentPreviewListComponent with custom attachments', () => { class="custom-attachment-container" *ngFor="let attachment of service.customAttachments$ | async" > - + @@ -356,11 +359,13 @@ describe('AttachmentPreviewListComponent with custom attachments', () => { hostFixture.nativeElement.querySelectorAll('.payment-link').length ).toBe(0); - const customAttachment = { + const customAttachment: Attachment = { type: 'custom', - subtype: 'payment', - value: '30$', - link: 'pay/me/or/else', + custom: { + subtype: 'payment', + value: '30$', + link: 'pay/me/or/else', + } as Attachment['custom'], }; const attachmentService = TestBed.inject(AttachmentService); attachmentService.customAttachments$.next([customAttachment]); @@ -375,11 +380,13 @@ describe('AttachmentPreviewListComponent with custom attachments', () => { const customTemplatesService = TestBed.inject(CustomTemplatesService); customTemplatesService.customAttachmentPreviewListTemplate$.next(undefined); - const customAttachment = { + const customAttachment: Attachment = { type: 'custom', - subtype: 'payment', - value: '30$', - link: 'pay/me/or/else', + custom: { + subtype: 'payment', + value: '30$', + link: 'pay/me/or/else', + } as Attachment['custom'], }; const attachmentService = TestBed.inject(AttachmentService); attachmentService.customAttachments$.next([customAttachment]); diff --git a/projects/stream-chat-angular/src/lib/attachment.service.spec.ts b/projects/stream-chat-angular/src/lib/attachment.service.spec.ts index a70bee33..f02a51e4 100644 --- a/projects/stream-chat-angular/src/lib/attachment.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/attachment.service.spec.ts @@ -1,10 +1,10 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { first } from 'rxjs/operators'; -import { AppSettings, Attachment } from 'stream-chat'; +import { Attachment } from 'stream-chat'; import { AttachmentService } from './attachment.service'; import { ChannelService } from './channel.service'; import { NotificationService } from './notification.service'; -import { AttachmentUpload } from './types'; +import { AppSettings, AttachmentUpload } from './types'; import { Subject } from 'rxjs'; import { ChatClientService } from './chat-client.service'; import { MessageService } from './message.service'; @@ -145,7 +145,8 @@ describe('AttachmentService', () => { })); it(`shouldn't try to delete custom attachments from CDN`, fakeAsync(() => { - const customAttachment = { + const customAttachment: Attachment = { + custom: {}, image_url: 'url/to/my/image', type: 'image', }; @@ -207,14 +208,14 @@ describe('AttachmentService', () => { spyOn(notificationService, 'addPermanentNotification'); const attachments = new Array(30) .fill(null) - .map(() => ({ type: 'custom' })); + .map(() => ({ type: 'custom', custom: {} })); service.customAttachments$.next(attachments); expect(notificationService.addPermanentNotification).not.toHaveBeenCalled(); service.customAttachments$.next([ ...service.customAttachments$.value, - { type: 'custom' }, + { type: 'custom', custom: {} }, ]); expect(notificationService.addPermanentNotification).toHaveBeenCalledWith( @@ -229,7 +230,7 @@ describe('AttachmentService', () => { spyOn(notificationService, 'addTemporaryNotification'); const attachments = new Array(28) .fill(null) - .map(() => ({ type: 'custom' })); + .map(() => ({ type: 'custom', custom: {} })); service.customAttachments$.next(attachments); expect(notificationService.addTemporaryNotification).not.toHaveBeenCalled(); @@ -254,7 +255,7 @@ describe('AttachmentService', () => { spyOn(notificationService, 'addTemporaryNotification'); const attachments = new Array(29) .fill(null) - .map(() => ({ type: 'custom' })); + .map(() => ({ type: 'custom', custom: {} })); service.customAttachments$.next(attachments); expect(notificationService.addTemporaryNotification).not.toHaveBeenCalled(); @@ -478,7 +479,7 @@ describe('AttachmentService', () => { const errorNotificationHideSpy = jasmine.createSpy(); service['attachmentLimitNotificationHide'] = errorNotificationHideSpy; const spy = jasmine.createSpy(); - service.customAttachments$.next([{ type: 'custom' }]); + service.customAttachments$.next([{ type: 'custom', custom: {} }]); service.attachmentUploads$.subscribe(spy); spy.calls.reset(); service.resetAttachmentUploads(); @@ -525,6 +526,7 @@ describe('AttachmentService', () => { ] as any as FileList); const customAttachment: Attachment = { + custom: {}, type: 'video', asset_url: 'url/to/my/video', thumb_url: 'url/to/my/thumb', @@ -534,10 +536,11 @@ describe('AttachmentService', () => { const customPaymentAttachment: Attachment = { type: 'custom', - // @ts-expect-error testing custom proerpty - subtype: 'payment', - value: '30$', - link: 'pay/me/or/else', + custom: { + subtype: 'payment', + value: '30$', + link: 'pay/me/or/else', + } as Attachment['custom'], }; service.customAttachments$.next([customPaymentAttachment]); @@ -547,29 +550,27 @@ describe('AttachmentService', () => { fallback: 'flower.png', image_url: 'http://url/to/img', type: 'image', - mime_type: 'image/png', + custom: { mime_type: 'image/png' }, }, { type: 'video', title: 'test.mp4', - file_size: 22233332, asset_url: 'http://url/to/file', thumb_url: 'http://url/to/thumb', - mime_type: 'video/mp4', + custom: { mime_type: 'video/mp4', file_size: 22233332 }, }, { title: 'note.txt', - file_size: 3272969, asset_url: 'http://url/to/data', type: 'file', thumb_url: undefined, - mime_type: 'plain/text', + custom: { mime_type: 'plain/text', file_size: 3272969 }, }, { type: 'video', asset_url: 'url/to/my/video', thumb_url: 'url/to/my/thumb', - isCustomAttachment: true, + custom: { isCustomAttachment: true }, }, customPaymentAttachment, ]); @@ -581,40 +582,39 @@ describe('AttachmentService', () => { messageService, 'isCustomAttachment' ).and.callThrough(); - const customAttachment = { + const customAttachment: Attachment = { type: 'custom', - subtype: 'payment', - value: '30$', - link: 'pay/me/or/else', + custom: { + subtype: 'payment', + value: '30$', + link: 'pay/me/or/else', + } as Attachment['custom'], }; - const attachments = [ + const attachments: Attachment[] = [ { fallback: 'flower.png', image_url: 'http://url/to/img', type: 'image', - mime_type: undefined, + custom: { mime_type: undefined }, }, { title: 'note.txt', - file_size: 3272969, asset_url: 'http://url/to/data', type: 'file', - mime_type: undefined, + custom: { mime_type: undefined, file_size: 3272969 }, }, { title: 'cute.mov', - file_size: 45367543, asset_url: 'http://url/to/video', type: 'video', thumb_url: 'http://url/to/poster', - mime_type: 'video/mov', + custom: { mime_type: 'video/mov', file_size: 45367543 }, }, { type: 'file', asset_url: 'url/to/my/file', title: 'my-file.pdf', - mime_type: 'application/pdf', - isCustomAttachment: true, + custom: { mime_type: 'application/pdf', isCustomAttachment: true }, }, customAttachment, ]; @@ -674,7 +674,11 @@ describe('AttachmentService', () => { }); it('should ignore URL attachments if creating from attachments', () => { - const urlAttachment = { type: 'image', title_link: 'title_link' }; + const urlAttachment: Attachment = { + type: 'image', + title_link: 'title_link', + custom: {}, + }; const spy = jasmine.createSpy(); service.attachmentUploads$.subscribe(spy); spy.calls.reset(); @@ -687,9 +691,8 @@ describe('AttachmentService', () => { const customAttachment: Attachment = { type: 'file', asset_url: 'url/to/my/file', - file_size: undefined, title: 'my-file.pdf', - mime_type: undefined, + custom: { file_size: undefined, mime_type: undefined }, }; const spy = jasmine.createSpy(); @@ -707,7 +710,10 @@ describe('AttachmentService', () => { type: undefined, } as unknown as File, type: 'file', - fromAttachment: { ...customAttachment, isCustomAttachment: true }, + fromAttachment: { + ...customAttachment, + custom: { ...customAttachment.custom, isCustomAttachment: true }, + }, thumb_url: undefined, }, ]); @@ -726,7 +732,7 @@ describe('AttachmentService', () => { image_upload_config: { size_limit: 3145728, }, - }); + } as unknown as AppSettings); let files = [{ name: 'test.pdf', type: 'application/pdf', size: 1048576 }]; let result = await service.filesSelected(files as any as FileList); @@ -778,7 +784,7 @@ describe('AttachmentService', () => { allowed_file_extensions: [], allowed_mime_types: [], }, - }); + } as unknown as AppSettings); let files = [{ name: 'test.pdf', type: 'application/pdf' }]; let result = await service.filesSelected(files as any as FileList); @@ -791,7 +797,7 @@ describe('AttachmentService', () => { image_upload_config: { blocked_mime_types: ['image/png'], }, - }); + } as unknown as AppSettings); files = [ { name: 'test.pdf', type: 'application/pdf' }, @@ -824,7 +830,7 @@ describe('AttachmentService', () => { image_upload_config: { allowed_file_extensions: ['.jpg', '.png'], }, - }); + } as unknown as AppSettings); files = [ { name: 'test.pdf', type: 'application/pdf' }, { name: 'test2.doc', type: 'application/msword' }, diff --git a/projects/stream-chat-angular/src/lib/attachment.service.ts b/projects/stream-chat-angular/src/lib/attachment.service.ts index 1538290b..9b77da60 100644 --- a/projects/stream-chat-angular/src/lib/attachment.service.ts +++ b/projects/stream-chat-angular/src/lib/attachment.service.ts @@ -8,18 +8,18 @@ import { shareReplay, take, } from 'rxjs'; -import { AppSettings, Attachment } from 'stream-chat'; +import { Attachment } from 'stream-chat'; import { ChannelService } from './channel.service'; import { isImageAttachment } from './is-image-attachment'; import { NotificationService } from './notification.service'; -import { AttachmentUpload, AudioRecording } from './types'; +import { AppSettings, AttachmentUpload, AudioRecording } from './types'; import { ChatClientService } from './chat-client.service'; import { MessageService } from './message.service'; /** * The `AttachmentService` manages the uploads of a message input. * - * You can read more about [uploads](/chat/docs/javascript/file_uploads/) in the Stream API documentation. You can use Stream's API or the dashboard to customize the [file](/chat/docs/javascript/app_setting_overview/#file-uploads) and [image upload](/chat/docs/javascript/app_setting_overview/#image-uploads) configuration. + * You can read more about [uploads](/chat/docs/javascript/file-uploads/) in the Stream API documentation. You can use Stream's API or the [dashboard](/signin/?product=chat) to customize the [file](/chat/docs/node/app-setting-overview/#file-uploads) and [image upload](/chat/docs/node/app-setting-overview/#image-uploads) configuration. */ @Injectable({ providedIn: 'root', @@ -126,8 +126,10 @@ export class AttachmentService { file: audioRecording.recording, previewUri: audioRecording.asset_url, extraData: { - duration: audioRecording.duration, - waveform_data: audioRecording.waveform_data, + custom: { + duration: audioRecording.duration, + waveform_data: audioRecording.waveform_data, + }, }, state: 'uploading' as const, type: 'voiceRecording' as const, @@ -142,7 +144,7 @@ export class AttachmentService { /** * Uploads the selected files, and creates preview for image files. The result is propagated throught the `attachmentUploads$` stream. - * @param fileList The files selected by the user, if you have Blobs instead of Files, you can convert them with this method: https://developer.mozilla.org/en-US/docs/Web/API/File/File + * @param fileList The files selected by the user, if you have Blobs instead of Files, you can convert them with the [File constructor](https://developer.mozilla.org/en-US/docs/Web/API/File/File) * @returns A promise with true or false. If false is returned the upload was canceled because of a client side error. The error is emitted via the `NotificationService`. */ async filesSelected(fileList: FileList | File[] | null) { @@ -210,7 +212,7 @@ export class AttachmentService { * Will set `isCustomAttachment` to `true` on the attachment. This is a non-standard field, other SDKs will ignore this property. */ addAttachment(attachment: Attachment) { - attachment.isCustomAttachment = true; + attachment.custom = { ...attachment.custom, isCustomAttachment: true }; this.createFromAttachments([attachment]); } @@ -239,7 +241,7 @@ export class AttachmentService { let result!: AttachmentUpload[]; if ( upload.state === 'success' && - !upload.fromAttachment?.isCustomAttachment + !upload.fromAttachment?.custom?.isCustomAttachment ) { try { await this.channelService.deleteAttachment(upload); @@ -271,22 +273,27 @@ export class AttachmentService { .map((r) => { let attachment: Attachment = { type: r.type, + custom: {}, }; if (r.fromAttachment) { return r.fromAttachment; } else { - attachment.mime_type = r.file?.type; + attachment.custom.mime_type = r.file?.type; if (r.type === 'image') { attachment.fallback = r.file?.name; attachment.image_url = r.url; } else { attachment.asset_url = r.url; attachment.title = r.file?.name; - attachment.file_size = r.file?.size; + attachment.custom.file_size = r.file?.size; attachment.thumb_url = r.thumb_url; } if (r.extraData) { - attachment = { ...attachment, ...r.extraData }; + attachment = { + ...attachment, + ...r.extraData, + custom: { ...attachment.custom, ...r.extraData.custom }, + }; } } @@ -313,14 +320,14 @@ export class AttachmentService { builtInAttachments.forEach((attachment) => { if (isImageAttachment(attachment)) { attachmentUploads.push({ - url: (attachment.img_url || + url: (attachment.custom?.img_url || attachment.thumb_url || attachment.image_url) as string, state: 'success', type: 'image', file: { name: attachment.fallback, - type: attachment.mime_type, + type: attachment.custom?.mime_type, } as File, fromAttachment: attachment, }); @@ -330,8 +337,8 @@ export class AttachmentService { state: 'success', file: { name: attachment.title, - size: attachment.file_size, - type: attachment.mime_type, + size: attachment.custom?.file_size, + type: attachment.custom?.mime_type, } as File, type: attachment.type, thumb_url: attachment.thumb_url, @@ -343,13 +350,15 @@ export class AttachmentService { state: 'success', file: { name: attachment.title, - size: attachment.file_size, - type: attachment.mime_type, + size: attachment.custom?.file_size, + type: attachment.custom?.mime_type, } as File, type: 'voiceRecording', extraData: { - duration: attachment.duration, - waveform_data: attachment.waveform_data, + custom: { + duration: attachment.custom?.duration, + waveform_data: attachment.custom?.waveform_data, + }, }, }); } @@ -378,10 +387,9 @@ export class AttachmentService { upload.previewUri = uri; this.attachmentUploadsSubject.next([...attachmentUploads]); } catch (e: unknown) { - this.chatClientService?.chatClient?.logger( - 'error', + this.chatClientService?.logger?.error( e instanceof Error ? e.message : `Can't create image preview`, - { error: e, tag: ['AttachmentService'] } + { error: e } ); } } diff --git a/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.spec.ts b/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.spec.ts index 9565939b..a590787a 100644 --- a/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Channel } from 'stream-chat'; +import { Channel, UserResponse } from 'stream-chat'; import { AvatarComponent } from '../avatar/avatar.component'; import { AvatarPlaceholderComponent } from './avatar-placeholder.component'; @@ -34,7 +34,7 @@ describe('AvatarPlaceholderComponent', () => { component.name = 'name'; component.type = 'user'; component.location = 'autocomplete-item'; - const user = { id: 'user-id' }; + const user = { id: 'user-id' } as UserResponse; component.user = user; component.ngOnChanges(); fixture.detectChanges(); diff --git a/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.ts b/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.ts index 465ad70b..87e06ed5 100644 --- a/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.ts +++ b/projects/stream-chat-angular/src/lib/avatar-placeholder/avatar-placeholder.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges } from '@angular/core'; -import { Channel, User } from 'stream-chat'; +import { Channel, UserResponse } from 'stream-chat'; import { CustomTemplatesService } from '../custom-templates.service'; import { AvatarContext, AvatarLocation, AvatarType } from '../types'; @@ -31,7 +31,7 @@ export class AvatarPlaceholderComponent implements OnChanges { /** * The user the avatar belongs to (if avatar of a user is displayed) */ - @Input() user?: User; + @Input() user?: UserResponse; /** * The type of the avatar: channel if channel avatar is displayed, user if user avatar is displayed */ diff --git a/projects/stream-chat-angular/src/lib/avatar/avatar.component.spec.ts b/projects/stream-chat-angular/src/lib/avatar/avatar.component.spec.ts index 2555d3e0..3283e93b 100644 --- a/projects/stream-chat-angular/src/lib/avatar/avatar.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/avatar/avatar.component.spec.ts @@ -1,4 +1,5 @@ import { SimpleChange } from '@angular/core'; +import { ChannelMemberResponse, Event, UserResponse } from 'stream-chat'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Observable, Subject, of } from 'rxjs'; import { ChatClientService, ClientEvent } from '../chat-client.service'; @@ -123,7 +124,7 @@ describe('AvatarComponent', () => { expect(component.initials).toBe(''); let channel = generateMockChannels()[0]; - channel.data!.name = undefined; + channel.data!.custom!.name = undefined; component.channel = channel; component.type = 'channel'; component.ngOnChanges({ @@ -136,16 +137,19 @@ describe('AvatarComponent', () => { expect(component.initials).toBe('#'); channel = generateMockChannels()[0]; - channel.data!.name = 'Test'; + channel.data!.custom!.name = 'Test'; channel.state.members = { otheruser: { user_id: 'otheruser', - user: { id: 'otheruser', name: 'Jack' }, - }, + user: { id: 'otheruser', name: 'Jack' } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; component.type = 'channel'; @@ -159,16 +163,19 @@ describe('AvatarComponent', () => { expect(component.initials).toBe('T'); channel = generateMockChannels()[0]; - channel.data!.name = undefined; + channel.data!.custom!.name = undefined; channel.state.members = { otheruser: { user_id: 'otheruser', - user: { id: 'otheruser', name: 'Jack' }, - }, + user: { id: 'otheruser', name: 'Jack' } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; component.ngOnChanges({ @@ -189,12 +196,12 @@ describe('AvatarComponent', () => { channel.state.members = { otheruser: { user_id: 'otheruser', - user: { id: 'otheruser', name: 'Jack' }, - }, + user: { id: 'otheruser', name: 'Jack' } as UserResponse, + } as ChannelMemberResponse, otheruser2: { user_id: 'otheruser2', - user: { id: 'otheruser2', name: 'Sara' }, - }, + user: { id: 'otheruser2', name: 'Sara' } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; component.ngOnChanges({ @@ -211,12 +218,19 @@ describe('AvatarComponent', () => { channel.state.members = { otheruser: { user_id: 'otheruser', - user: { id: 'otheruser', name: 'Jack', image: 'url/to/img' }, - }, + user: { + id: 'otheruser', + name: 'Jack', + image: 'url/to/img', + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.imageUrl = undefined; component.channel = channel; @@ -245,8 +259,8 @@ describe('AvatarComponent', () => { channel.state.members['thirduser'] = { user_id: 'thirduser', - user: { id: 'thirduser', image: 'profile/img' }, - }; + user: { id: 'thirduser', image: 'profile/img' } as UserResponse, + } as ChannelMemberResponse; component.ngOnChanges({ channel: {} as SimpleChange, }); @@ -265,12 +279,15 @@ describe('AvatarComponent', () => { name: 'Jack', image: 'url/to/img', online: true, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; void component.ngOnChanges({ channel: {} as SimpleChange }); @@ -290,12 +307,15 @@ describe('AvatarComponent', () => { name: 'Jack', image: 'url/to/img', online: false, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; void component.ngOnChanges({ channel: {} as SimpleChange }); @@ -315,12 +335,15 @@ describe('AvatarComponent', () => { name: 'Jack', image: 'url/to/img', online: false, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; void component.ngOnChanges({ channel: {} as SimpleChange }); @@ -333,8 +356,8 @@ describe('AvatarComponent', () => { eventType: 'user.presence.changed', event: { type: 'user.presence.changed', - user: { id: 'otheruser', online: true }, - }, + user: { id: 'otheruser', online: true } as UserResponse, + } as Event, }); fixture.detectChanges(); @@ -351,8 +374,8 @@ describe('AvatarComponent', () => { name: 'Jack', image: 'url/to/img', online: true, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, thirduser: { user_id: 'thirduser', user: { @@ -360,12 +383,15 @@ describe('AvatarComponent', () => { name: 'John', image: 'url/to/img', online: true, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; void component.ngOnChanges({ channel: {} as SimpleChange }); @@ -385,12 +411,15 @@ describe('AvatarComponent', () => { name: 'Jack', image: 'url/to/img', online: true, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; component.showOnlineIndicator = false; @@ -424,12 +453,15 @@ describe('AvatarComponent', () => { name: 'Jack', image: 'url/to/img', online: true, - }, - }, + } as UserResponse, + } as ChannelMemberResponse, [chatClientServiceMock.chatClient.user.id]: { user_id: chatClientServiceMock.chatClient.user.id, - user: { id: chatClientServiceMock.chatClient.user.id, name: 'Sara' }, - }, + user: { + id: chatClientServiceMock.chatClient.user.id, + name: 'Sara', + } as UserResponse, + } as ChannelMemberResponse, }; component.channel = channel; void component.ngOnChanges({ channel: {} as SimpleChange }); diff --git a/projects/stream-chat-angular/src/lib/avatar/avatar.component.ts b/projects/stream-chat-angular/src/lib/avatar/avatar.component.ts index 5a8969b5..e4bbcd66 100644 --- a/projects/stream-chat-angular/src/lib/avatar/avatar.component.ts +++ b/projects/stream-chat-angular/src/lib/avatar/avatar.component.ts @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { Channel, User } from 'stream-chat'; +import { Channel, UserResponse } from 'stream-chat'; import { ChatClientService } from '../chat-client.service'; import { AvatarLocation, AvatarType } from '../types'; @@ -45,7 +45,7 @@ export class AvatarComponent /** * The user the avatar belongs to (if avatar of a user is displayed) */ - @Input() user?: User; + @Input() user?: UserResponse; /** * The type of the avatar: channel if channel avatar is displayed, user if user avatar is displayed */ @@ -128,8 +128,8 @@ export class AvatarComponent if (this.type === 'user') { result = this.name?.toString() || ''; } else if (this.type === 'channel') { - if (this.channel?.data?.name) { - result = this.channel?.data?.name; + if (this.channel?.data?.custom?.name) { + result = this.channel?.data?.custom?.name; } else { const otherMember = this.getOtherMemberIfOneToOneChannel(); if (otherMember) { @@ -158,9 +158,13 @@ export class AvatarComponent this.isOnlineSubscription = this.chatClientService.events$ .pipe(filter((e) => e.eventType === 'user.presence.changed')) .subscribe((event) => { - if (event.event.user?.id === otherMember.id) { + const user = + event.event.type === 'user.presence.changed' + ? event.event.user + : undefined; + if (user?.id === otherMember.id) { this.ngZone.run(() => { - this.isOnline = event.event.user?.online || false; + this.isOnline = user?.online || false; }); } }); 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 a14fce95..c50c2cf5 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 @@ -3,7 +3,7 @@ diff --git a/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.spec.ts b/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.spec.ts index 0795fd04..9b90528f 100644 --- a/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel-header/channel-header.component.spec.ts @@ -27,7 +27,7 @@ describe('ChannelHeaderComponent', () => { activeChannel$: new Subject(), }; chatClientServiceMock = { - chatClient: { user: { id: 'currentUser' } }, + chatClient: { user: { id: 'currentUser' } as UserResponse }, }; TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], @@ -98,9 +98,12 @@ describe('ChannelHeaderComponent', () => { const channel = { id: 'running-club', data: { - name: 'Running club', - image: 'http://url/to/img', + custom: { + name: 'Running club', + image: 'http://url/to/img', + }, }, + state: {}, } as any as Channel; channelServiceMock.activeChannel$.next(channel); const avatar = queryAvatar(); 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 39ff7f32..38ae6ff7 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 @@ -14,7 +14,7 @@ import { getChannelDisplayText } from '../get-channel-display-text'; import { ChannelActionsContext, ChannelHeaderInfoContext } from '../types'; /** - * The `ChannelHeader` component displays the avatar and name of the currently active channel along with member and watcher information. You can read about [the difference between members and watchers](/chat/docs/javascript/watch_channel/#watchers-vs-members) in the platform documentation. Please note that number of watchers is only displayed if the user has [`connect-events` capability](/chat/docs/javascript/channel_capabilities/) + * The `ChannelHeader` component displays the avatar and name of the currently active channel along with member and watcher information. You can read about [the difference between members and watchers](/chat/docs/javascript/creating-channels/#watching-channels) in the platform documentation. Please note that number of watchers is only displayed if the user has [`connect-events` capability](/chat/docs/node/chat-permission-policies/) */ @Component({ selector: 'stream-channel-header', @@ -90,11 +90,11 @@ export class ChannelHeaderComponent implements OnInit, OnDestroy { } return getChannelDisplayText( this.activeChannel, - this.chatClientService.chatClient.user! + this.chatClientService.chatClient.user ); } get avatarName() { - return this.activeChannel?.data?.name; + return this.activeChannel?.data?.custom?.name; } } diff --git a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts index 28c35586..0842fd57 100644 --- a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts @@ -1,7 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; -import { Event, UserResponse } from 'stream-chat'; +import { + dateToNs, + nsToDate, + Event, + LocalMessage, + UserResponse, +} from 'stream-chat'; import { AvatarPlaceholderComponent } from '../avatar-placeholder/avatar-placeholder.component'; import { AvatarComponent } from '../avatar/avatar.component'; import { ChannelService } from '../channel.service'; @@ -10,14 +16,30 @@ import { generateMockChannels, mockChannelService, MockChannelService, + MockChannel, mockMessage, } from '../mocks'; +import { toLocalMessage } from '../local-message'; import { ChannelPreviewComponent } from './channel-preview.component'; import { Observable, Subject, of } from 'rxjs'; import { DateParserService } from '../date-parser.service'; import { IconModule } from '../icon/icon.module'; import { IconPlaceholderComponent } from '../icon/icon-placeholder/icon-placeholder.component'; +const getLastMessage = (channel: MockChannel) => { + const items = channel.messagePaginator.items!; + return items[items.length - 1]; +}; + +const updateLastMessage = ( + channel: MockChannel, + update: (message: LocalMessage) => LocalMessage +) => { + const items = [...channel.messagePaginator.items!]; + items[items.length - 1] = update(items[items.length - 1]); + channel.messagePaginator.setItems(items); +}; + describe('ChannelPreviewComponent', () => { let fixture: ComponentFixture; let component: ChannelPreviewComponent; @@ -38,7 +60,7 @@ describe('ChannelPreviewComponent', () => { channelServiceMock = mockChannelService(); chatClientServiceMock = { events$: new Subject(), - chatClient: { user: { id: 'currentUser' } }, + chatClient: { user: { id: 'currentUser' } as UserResponse }, user$: of({ id: 'currentUser' }), }; TestBed.configureTestingModule({ @@ -107,8 +129,8 @@ describe('ChannelPreviewComponent', () => { countUnreadSpy.and.returnValue(1); const newMessage = mockMessage(); - channel.state.latestMessages.push(newMessage); - channel.handleEvent('message.new', { message: newMessage }); + newMessage.id = 'new-message'; + channel.messagePaginator.ingestItem(toLocalMessage(newMessage)); fixture.detectChanges(); expect(container?.classList.contains(unreadClass)).toBeTrue(); @@ -185,6 +207,7 @@ describe('ChannelPreviewComponent', () => { chatClientServiceMock.events$.next({ eventType: 'notification.mark_unread', event: { + type: 'notification.mark_unread', channel_id: channel.id, } as Event, }); @@ -211,8 +234,8 @@ describe('ChannelPreviewComponent', () => { fixture.detectChanges(); const avatar = queryAvatar(); - expect(avatar.imageUrl).toBe(channel.data?.image as string); - expect(avatar.name).toBe(channel.data?.name); + expect(avatar.imageUrl).toBe(channel.data?.custom?.image as string); + expect(avatar.name).toBe(channel.data?.custom?.name); expect(avatar.type).toBe('channel'); expect(avatar.channel).toBe(channel); expect(avatar.location).toBe('channel-preview'); @@ -220,13 +243,13 @@ describe('ChannelPreviewComponent', () => { it('should display channel display text', () => { const channel = generateMockChannels()[0]; - channel.data!.name = undefined; + channel.data!.custom!.name = undefined; channel.state.members = { user1: { user: { id: 'user1', name: 'Ben' } }, [chatClientServiceMock.chatClient.user.id]: { user: { id: chatClientServiceMock.chatClient.user.id }, }, - }; + } as unknown as MockChannel['state']['members']; component.channel = channel; fixture.detectChanges(); @@ -236,7 +259,7 @@ describe('ChannelPreviewComponent', () => { describe('should display latest message of channel', () => { it('if channel has no messages', () => { const channel = generateMockChannels()[0]; - channel.state.latestMessages = []; + channel.messagePaginator.setItems([]); channelServiceMock.activeChannel$.next(channel); component.channel = channel; fixture.detectChanges(); @@ -251,19 +274,17 @@ describe('ChannelPreviewComponent', () => { fixture.detectChanges(); expect(queryLatestMessage()?.textContent).toContain( - channel.state.latestMessages[channel.state.latestMessages.length - 1] - .text + getLastMessage(channel).text ); }); it('if last message has attachments', () => { const channel = generateMockChannels()[0]; - channel.state.latestMessages[ - channel.state.latestMessages.length - 1 - ].text = undefined; - channel.state.latestMessages[ - channel.state.latestMessages.length - 1 - ].attachments = [{}]; + updateLastMessage(channel, (m) => ({ + ...m, + text: '', + attachments: [{ custom: {} }], + })); channelServiceMock.activeChannel$.next(channel); component.channel = channel; fixture.detectChanges(); @@ -277,9 +298,9 @@ describe('ChannelPreviewComponent', () => { component.channel = channel; fixture.detectChanges(); const newMessage = mockMessage(); + newMessage.id = 'new-message'; newMessage.text = 'this is the text of new message'; - channel.state.latestMessages.push(newMessage); - channel.handleEvent('message.new', { message: newMessage }); + channel.messagePaginator.ingestItem(toLocalMessage(newMessage)); fixture.detectChanges(); expect(queryLatestMessage()?.textContent).toContain(newMessage.text); @@ -290,11 +311,11 @@ describe('ChannelPreviewComponent', () => { channelServiceMock.activeChannel$.next(channel); component.channel = channel; fixture.detectChanges(); - const updatedMessage = mockMessage(); - updatedMessage.text = 'this is the text of new message'; - channel.state.latestMessages[channel.state.latestMessages.length - 1] = - updatedMessage; - channel.handleEvent('message.updated', { message: updatedMessage }); + const updatedMessage = { + ...getLastMessage(channel), + text: 'this is the text of new message', + }; + channel.messagePaginator.ingestItem(updatedMessage); fixture.detectChanges(); expect(queryLatestMessage()?.textContent).toContain(updatedMessage.text); @@ -305,11 +326,11 @@ describe('ChannelPreviewComponent', () => { channelServiceMock.activeChannel$.next(channel); component.channel = channel; fixture.detectChanges(); - const deletedMessage = mockMessage(); - deletedMessage.deleted_at = new Date(); - channel.state.latestMessages[channel.state.latestMessages.length - 1] = - deletedMessage; - channel.handleEvent('message.updated', { message: deletedMessage }); + const deletedMessage = { + ...getLastMessage(channel), + deleted_at: dateToNs(new Date()), + }; + channel.messagePaginator.ingestItem(deletedMessage); fixture.detectChanges(); expect(queryLatestMessage()?.textContent).toContain('Message deleted'); @@ -321,10 +342,11 @@ describe('ChannelPreviewComponent', () => { channelServiceMock.activeChannel$.next(channel); component.channel = channel; fixture.detectChanges(); - const updatedMessage = mockMessage(); - updatedMessage.text = 'this is the text of new message'; - channel.state.latestMessages[0] = updatedMessage; - channel.handleEvent('message.updated', updatedMessage); + const updatedMessage = { + ...channel.messagePaginator.items![0], + text: 'this is the text of new message', + }; + channel.messagePaginator.ingestItem(updatedMessage); fixture.detectChanges(); expect(queryLatestMessage()?.textContent).not.toContain( @@ -337,8 +359,8 @@ describe('ChannelPreviewComponent', () => { channelServiceMock.activeChannel$.next(channel); component.channel = channel; fixture.detectChanges(); - channel.state.latestMessages = []; - channel.handleEvent('channel.truncated', { type: 'channel.truncated' }); + // stream-chat clears the paginator on truncate + channel.messagePaginator.setItems([]); fixture.detectChanges(); expect(queryLatestMessage()?.textContent).toContain('Nothing yet...'); @@ -346,13 +368,14 @@ describe('ChannelPreviewComponent', () => { it('should display translated message', () => { const channel = generateMockChannels()[0]; - const latestMessage = - channel.state.latestMessages[channel.state.latestMessages.length - 1]; - latestMessage.i18n = { - hu_text: 'Hogy vagy?', - language: 'en', - }; - latestMessage.user!.id += 'not'; + updateLastMessage(channel, (m) => ({ + ...m, + i18n: { + hu_text: 'Hogy vagy?', + language: 'en', + }, + user: { ...m.user, id: m.user.id + 'not' }, + })); channel.data!.auto_translation_language = 'hu'; channelServiceMock.activeChannel$.next(channel); component.channel = channel; @@ -395,12 +418,12 @@ describe('ChannelPreviewComponent', () => { it('should set last message status', () => { const channel = generateMockChannels()[0]; - const latestMessage = - channel.state.messages[channel.state.messages.length - 1]; + const latestMessage = getLastMessage(channel); component.channel = channel; fixture.detectChanges(); - expect(component.latestMessage).toBe(latestMessage); + expect(component.latestMessage?.id).toBe(latestMessage.id); + expect(component.latestMessage?.created_at).toEqual(jasmine.any(Number)); expect(component.latestMessageStatus).toBe('delivered'); @@ -408,24 +431,21 @@ describe('ChannelPreviewComponent', () => { last_read: latestMessage.created_at, unread_messages: 0, user: { id: 'otheruser' }, - }; + } as unknown as MockChannel['state']['read'][string]; channel.handleEvent('message.read', { message: latestMessage, }); expect(component.latestMessageStatus).toBe('read'); - latestMessage.type = 'deleted'; - channel.handleEvent('message.deleted', { - message: latestMessage, - }); + channel.messagePaginator.ingestItem({ ...latestMessage, type: 'deleted' }); expect(component.latestMessageStatus).toBe(undefined); - latestMessage.type = 'regular'; - latestMessage.user!.id = 'other-user'; - channel.handleEvent('message.updated', { - message: latestMessage, + channel.messagePaginator.ingestItem({ + ...latestMessage, + type: 'regular', + user: { ...latestMessage.user, id: 'other-user' }, }); expect(component.latestMessageStatus).toBe(undefined); @@ -437,17 +457,20 @@ describe('ChannelPreviewComponent', () => { spyOn(dateParser, 'parseTime'); const channel = generateMockChannels()[0]; - const latestMessage = - channel.state.messages[channel.state.messages.length - 1]; - latestMessage.created_at = new Date(); + const now = new Date(); + updateLastMessage(channel, (m) => ({ ...m, created_at: dateToNs(now) })); component.channel = channel; component.ngOnInit(); - expect(dateParser.parseTime).toHaveBeenCalledWith(latestMessage.created_at); + // stream-chat's nsToMs floors, so a Date -> ns -> Date round trip can lose 1ms + expect(dateParser.parseTime).toHaveBeenCalledWith(nsToDate(dateToNs(now))); - latestMessage.created_at = new Date('2024-04-02T03:24:00'); - component.ngOnInit(); + const earlier = new Date('2024-04-02T03:24:00'); + updateLastMessage(channel, (m) => ({ + ...m, + created_at: dateToNs(earlier), + })); - expect(dateParser.parseDate).toHaveBeenCalledWith(latestMessage.created_at); + expect(dateParser.parseDate).toHaveBeenCalledWith(earlier); }); }); diff --git a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts index bd2ee312..64b4c89a 100644 --- a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts +++ b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.ts @@ -1,7 +1,7 @@ import { Component, Input, NgZone, OnDestroy, OnInit } from '@angular/core'; import { Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { Channel, Event, FormatMessageResponse } from 'stream-chat'; +import { Channel, LocalMessage, nsToDate } from 'stream-chat'; import { ChannelService } from '../channel.service'; import { getChannelDisplayText } from '../get-channel-display-text'; import { ChatClientService } from '../chat-client.service'; @@ -32,7 +32,7 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { latestMessageText: string = 'streamChat.Nothing yet...'; latestMessageStatus?: 'delivered' | 'read'; latestMessageTime?: string; - latestMessage?: FormatMessageResponse; + latestMessage?: LocalMessage; displayAs: 'text' | 'html'; userId?: string; private subscriptions: (Subscription | { unsubscribe: () => void })[] = []; @@ -63,26 +63,20 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { (this.isActive = activeChannel?.id === this.channel?.id) ) ); - const messages = this.channel?.state?.latestMessages; - if (messages && messages.length > 0) { - this.setLatestMessage(messages[messages.length - 1]); - } - this.updateUnreadState(); - const capabilities = - (this.channel?.data?.own_capabilities as string[]) || []; + const capabilities = this.channel?.data?.own_capabilities || []; this.canSendReadEvents = capabilities.indexOf('read-events') !== -1; - this.subscriptions.push( - this.channel!.on('message.new', this.handleMessageEvent.bind(this)) - ); - this.subscriptions.push( - this.channel!.on('message.updated', this.handleMessageEvent.bind(this)) - ); - this.subscriptions.push( - this.channel!.on('message.deleted', this.handleMessageEvent.bind(this)) - ); - this.subscriptions.push( - this.channel!.on('channel.truncated', this.handleMessageEvent.bind(this)) - ); + // The latest message is tracked by stream-chat (new, updated, deleted messages, truncate) + const unsubscribeFromLatestMessage = + this.channel!.messagePaginator.aggregateState.subscribeWithSelector( + (state) => ({ latestMessage: state.latestMessage }), + ({ latestMessage }) => { + this.ngZone.run(() => { + this.setLatestMessage(latestMessage ?? undefined); + this.updateUnreadState(); + }); + } + ); + this.subscriptions.push({ unsubscribe: unsubscribeFromLatestMessage }); this.subscriptions.push( this.channel!.on('message.read', () => this.ngZone.run(() => { @@ -96,8 +90,8 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { .pipe( filter( (e) => - e.eventType === 'notification.mark_unread' && - this.channel!.id === e.event?.channel_id + e.event.type === 'notification.mark_unread' && + this.channel!.id === e.event.channel_id ) ) .subscribe(() => { @@ -114,11 +108,11 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { } get avatarImage() { - return this.channel?.data?.image; + return this.channel?.data?.custom?.image; } get avatarName() { - return this.channel?.data?.name; + return this.channel?.data?.custom?.name; } get title() { @@ -127,7 +121,7 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { } return getChannelDisplayText( this.channel, - this.chatClientService.chatClient.user! + this.chatClientService.chatClient.user ); } @@ -135,35 +129,17 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { void this.channelService.setAsActiveChannel(this.channel!); } - private handleMessageEvent(event: Event) { - this.ngZone.run(() => { - if (this.channel?.state.latestMessages.length === 0) { - this.latestMessage = undefined; - this.latestMessageStatus = undefined; - this.latestMessageText = 'streamChat.Nothing yet...'; - this.latestMessageTime = undefined; - return; - } - const latestMessage = - this.channel?.state.latestMessages[ - this.channel?.state.latestMessages.length - 1 - ]; - if (!event.message || latestMessage?.id !== event.message.id) { - return; - } - this.setLatestMessage(latestMessage); - this.updateUnreadState(); - }); - } - - private setLatestMessage(message?: FormatMessageResponse) { + private setLatestMessage(localMessage?: LocalMessage) { + const message = localMessage; this.latestMessage = message; - if (message?.deleted_at) { + if (!message) { + this.latestMessageText = 'streamChat.Nothing yet...'; + } else if (message?.deleted_at) { this.latestMessageText = 'streamChat.Message deleted'; } else if (message?.text) { this.latestMessageText = getMessageTranslation( - message, + localMessage, this.channel, this.chatClientService.chatClient.user ) || message.text; @@ -173,12 +149,10 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { this.latestMessageText = `📊 ${message.poll?.name}`; } if (this.latestMessage && this.latestMessage.type === 'regular') { - this.latestMessageTime = isOnSeparateDate( - new Date(), - this.latestMessage.created_at - ) - ? this.dateParser.parseDate(this.latestMessage.created_at) - : this.dateParser.parseTime(this.latestMessage.created_at); + const createdAt = nsToDate(this.latestMessage.created_at); + this.latestMessageTime = isOnSeparateDate(new Date(), createdAt) + ? this.dateParser.parseDate(createdAt) + : this.dateParser.parseTime(createdAt); } else { this.latestMessageTime = undefined; } diff --git a/projects/stream-chat-angular/src/lib/channel-query.ts b/projects/stream-chat-angular/src/lib/channel-query.ts index 8966770a..358ca1aa 100644 --- a/projects/stream-chat-angular/src/lib/channel-query.ts +++ b/projects/stream-chat-angular/src/lib/channel-query.ts @@ -2,7 +2,7 @@ import { Channel, ChannelFilters, ChannelOptions, - ChannelSort, + SortParamRequest, } from 'stream-chat'; import { ChannelService } from './channel.service'; import { @@ -21,7 +21,7 @@ export class ChannelQuery { * * The method will be called with the result of the latest channel query. * - * You can return either an offset, or a filter using the [`$lte`/`$gte` operator](/chat/docs/javascript/query_syntax_operators/). If you return a filter, it will be merged with the filter provided for the `init` method. + * You can return either an offset, or a filter using the [`$lte`/`$gte` operator](/docs/platform/query-syntax-operators/). If you return a filter, it will be merged with the filter provided for the `init` method. */ customPaginator?: (channelQueryResult: Channel[]) => NextPageConfiguration; private nextPageConfiguration?: NextPageConfiguration; @@ -30,7 +30,9 @@ export class ChannelQuery { private chatService: ChatClientService, private channelService: ChannelService, private filters: ChannelFilters, - private sort: ChannelSort = { last_message_at: -1 }, + private sort: SortParamRequest[] = [ + { field: 'last_message_at', direction: -1 }, + ], private options: ChannelOptions = { limit: 25, state: true, @@ -65,11 +67,11 @@ export class ChannelQuery { filters = this.filters; options = this.options; } - const channels = await this.chatService.chatClient.queryChannels( - filters, - this.sort || {}, - options - ); + const channels = await this.chatService.chatClient.queryChannelsAndHydrate({ + ...options, + filter_conditions: filters, + sort: this.sort, + }); this.setNextPageConfiguration(channels); const currentActiveChannel = this.channelService.activeChannel; @@ -78,16 +80,8 @@ export class ChannelQuery { currentActiveChannel && !channels.find((c) => c.cid === currentActiveChannel?.cid) ) { - try { - await currentActiveChannel.watch(); - channels.unshift(currentActiveChannel); - } catch (error) { - this.chatService.chatClient.logger( - 'warn', - 'Unable to refetch active channel after state recover', - error as Record - ); - } + // The active channel is activated, so stream-chat reloads it after reconnect, we just keep it in the list + channels.unshift(currentActiveChannel); } return { diff --git a/projects/stream-chat-angular/src/lib/channel.service.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.spec.ts index b912b5cb..0f5bbcac 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -1,48 +1,55 @@ -import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; -import { Subject } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { Observable, Subject } from 'rxjs'; import { first, take } from 'rxjs/operators'; import { + Attachment, Channel, ChannelMemberResponse, + CustomMessageData, ChannelOptions, - ChannelResponse, - ChannelSort, + dateToNs, + nsToDate, Event, - FormatMessageResponse, - Message, + LocalMessage, ReactionResponse, - SendMessageAPIResponse, + SortParamRequest, UserResponse, } from 'stream-chat'; import { ChannelService } from './channel.service'; import { ChatClientService, ClientEvent } from './chat-client.service'; import { generateMockChannels, - generateMockMessages, + generateMockLocalMessages, MockChannel, mockCurrentUser, + MockMessagePaginator, mockMessage, + MockStateStore, + mockStreamChatClient, + MockStreamChatClient, + MockThread, } from './mocks'; import { NotificationService } from './notification.service'; +import { toLocalMessage } from './local-message'; import { AttachmentUpload, MessageInput, StreamMessage } from './types'; +type TestChatClient = Omit< + MockStreamChatClient, + 'channel' | 'channelManager' +> & { + channel: jasmine.Spy; + channelManager: { get: jasmine.Spy }; + activeChannels: { [key: string]: Channel }; +}; + describe('ChannelService', () => { let service: ChannelService; - let mockChatClient: { - queryChannels: jasmine.Spy; - channel: jasmine.Spy; - updateMessage: jasmine.Spy; - deleteMessage: jasmine.Spy; - userID: string; - pinMessage: jasmine.Spy; - unpinMessage: jasmine.Spy; - activeChannels: { [key: string]: Channel }; - }; + let mockChatClient: TestChatClient; let events$: Subject; - let connectionState$: Subject<'online' | 'offline'>; + let logger: { warn: jasmine.Spy; error: jasmine.Spy; info: jasmine.Spy }; let init: ( c?: Channel[], - sort?: ChannelSort, + sort?: SortParamRequest[], options?: ChannelOptions, mockChannelQuery?: Function, shouldSetActiveChannel?: boolean @@ -50,30 +57,50 @@ describe('ChannelService', () => { let user: UserResponse; const filters = { type: 'messaging' }; + const getActiveChannel = () => service.activeChannel as MockChannel; + const getLatestValue = (observable: Observable) => { + let value!: T; + observable.pipe(take(1)).subscribe((v) => (value = v)); + return value; + }; + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + const waitForMarkReadThrottle = () => + new Promise((resolve) => { + setTimeout(resolve, service['markReadThrottleTime']); + }); + const toLocal = (m: StreamMessage) => toLocalMessage(m); + const mockPaginator = (c: Channel) => + c.messagePaginator as unknown as MockMessagePaginator; + beforeEach(() => { user = mockCurrentUser(); - connectionState$ = new Subject<'online' | 'offline'>(); + const client = mockStreamChatClient(); + const activeChannels: { [key: string]: Channel } = {}; mockChatClient = { - queryChannels: jasmine - .createSpy() - .and.returnValue(generateMockChannels()), + ...client, channel: jasmine.createSpy(), - updateMessage: jasmine.createSpy(), - deleteMessage: jasmine.createSpy(), - userID: user.id, - pinMessage: jasmine.createSpy(), - unpinMessage: jasmine.createSpy(), - activeChannels: {}, + channelManager: { + get: jasmine + .createSpy() + .and.callFake((cid: string) => activeChannels[cid]), + }, + activeChannels, }; + mockChatClient.queryChannels.and.returnValue(generateMockChannels()); events$ = new Subject(); + logger = { + warn: jasmine.createSpy(), + error: jasmine.createSpy(), + info: jasmine.createSpy(), + }; TestBed.configureTestingModule({ providers: [ { provide: ChatClientService, useValue: { - chatClient: { ...mockChatClient, user }, + chatClient: mockChatClient, events$, - connectionState$, + logger, }, }, NotificationService, @@ -82,7 +109,7 @@ describe('ChannelService', () => { service = TestBed.inject(ChannelService); init = async ( channels?: Channel[], - sort?: ChannelSort, + sort?: SortParamRequest[], options?: ChannelOptions, mockChannelQuery?: Function, shouldSetActiveChannel?: boolean @@ -99,14 +126,41 @@ describe('ChannelService', () => { }; }); - it('should use provided sort params', async () => { - const sort: ChannelSort = { last_message_at: -1 }; + it('should pass the provided sort params to stream-chat unchanged', async () => { + const sort: SortParamRequest[] = [ + { field: 'last_message_at', direction: -1 }, + ]; await init(undefined, sort); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - jasmine.any(Object), - sort, - jasmine.any(Object) + expect( + mockChatClient.queryChannelsAndHydrate.calls.mostRecent().args[0].sort + ).toBe(sort); + + mockChatClient.queryChannelsAndHydrate.calls.reset(); + service.reset(); + const multiFieldSort: SortParamRequest[] = [ + { field: 'pinned_at', direction: -1 }, + { field: 'updated_at', direction: 1 }, + ]; + await init(undefined, multiFieldSort); + + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ + sort: [ + { field: 'pinned_at', direction: -1 }, + { field: 'updated_at', direction: 1 }, + ], + }) + ); + }); + + it('should use default sort params', async () => { + await init(); + + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ + sort: [{ field: 'last_message_at', direction: -1 }], + }) ); }); @@ -114,9 +168,7 @@ describe('ChannelService', () => { const options: ChannelOptions = { limit: 5 }; await init(undefined, undefined, options); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - jasmine.any(Object), - jasmine.any(Object), + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( jasmine.objectContaining(options) ); }); @@ -124,13 +176,24 @@ describe('ChannelService', () => { it('should use provided filter params', async () => { await init(); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - filters, - jasmine.any(Object), - jasmine.any(Object) + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ filter_conditions: filters }) ); }); + it('should register thread subscriptions and configure message paginators on init', async () => { + await init(undefined, undefined, { message_limit: 40 }); + + expect(mockChatClient.threads.registerSubscriptions).toHaveBeenCalledWith(); + expect(mockChatClient.config.setConfig).toHaveBeenCalledWith('channel', { + messagePaginator: { pageSize: 40 }, + }); + expect(mockChatClient.config.setConfig).toHaveBeenCalledWith('thread', { + messagePaginator: { pageSize: 40 }, + }); + expect(service.messagePageSize).toBe(40); + }); + it('should emit #channels$', async () => { await init(); const spy = jasmine.createSpy(); @@ -178,19 +241,17 @@ describe('ChannelService', () => { ); }); - it('should handle errors during channel load', fakeAsync(() => { + it('should handle errors during channel load', async () => { const spy = jasmine.createSpy(); service.channels$.subscribe(spy); const activeChannelSpy = jasmine.createSpy(); service.activeChannel$.subscribe(activeChannelSpy); - try { - void init(undefined, undefined, undefined, () => + await expectAsync( + init(undefined, undefined, undefined, () => mockChatClient.queryChannels.and.rejectWith('there was an error') - ); - tick(); - // eslint-disable-next-line no-empty - } catch (error) {} + ) + ).toBeRejected(); const channels = generateMockChannels(); mockChatClient.queryChannels.and.resolveTo(channels); @@ -201,14 +262,12 @@ describe('ChannelService', () => { notificationService.notifications$.subscribe(notificationSpy); notificationSpy.calls.reset(); events$.next({ eventType: 'connection.recovered' } as ClientEvent); - - tick(); - flush(); + await settle(); expect(spy).toHaveBeenCalledWith(channels); expect(activeChannelSpy).toHaveBeenCalledWith(channels[0]); expect(notificationSpy).toHaveBeenCalledWith([]); - })); + }); it('should emit channel query state correctly', async () => { const spy = jasmine.createSpy(); @@ -319,21 +378,24 @@ describe('ChannelService', () => { channelsSpy.calls.reset(); events$.next({ - eventType: 'message.new', + eventType: 'notification.message_new', event: { + type: 'notification.message_new', channel: { id: 'channel', - } as ChannelResponse, - } as Event, + type: 'messaging', + cid: 'messaging:channel', + }, + } as unknown as Event, }); + await settle(); expect(channelsSpy).not.toHaveBeenCalled(); }); it('should deselect active channel', async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.pipe(take(1)).subscribe((c) => (activeChannel = c!)); + const activeChannel = getActiveChannel(); const messagesSpy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(messagesSpy); const activeChannelSpy = jasmine.createSpy(); @@ -350,6 +412,8 @@ describe('ChannelService', () => { service.usersTypingInChannel$.subscribe(typingUsersSpy); const typingUsersInThreadSpy = jasmine.createSpy(); service.usersTypingInThread$.subscribe(typingUsersInThreadSpy); + const paginationStateSpy = jasmine.createSpy(); + service.activeChannelMessagesPaginationState$.subscribe(paginationStateSpy); service.isMessageLoadingInProgress = true; messagesSpy.calls.reset(); activeChannelSpy.calls.reset(); @@ -359,9 +423,11 @@ describe('ChannelService', () => { pinnedMessagesSpy.calls.reset(); typingUsersSpy.calls.reset(); typingUsersInThreadSpy.calls.reset(); + paginationStateSpy.calls.reset(); const channelSwitchStateSpy = jasmine.createSpy(); service.channelSwitchState$.subscribe(channelSwitchStateSpy); channelSwitchStateSpy.calls.reset(); + spyOn(activeChannel.messagePaginator, 'setViewingLive'); service.deselectActiveChannel(); expect(messagesSpy).toHaveBeenCalledWith([]); @@ -376,11 +442,23 @@ describe('ChannelService', () => { expect(pinnedMessagesSpy).toHaveBeenCalledWith([]); expect(typingUsersSpy).toHaveBeenCalledWith([]); expect(typingUsersInThreadSpy).toHaveBeenCalledWith([]); + expect(paginationStateSpy).toHaveBeenCalledWith({ + hasMoreOlder: true, + hasMoreNewer: false, + isLoading: false, + error: undefined, + }); expect(service.activeChannelLastReadMessageId).toBeUndefined(); expect(service.activeChannelUnreadCount).toBeUndefined(); + expect(activeChannel.messagePaginator.setViewingLive).toHaveBeenCalledWith( + false + ); messagesSpy.calls.reset(); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage()), + }); + activeChannel.messagePaginator.ingestItem(toLocal(mockMessage(100))); expect(messagesSpy).not.toHaveBeenCalled(); expect(service.isMessageLoadingInProgress).toBeFalse(); @@ -426,10 +504,11 @@ describe('ChannelService', () => { const prevChannelCount = service.channels.length; await service.loadMoreChannels(); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - jasmine.any(Object), - jasmine.any(Object), - jasmine.any(Object) + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ + filter_conditions: filters, + offset: prevChannelCount, + }) ); expect(service.channels.length).toEqual(prevChannelCount + 1); @@ -461,9 +540,9 @@ describe('ChannelService', () => { service.usersTypingInThread$.subscribe(typingUsersInThreadSpy); typingUsersInThreadSpy.calls.reset(); const newActiveChannel = mockChannels[1]; - spyOn(newActiveChannel, 'markRead'); - const pinnedMessages = generateMockMessages(); - newActiveChannel.state.pinnedMessages = pinnedMessages; + spyOn(newActiveChannel, 'markReadViaReporter').and.callThrough(); + const pinnedMessages = generateMockLocalMessages(100); + newActiveChannel.pinnedMessagesPaginator.setItems(pinnedMessages); const channelSwitchStateSpy = jasmine.createSpy(); service.channelSwitchState$.subscribe(channelSwitchStateSpy); channelSwitchStateSpy.calls.reset(); @@ -472,9 +551,22 @@ describe('ChannelService', () => { expect(result.cid).toBe(newActiveChannel.cid); expect(messagesSpy).toHaveBeenCalledWith(jasmine.any(Object)); - expect(newActiveChannel.markRead).toHaveBeenCalledWith(); + expect( + (messagesSpy.calls.mostRecent().args[0] as StreamMessage[]).map( + (m) => m.id + ) + ).toEqual(newActiveChannel.messagePaginator.items!.map((m) => m.id)); + expect(newActiveChannel.markReadViaReporter).toHaveBeenCalledWith(); expect(messageToQuoteSpy).toHaveBeenCalledWith(undefined); - expect(pinnedMessagesSpy).toHaveBeenCalledWith(pinnedMessages); + expect( + (pinnedMessagesSpy.calls.mostRecent().args[0] as StreamMessage[]).map( + (m) => m.id + ) + ).toEqual(pinnedMessages.map((m) => m.id)); + expect( + (pinnedMessagesSpy.calls.mostRecent().args[0] as StreamMessage[])[0] + .created_at + ).toBe(pinnedMessages[0].created_at); expect(typingUsersSpy).toHaveBeenCalledWith([]); expect(typingUsersInThreadSpy).toHaveBeenCalledWith([]); expect(channelSwitchStateSpy.calls.count()).toBe(2); @@ -482,15 +574,144 @@ describe('ChannelService', () => { expect(channelSwitchStateSpy.calls.mostRecent().args[0]).toBe('end'); }); + it('should activate the active channel, and release the previous one on channel switch', async () => { + const channels = generateMockChannels(3); + const releaseSpies = channels.map(() => jasmine.createSpy()); + channels.forEach((c, i) => + spyOn(c, 'activate').and.returnValue(releaseSpies[i]) + ); + await init(channels); + + expect(channels[0].activate).toHaveBeenCalledTimes(1); + expect(releaseSpies[0]).not.toHaveBeenCalled(); + + service.setAsActiveChannel(channels[1]); + + expect(releaseSpies[0]).toHaveBeenCalledTimes(1); + expect(channels[1].activate).toHaveBeenCalledTimes(1); + expect(releaseSpies[1]).not.toHaveBeenCalled(); + + service.deselectActiveChannel(); + + expect(releaseSpies[1]).toHaveBeenCalledTimes(1); + }); + + it('should load messages of the channel, if channel is not yet initialized', async () => { + await init(); + const channel = generateMockChannels(1)[0]; + channel.cid = 'not-initialized'; + (channel as unknown as { initialized: boolean }).initialized = false; + spyOn(channel, 'watch').and.callThrough(); + service.setAsActiveChannel(channel); + + expect(channel.watch).toHaveBeenCalledWith(); + }); + + it('should emit error, if loading messages of the channel failed', async () => { + await init(); + const channel = generateMockChannels(1)[0]; + channel.cid = 'not-initialized'; + (channel as unknown as { initialized: boolean }).initialized = false; + const error = new Error('watch failed'); + spyOn(channel, 'watch').and.rejectWith(error); + const notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'addTemporaryNotification'); + const paginationStateSpy = jasmine.createSpy(); + service.activeChannelMessagesPaginationState$.subscribe(paginationStateSpy); + service.setAsActiveChannel(channel); + await settle(); + + expect(paginationStateSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ isLoading: true }) + ); + expect(paginationStateSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ isLoading: false, error }) + ); + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.Error loading messages' + ); + }); + it('should emit #activeChannelMessages$', async () => { await init(); const spy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(spy); - const messages = generateMockChannels()[0].state.messages; + const messages = generateMockChannels()[0].messagePaginator.items!; const result = spy.calls.mostRecent().args[0] as StreamMessage[]; + + expect(result.length).toBe(messages.length); result.forEach((m, i) => { expect(m.id).toEqual(messages[i].id); + expect(m.created_at).toBe(messages[i].created_at); + }); + }); + + it('should emit #activeChannelMessages$ if items of the message paginator change', async () => { + await init(); + const channel = getActiveChannel(); + const spy = jasmine.createSpy(); + service.activeChannelMessages$.subscribe(spy); + const prevMessages = spy.calls.mostRecent().args[0] as StreamMessage[]; + spy.calls.reset(); + const newMessage = toLocal(mockMessage(100)); + channel.messagePaginator.ingestItem(newMessage); + + let messages = spy.calls.mostRecent().args[0] as StreamMessage[]; + + expect(messages.length).toBe(prevMessages.length + 1); + expect(messages[messages.length - 1].id).toBe(newMessage.id); + // unchanged messages are cached + expect(messages[0]).toBe(prevMessages[0]); + + spy.calls.reset(); + channel.messagePaginator.setItems([]); + + expect(spy).toHaveBeenCalledWith([]); + + // hasMoreHead change also re-emits messages (it's used to decide if the latest message is in the list) + spy.calls.reset(); + mockPaginator(channel).state.partialNext({ hasMoreHead: true }); + + expect(spy).toHaveBeenCalledWith([]); + + // other pagination state changes don't re-emit messages + spy.calls.reset(); + mockPaginator(channel).state.partialNext({ isLoading: true }); + + expect(spy).not.toHaveBeenCalled(); + + messages = getLatestValue(service.activeChannelMessages$); + + expect(messages).toEqual([]); + }); + + it('should emit #activeChannelMessagesPaginationState$', async () => { + await init(); + const channel = getActiveChannel(); + const spy = jasmine.createSpy(); + service.activeChannelMessagesPaginationState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith({ + hasMoreOlder: true, + hasMoreNewer: false, + isLoading: false, + error: undefined, + }); + + const error = new Error('error'); + mockPaginator(channel).state.partialNext({ + hasMoreTail: false, + hasMoreHead: true, + isLoading: true, + lastQueryError: error, + }); + + expect(spy).toHaveBeenCalledWith({ + hasMoreOlder: false, + hasMoreNewer: true, + isLoading: true, + error, }); }); @@ -503,141 +724,250 @@ describe('ChannelService', () => { it('should load more older messages', async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c as Channel)); - spyOn(activeChannel, 'query').and.callThrough(); + const activeChannel = getActiveChannel(); + const paginator = mockPaginator(activeChannel); + spyOn(paginator, 'toTail').and.callThrough(); + const olderMessages = generateMockLocalMessages(100, true); + paginator.loadPage = () => olderMessages; + const spy = jasmine.createSpy(); + service.activeChannelMessages$.subscribe(spy); + const prevCount = (spy.calls.mostRecent().args[0] as StreamMessage[]) + .length; await service.loadMoreMessages(); - expect(activeChannel.query).toHaveBeenCalledWith(jasmine.any(Object)); + expect(paginator.toTail).toHaveBeenCalledWith(); - const arg = (activeChannel.query as jasmine.Spy).calls.mostRecent() - .args[0] as { messages: { id_lt: string } }; - const oldestMessage = activeChannel.state.messages[0]; + const messages = spy.calls.mostRecent().args[0] as StreamMessage[]; - expect(arg.messages.id_lt).toEqual(oldestMessage.id); + expect(messages.length).toBe(prevCount + olderMessages.length); + expect(messages[0].id).toBe(olderMessages[0].id); }); it('should load more newer messages', async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c as Channel)); - activeChannel.state.latestMessages = []; - spyOn(activeChannel, 'query').and.callThrough(); + const activeChannel = getActiveChannel(); + const paginator = mockPaginator(activeChannel); + paginator.state.partialNext({ hasMoreHead: true }); + spyOn(paginator, 'toHead').and.callThrough(); + const newerMessages = generateMockLocalMessages(100); + paginator.loadPage = () => newerMessages; await service.loadMoreMessages('newer'); - expect(activeChannel.query).toHaveBeenCalledWith(jasmine.any(Object)); + expect(paginator.toHead).toHaveBeenCalledWith(); - const arg = (activeChannel.query as jasmine.Spy).calls.mostRecent() - .args[0] as { messages: { id_gt: string } }; - const lastMessage = - activeChannel.state.messages[activeChannel.state.messages.length - 1]; + const messages = getLatestValue(service.activeChannelMessages$); - expect(arg.messages.id_gt).toEqual(lastMessage.id); + expect(messages[messages.length - 1].id).toBe( + newerMessages[newerMessages.length - 1].id + ); }); it(`shouldn't load more newer messages if already at the latest messages`, async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c as Channel)); - spyOn(activeChannel, 'query').and.callThrough(); - await service.loadMoreMessages('newer'); + const activeChannel = getActiveChannel(); + const paginator = mockPaginator(activeChannel); + spyOn(paginator, 'toHead').and.callThrough(); + spyOn(paginator, 'toTail').and.callThrough(); - expect(activeChannel.query).not.toHaveBeenCalled(); + expect(service.loadMoreMessages('newer')).toBeFalse(); + expect(paginator.toHead).not.toHaveBeenCalled(); await service.loadMoreMessages('older'); - expect(activeChannel.query).toHaveBeenCalledWith(jasmine.any(Object)); + expect(paginator.toTail).toHaveBeenCalledWith(); + }); + + it(`shouldn't load more older messages if there are no more older messages`, async () => { + await init(); + const paginator = mockPaginator(getActiveChannel()); + spyOn(paginator, 'toTail').and.callThrough(); + paginator.state.partialNext({ hasMoreTail: false }); + + expect(service.loadMoreMessages('older')).toBeFalse(); + expect(paginator.toTail).not.toHaveBeenCalled(); + }); + + it(`shouldn't load more messages if loading is in progress or if there are no messages`, async () => { + await init(); + const paginator = mockPaginator(getActiveChannel()); + spyOn(paginator, 'toTail').and.callThrough(); + paginator.state.partialNext({ isLoading: true }); + + expect(service.loadMoreMessages('older')).toBeFalse(); + + paginator.state.partialNext({ isLoading: false, items: [] }); + + expect(service.loadMoreMessages('older')).toBeFalse(); + + paginator.state.partialNext({ items: undefined }); + + expect(service.loadMoreMessages('older')).toBeFalse(); + expect(paginator.toTail).not.toHaveBeenCalled(); + }); + + it(`shouldn't load more messages if there is no active channel`, () => { + expect(service.loadMoreMessages()).toBeFalse(); + expect(service.loadMoreThreadReplies()).toBeFalse(); + }); + + it('should reject #loadMoreMessages if the paginator has a query error', async () => { + await init(); + const paginator = mockPaginator(getActiveChannel()); + const error = new Error('query failed'); + paginator.loadPage = () => { + paginator.state.partialNext({ lastQueryError: error }); + return []; + }; + + await expectAsync( + service.loadMoreMessages('older') as Promise + ).toBeRejectedWith(error); + + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to load older messages', + jasmine.objectContaining({ error }) + ); + }); + + it('should load more thread replies', async () => { + await init(); + const parentMessage = service.activeChannelMessages[0] as StreamMessage; + await service.setAsActiveParentMessage( + getLatestValue(service.activeChannelMessages$).find( + (m) => m.id === parentMessage.id + ) + ); + const thread = mockChatClient.threads.get(parentMessage.id)!; + const paginator = thread.messagePaginator; + spyOn(paginator, 'toTail').and.callThrough(); + const olderReplies = generateMockLocalMessages(100, true).map((m) => ({ + ...m, + parent_id: parentMessage.id, + })); + paginator.loadPage = () => olderReplies; + const spy = jasmine.createSpy(); + service.activeThreadMessages$.subscribe(spy); + const prevCount = (spy.calls.mostRecent().args[0] as StreamMessage[]) + .length; + await service.loadMoreThreadReplies(); + + expect(paginator.toTail).toHaveBeenCalledWith(); + expect((spy.calls.mostRecent().args[0] as StreamMessage[]).length).toBe( + prevCount + olderReplies.length + ); + + expect(service.loadMoreThreadReplies('newer')).toBeFalse(); }); it('should add reaction', async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); + const activeChannel = getActiveChannel(); spyOn(activeChannel, 'sendReaction'); const messageId = 'id'; const reactionType = 'wow'; await service.addReaction(messageId, reactionType); - expect(activeChannel.sendReaction).toHaveBeenCalledWith(messageId, { - type: reactionType, - }); + expect(activeChannel.sendReaction).toHaveBeenCalledWith( + { id: messageId }, + { reaction: { type: reactionType, custom: undefined } } + ); + + await service.addReaction(messageId, reactionType, { clap: true }); + + expect(activeChannel.sendReaction).toHaveBeenCalledWith( + { id: messageId }, + { reaction: { type: reactionType, custom: { clap: true } } } + ); }); it('should remove reaction', async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); + const activeChannel = getActiveChannel(); spyOn(activeChannel, 'deleteReaction'); const messageId = 'id'; const reactionType = 'wow'; await service.removeReaction(messageId, reactionType); - expect(activeChannel.deleteReaction).toHaveBeenCalledWith( - messageId, - reactionType - ); + expect(activeChannel.deleteReaction).toHaveBeenCalledWith({ + id: messageId, + type: reactionType, + }); }); it('should watch for new message events', async () => { await init(); // wait for mark read throttle time - await new Promise((resolve) => { - setTimeout(resolve, service['markReadThrottleTime']); - }); + await waitForMarkReadThrottle(); const spy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(spy); - const prevCount = (spy.calls.mostRecent().args[0] as Channel[]).length; + const prevCount = (spy.calls.mostRecent().args[0] as StreamMessage[]) + .length; spy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - const newMessage = mockMessage(); - activeChannel.state.messages.push(newMessage); - spyOn(activeChannel, 'markRead'); - (activeChannel as MockChannel).handleEvent('message.new', newMessage); + const activeChannel = getActiveChannel(); + const newMessage = toLocal(mockMessage(100)); + spyOn(activeChannel, 'markReadViaReporter'); + // stream-chat adds the message to the paginator + activeChannel.messagePaginator.ingestItem(newMessage); + activeChannel.handleEvent('message.new', { message: newMessage }); const newCount = (spy.calls.mostRecent().args[0] as StreamMessage[]).length; expect(newCount).toBe(prevCount + 1); - expect(activeChannel.markRead).toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledWith(); }); it('should pause read events', async () => { await init(); + await waitForMarkReadThrottle(); service.shouldMarkActiveChannelAsRead = false; - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - const newMessage = mockMessage(); - activeChannel.state.messages.push(newMessage); - spyOn(activeChannel, 'markRead'); - (activeChannel as MockChannel).handleEvent('message.new', newMessage); + const activeChannel = getActiveChannel(); + const newMessage = toLocal(mockMessage(100)); + spyOn(activeChannel, 'markReadViaReporter'); + activeChannel.handleEvent('message.new', { message: newMessage }); - expect(activeChannel.markRead).not.toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).not.toHaveBeenCalled(); service.setAsActiveChannel(activeChannel); - expect(activeChannel.markRead).not.toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).not.toHaveBeenCalled(); service.shouldMarkActiveChannelAsRead = true; - expect(activeChannel.markRead).toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledWith(); }); it(`shouldn't make "markRead" call, if user dosen't have 'read-events' capability`, async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); + await waitForMarkReadThrottle(); + const activeChannel = getActiveChannel(); const capabilites = activeChannel.data?.own_capabilities as string[]; capabilites.splice(capabilites.indexOf('read-events'), 1); - const newMessage = mockMessage(); - activeChannel.state.messages.push(newMessage); - spyOn(activeChannel, 'markRead'); - (activeChannel as MockChannel).handleEvent('message.new', newMessage); + const newMessage = toLocal(mockMessage(100)); + spyOn(activeChannel, 'markReadViaReporter'); + activeChannel.handleEvent('message.new', { message: newMessage }); - expect(activeChannel.markRead).not.toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).not.toHaveBeenCalled(); service.setAsActiveChannel(activeChannel); - expect(activeChannel.markRead).not.toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).not.toHaveBeenCalled(); + }); + + it(`should log a warning if "markRead" call fails`, async () => { + await init(); + await waitForMarkReadThrottle(); + const activeChannel = getActiveChannel(); + const error = new Error('mark read failed'); + spyOn(activeChannel, 'markReadViaReporter').and.rejectWith(error); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(100)), + }); + await settle(); + + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to mark channel as read', + jasmine.objectContaining({ error }) + ); }); it('should watch for message update events', async () => { @@ -648,48 +978,130 @@ describe('ChannelService', () => { const pinnedMessagesSpy = jasmine.createSpy(); service.activeChannelPinnedMessages$.subscribe(pinnedMessagesSpy); pinnedMessagesSpy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - const pinnedMessages = generateMockMessages(); - activeChannel.state.pinnedMessages = pinnedMessages; - const message = - activeChannel.state.messages[activeChannel.state.messages.length - 1]; - message.text = 'updated'; - (activeChannel as MockChannel).handleEvent('message.updated', { message }); + const activeChannel = getActiveChannel(); + const pinnedMessages = generateMockLocalMessages(100); + // stream-chat updates the paginators on message.updated events + activeChannel.pinnedMessagesPaginator.setItems(pinnedMessages); + const items = activeChannel.messagePaginator.items!; + const message = items[items.length - 1]; + activeChannel.messagePaginator.ingestItem({ ...message, text: 'updated' }); const messages = spy.calls.mostRecent().args[0] as StreamMessage[]; const updatedMessage = messages[messages.length - 1]; expect(updatedMessage.text).toBe('updated'); - expect(pinnedMessagesSpy).toHaveBeenCalledWith(pinnedMessages); + expect( + (pinnedMessagesSpy.calls.mostRecent().args[0] as StreamMessage[]).map( + (m) => m.id + ) + ).toEqual(pinnedMessages.map((m) => m.id)); }); - it('should watch for message deleted events', async () => { + it('should keep attachments if only the signed URLs changed (e.g. reaction added)', async () => { + await init(); + const channel = getActiveChannel(); + const paginator = mockPaginator(channel); + const message = paginator.items![paginator.items!.length - 1]; + paginator.ingestItem({ + ...message, + attachments: [ + { + type: 'image', + image_url: 'https://cdn.example.com/image.png?Signature=first', + custom: {}, + }, + ], + }); + const before = getLatestValue(service.activeChannelMessages$).find( + (m) => m.id === message.id + )!; + paginator.ingestItem({ + ...message, + reaction_counts: { like: 1 }, + attachments: [ + { + type: 'image', + image_url: 'https://cdn.example.com/image.png?Signature=second', + custom: {}, + }, + ], + }); + const after = getLatestValue(service.activeChannelMessages$).find( + (m) => m.id === message.id + )!; + + expect(after.reaction_counts).toEqual({ like: 1 }); + expect(after.attachments).toBe(before.attachments); + + paginator.ingestItem({ + ...message, + attachments: [ + { + type: 'image', + image_url: 'https://cdn.example.com/other-image.png?Signature=third', + custom: {}, + }, + ], + }); + const changed = getLatestValue(service.activeChannelMessages$).find( + (m) => m.id === message.id + )!; + + expect(changed.attachments).not.toBe(before.attachments); + expect(changed.attachments[0].image_url).toContain('other-image.png'); + }); + + it('should emit reaction updates', async () => { await init(); const spy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(spy); spy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - const message = - activeChannel.state.messages[activeChannel.state.messages.length - 1]; - message.deleted_at = new Date(); - (activeChannel as MockChannel).handleEvent('message.deleted', { message }); + const activeChannel = getActiveChannel(); + const message = activeChannel.messagePaginator.items![0]; + // stream-chat updates the paginator on reaction events + activeChannel.messagePaginator.ingestItem({ + ...message, + reaction_counts: { wow: 1 }, + } as LocalMessage); - expect(spy).toHaveBeenCalledWith(jasmine.arrayContaining([message])); + const messages = spy.calls.mostRecent().args[0] as StreamMessage[]; + + expect(messages[0].reaction_counts).toEqual({ wow: 1 }); + }); + it('should watch for message deleted events', async () => { + await init(); + const spy = jasmine.createSpy(); + service.activeChannelMessages$.subscribe(spy); spy.calls.reset(); - activeChannel.state.messages.splice( - activeChannel.state.messages.findIndex((m) => m.id === message.id) - ); - (activeChannel as MockChannel).handleEvent('message.deleted', { - message, - type: 'message.deleted', + const activeChannel = getActiveChannel(); + const items = activeChannel.messagePaginator.items!; + const message = items[items.length - 1]; + const deletedAt = new Date(); + activeChannel.messagePaginator.ingestItem({ + ...message, + deleted_at: dateToNs(deletedAt), }); + expect(spy).toHaveBeenCalledWith( + jasmine.arrayContaining([ + jasmine.objectContaining({ + id: message.id, + deleted_at: dateToNs(deletedAt), + }), + ]) + ); + + spy.calls.reset(); + activeChannel.messagePaginator.removeItem({ id: message.id }); + expect(spy).toHaveBeenCalled(); - expect(spy).not.toHaveBeenCalledWith(jasmine.arrayContaining([message])); + expect( + (spy.calls.mostRecent().args[0] as StreamMessage[]).find( + (m) => m.id === message.id + ) + ).toBeUndefined(); }); it('should move channel to the top of the list', async () => { @@ -701,9 +1113,9 @@ describe('ChannelService', () => { const spy = jasmine.createSpy(); service.channels$.subscribe(spy); const event = { - message: mockMessage(), + message: toLocal(mockMessage()), type: 'message.new', - } as any as Event; + }; (channel as MockChannel).handleEvent('message.new', event); const firtChannel = (spy.calls.mostRecent().args[0] as Channel[])[0]; @@ -720,9 +1132,9 @@ describe('ChannelService', () => { const spy = jasmine.createSpy(); service.customNewMessageHandler = spy; const event = { - message: mockMessage(), + message: toLocal(mockMessage()), type: 'message.new', - } as any as Event; + }; (channel as MockChannel).handleEvent('message.new', event); expect(spy).toHaveBeenCalledWith( @@ -737,25 +1149,24 @@ describe('ChannelService', () => { it('should handle if channel visibility changes', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); mockChatClient.activeChannels[channel.cid] = channel; spyOn(channel, 'stopWatching'); - (channel as MockChannel).handleEvent('channel.hidden', { + channel.handleEvent('channel.hidden', { type: 'channel.hidden', - channel, + cid: channel.cid, }); let channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); - expect(channel.stopWatching).not.toHaveBeenCalledWith(); + expect(channel.stopWatching).not.toHaveBeenCalled(); - (channel as MockChannel).handleEvent('channel.visible', { + channel.handleEvent('channel.visible', { type: 'channel.visible', - channel, + cid: channel.cid, }); channels = spy.calls.mostRecent().args[0] as Channel[]; @@ -765,17 +1176,16 @@ describe('ChannelService', () => { it('should handle if channel visibility changes, if custom event handlers are provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const visibleSpy = jasmine.createSpy(); const hiddenSpy = jasmine.createSpy(); service.customChannelVisibleHandler = visibleSpy; service.customChannelHiddenHandler = hiddenSpy; const hiddenEvent = { type: 'channel.hidden', - channel, - } as any as Event; - (channel as MockChannel).handleEvent('channel.hidden', hiddenEvent); + cid: channel.cid, + }; + channel.handleEvent('channel.hidden', hiddenEvent); expect(hiddenSpy).toHaveBeenCalledWith( hiddenEvent, @@ -788,9 +1198,9 @@ describe('ChannelService', () => { const visibleEvent = { type: 'channel.visible', - channel, - } as any as Event; - (channel as MockChannel).handleEvent('channel.hidden', visibleEvent); + cid: channel.cid, + }; + channel.handleEvent('channel.visible', visibleEvent); expect(visibleSpy).toHaveBeenCalledWith( visibleEvent, @@ -804,34 +1214,33 @@ describe('ChannelService', () => { it('should remove channel from list, if deleted', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); mockChatClient.activeChannels[channel.cid] = channel; spyOn(channel, 'stopWatching'); - (channel as MockChannel).handleEvent('channel.deleted', { + channel.handleEvent('channel.deleted', { type: 'channel.deleted', - channel, + cid: channel.cid, }); const channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); - expect(channel.stopWatching).not.toHaveBeenCalledWith(); + expect(channel.stopWatching).not.toHaveBeenCalled(); + expect(service.activeChannel?.cid).toBe(channels[0].cid); }); it('should call #customChannelDeletedHandler, if channel is deleted and handler is provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.customChannelDeletedHandler = spy; const event = { type: 'channel.deleted', - channel, - } as any as Event; - (channel as MockChannel).handleEvent('channel.deleted', event); + cid: channel.cid, + }; + channel.handleEvent('channel.deleted', event); expect(spy).toHaveBeenCalledWith( event, @@ -845,35 +1254,36 @@ describe('ChannelService', () => { it('should update channel in list, if updated', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); - (channel as MockChannel).handleEvent('channel.updated', { + const activeChannelSpy = jasmine.createSpy(); + service.activeChannel$.subscribe(activeChannelSpy); + spy.calls.reset(); + activeChannelSpy.calls.reset(); + // stream-chat updates channel.data + channel.data!.custom = { ...channel.data!.custom, name: 'New name' }; + channel.handleEvent('channel.updated', { type: 'channel.updated', - channel: { - cid: channel.cid, - name: 'New name', - }, + cid: channel.cid, }); const channels = spy.calls.mostRecent().args[0] as Channel[]; const updatedChannel = channels.find((c) => c.cid === channel.cid); - expect(updatedChannel!.data!.name).toBe('New name'); + expect(updatedChannel!.data!.custom!.name).toBe('New name'); expect(updatedChannel!.data!.own_capabilities).toBeDefined(); - expect(updatedChannel!.data!.hidden).toBeDefined(); + expect(activeChannelSpy).toHaveBeenCalledWith(channel); }); it('should emit changed channel if `capabilities.changed` dispatched', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); channel.data!.own_capabilities = ['send-message']; - (channel as MockChannel).handleEvent('capabilities.changed', { + channel.handleEvent('capabilities.changed', { type: 'capabilities.changed', cid: channel.cid, }); @@ -887,18 +1297,14 @@ describe('ChannelService', () => { it('should call #customChannelUpdatedHandler, if updated and handler is provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.customChannelUpdatedHandler = spy; const event = { type: 'channel.updated', - channel: { - cid: channel.cid, - name: 'New name', - }, - } as Event; - (channel as MockChannel).handleEvent('channel.updated', event); + cid: channel.cid, + }; + channel.handleEvent('channel.updated', event); expect(spy).toHaveBeenCalledWith( event, @@ -912,43 +1318,42 @@ describe('ChannelService', () => { it('should handle if channel is truncated', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); + await service.setAsActiveParentMessage( + getLatestValue(service.activeChannelMessages$)[0] + ); const channelsSpy = jasmine.createSpy(); service.channels$.subscribe(channelsSpy); + channelsSpy.calls.reset(); const messagesSpy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(messagesSpy); - (channel as MockChannel).handleEvent('channel.truncated', { + const parentMessageIdSpy = jasmine.createSpy(); + service.activeParentMessageId$.subscribe(parentMessageIdSpy); + parentMessageIdSpy.calls.reset(); + // stream-chat truncates the message paginator + channel.messagePaginator.setItems([]); + channel.handleEvent('channel.truncated', { type: 'channel.truncated', - channel: { - cid: channel.cid, - name: 'New name', - }, + cid: channel.cid, }); - const channels = channelsSpy.calls.mostRecent().args[0] as Channel[]; - - expect( - channels.find((c) => c.cid === channel.cid)!.state.messages.length - ).toBe(0); - + expect(channelsSpy).toHaveBeenCalledWith( + jasmine.arrayContaining([channel]) + ); expect(messagesSpy).toHaveBeenCalledWith([]); + expect(parentMessageIdSpy).toHaveBeenCalledWith(undefined); }); it('should call #customChannelTruncatedHandler, if channel is truncated and custom handler is provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const spy = jasmine.createSpy(); service.customChannelTruncatedHandler = spy; const event = { type: 'channel.truncated', - channel: { - cid: channel.cid, - name: 'New name', - }, - } as Event; - (channel as MockChannel).handleEvent('channel.truncated', event); + cid: channel.cid, + }; + channel.handleEvent('channel.truncated', event); expect(spy).toHaveBeenCalledWith( event, @@ -960,29 +1365,6 @@ describe('ChannelService', () => { ); }); - it('should watch for reaction events', async () => { - await init(); - const spy = jasmine.createSpy(); - service.activeChannelMessages$.subscribe(spy); - const message = (spy.calls.mostRecent().args[0] as StreamMessage[])[0]; - spy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - (activeChannel as MockChannel).handleEvent('reaction.new', { message }); - - expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); - - spy.calls.reset(); - (activeChannel as MockChannel).handleEvent('reaction.updated', { message }); - - expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); - - spy.calls.reset(); - (activeChannel as MockChannel).handleEvent('reaction.deleted', { message }); - - expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); - }); - it('should watch for channel events', async () => { const channel = generateMockChannels(1)[0]; const unsubscribeSpy = jasmine.createSpy(); @@ -996,13 +1378,11 @@ describe('ChannelService', () => { expect(unsubscribeSpy).toHaveBeenCalledWith(); }); - it('should add the new channel to the top of the list, and start watching it, if user is added to a channel', fakeAsync(async () => { + it('should add the new channel to the top of the list, and start watching it, if user is added to a channel', async () => { await init(); - flush(); const newChannel = generateMockChannels()[0]; - newChannel.cid = 'newchannel'; + newChannel.cid = 'messaging:newchannel'; newChannel.id = 'newchannel'; - newChannel.type = 'messaging'; mockChatClient.channel.and.returnValue(newChannel); spyOn(newChannel, 'watch').and.callThrough(); spyOn(newChannel, 'on').and.callThrough(); @@ -1010,24 +1390,30 @@ describe('ChannelService', () => { service.channels$.subscribe(spy); events$.next({ eventType: 'notification.added_to_channel', - event: { channel: newChannel } as any as Event, + event: { + type: 'notification.added_to_channel', + channel: { cid: newChannel.cid, id: newChannel.id, type: 'messaging' }, + } as unknown as Event, }); - tick(); + await settle(); const channels = spy.calls.mostRecent().args[0] as Channel[]; const firstChannel = channels[0]; + expect(mockChatClient.channel).toHaveBeenCalledWith( + 'messaging', + 'newchannel' + ); expect(firstChannel.cid).toBe(newChannel.cid); expect(newChannel.watch).toHaveBeenCalledWith(); expect(newChannel.on).toHaveBeenCalledWith(jasmine.any(Function)); - })); + }); - it('should add the new channel to the top of the list, and start watching it, if a new message is received from the channel', fakeAsync(async () => { + it('should add the new channel to the top of the list, and start watching it, if a new message is received from the channel', async () => { await init(); const channel = generateMockChannels()[0]; - channel.cid = 'channel'; + channel.cid = 'messaging:channel'; channel.id = 'channel'; - channel.type = 'messaging'; mockChatClient.channel.and.returnValue(channel); spyOn(channel, 'watch').and.callThrough(); spyOn(channel, 'on').and.callThrough(); @@ -1035,10 +1421,12 @@ describe('ChannelService', () => { service.channels$.subscribe(spy); events$.next({ eventType: 'notification.message_new', - event: { channel: channel } as any as Event, + event: { + type: 'notification.message_new', + channel: { cid: channel.cid, id: channel.id, type: 'messaging' }, + } as unknown as Event, }); - tick(); - flush(); + await settle(); const channels = spy.calls.mostRecent().args[0] as Channel[]; const firstChannel = channels[0]; @@ -1046,35 +1434,63 @@ describe('ChannelService', () => { expect(firstChannel.cid).toBe(channel.cid); expect(channel.watch).toHaveBeenCalledWith(); expect(channel.on).toHaveBeenCalledWith(jasmine.any(Function)); - })); + }); - it(`shouldn't add channels twice if two notification events were received for the same channel`, fakeAsync(async () => { + it('should add the new channel to the list even if watch request failed, and log the error', async () => { await init(); const channel = generateMockChannels()[0]; - channel.cid = 'channel'; + channel.cid = 'messaging:channel'; + channel.id = 'channel'; + mockChatClient.channel.and.returnValue(channel); + const error = new Error('watch failed'); + spyOn(channel, 'watch').and.rejectWith(error); + events$.next({ + eventType: 'notification.message_new', + event: { + type: 'notification.message_new', + channel: { cid: channel.cid, id: channel.id, type: 'messaging' }, + } as unknown as Event, + }); + await settle(); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to add channel to channel list because watch request failed', + jasmine.objectContaining({ error }) + ); + expect(service.channels[0].cid).toBe(channel.cid); + }); + + it(`shouldn't add channels twice if two notification events were received for the same channel`, async () => { + await init(); + const channel = generateMockChannels()[0]; + channel.cid = 'messaging:channel'; channel.id = 'channel'; - channel.type = 'messaging'; mockChatClient.channel.and.returnValue(channel); spyOn(channel, 'watch').and.callThrough(); spyOn(channel, 'on').and.callThrough(); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); + const event = { + channel: { cid: channel.cid, id: channel.id, type: 'messaging' }, + }; events$.next({ eventType: 'notification.added_to_channel', - event: { channel: channel } as any as Event, + event: { + ...event, + type: 'notification.added_to_channel', + } as unknown as Event, }); events$.next({ eventType: 'notification.message_new', - event: { channel: channel } as any as Event, + event: { ...event, type: 'notification.message_new' } as unknown as Event, }); - tick(); - flush(); + await settle(); const channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.filter((c) => c.cid === channel.cid).length).toBe(1); expect(channel.on).toHaveBeenCalledOnceWith(jasmine.any(Function)); - })); + }); it('should remove channel form the list if user is removed from channel', async () => { await init(); @@ -1090,18 +1506,22 @@ describe('ChannelService', () => { events$.next({ eventType: 'notification.removed_from_channel', - event: { channel: channel } as any as Event, + event: { + type: 'notification.removed_from_channel', + channel: { cid: channel.cid, id: channel.id, type: channel.type }, + } as unknown as Event, }); let channels = spy.calls.mostRecent().args[0] as Channel[]; expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); expect(service.setAsActiveChannel).not.toHaveBeenCalled(); + expect(mockChatClient.channelManager.get).toHaveBeenCalledWith(channel.cid); expect(channel.stopWatching).toHaveBeenCalledWith(); // Check that new messages won't readd the channel to the list (channel as MockChannel).handleEvent('message.new', { - id: 'new-message', + message: toLocal(mockMessage(100)), type: 'message.new', }); @@ -1110,6 +1530,21 @@ describe('ChannelService', () => { expect(channels.find((c) => c.cid === channel.cid)).toBeUndefined(); }); + it('should log a warning if unwatching a removed channel failed', async () => { + await init(); + const channel = service.channels[1]; + mockChatClient.activeChannels[channel.cid] = channel; + const error = new Error('stop watching failed'); + spyOn(channel, 'stopWatching').and.rejectWith(error); + service.removeChannel(channel.cid); + await settle(); + + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to unwatch channel', + jasmine.objectContaining({ error }) + ); + }); + it('should remove channel form the list if user is removed from channel, and emit new active channel', async () => { await init(); let channel!: Channel; @@ -1123,7 +1558,10 @@ describe('ChannelService', () => { spyOn(service, 'setAsActiveChannel'); events$.next({ eventType: 'notification.removed_from_channel', - event: { channel: channel } as any as Event, + event: { + type: 'notification.removed_from_channel', + channel: { cid: channel.cid, id: channel.id, type: channel.type }, + } as unknown as Event, }); const channels = spy.calls.mostRecent().args[0] as Channel[]; @@ -1141,8 +1579,9 @@ describe('ChannelService', () => { .pipe(first()) .subscribe((channels) => (channel = channels![1])); const event = { - channel: channel, - } as any as Event; + type: 'notification.message_new', + channel: { cid: channel.cid, id: channel.id, type: channel.type }, + } as unknown as Event; const channelsSpy = jasmine.createSpy(); service.channels$.subscribe(channelsSpy); channelsSpy.calls.reset(); @@ -1172,8 +1611,9 @@ describe('ChannelService', () => { .pipe(first()) .subscribe((channels) => (channel = channels![1])); const event = { - channel: channel, - } as any as Event; + type: 'notification.added_to_channel', + channel: { cid: channel.cid, id: channel.id, type: channel.type }, + } as unknown as Event; const channelsSpy = jasmine.createSpy(); service.channels$.subscribe(channelsSpy); channelsSpy.calls.reset(); @@ -1199,8 +1639,9 @@ describe('ChannelService', () => { .pipe(first()) .subscribe((channels) => (channel = channels![1])); const event = { - channel: channel, - } as any as Event; + type: 'notification.removed_from_channel', + channel: { cid: channel.cid, id: channel.id, type: channel.type }, + } as unknown as Event; const channelsSpy = jasmine.createSpy(); service.channels$.subscribe(channelsSpy); channelsSpy.calls.reset(); @@ -1219,13 +1660,14 @@ describe('ChannelService', () => { it('should send message', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'sendMessage').and.callThrough(); - spyOn(channel.state, 'addMessageSorted').and.callThrough(); + spyOn(channel.messagePaginator, 'ingestItem').and.callThrough(); const text = 'Hi'; - const attachments = [{ fallback: 'image.png', url: 'http://url/to/image' }]; - const mentionedUsers = [{ id: 'sara', name: 'Sara' }]; + const attachments = [ + { fallback: 'image.png', url: 'http://url/to/image' }, + ] as unknown as Attachment[]; + const mentionedUsers = [{ id: 'sara', name: 'Sara' }] as UserResponse[]; const quotedMessageId = 'quotedMessage'; const customData = { isVote: true, @@ -1235,7 +1677,7 @@ describe('ChannelService', () => { service.activeChannelMessages$ .pipe(first()) .subscribe((m) => (prevMessageCount = m.length)); - await service.sendMessage( + const result = await service.sendMessage( text, attachments, mentionedUsers, @@ -1251,59 +1693,131 @@ describe('ChannelService', () => { }); expect(channel.sendMessage).toHaveBeenCalledWith({ - text, - attachments, - mentioned_users: ['sara'], - id: jasmine.any(String), - parent_id: undefined, - quoted_message_id: quotedMessageId, - isVote: true, - options: ['A', 'B', 'C'], - poll_id: undefined, + message: { + text, + attachments, + mentioned_users: ['sara'], + id: jasmine.any(String), + parent_id: undefined, + quoted_message_id: quotedMessageId, + custom: customData, + poll_id: undefined, + }, }); - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( - jasmine.objectContaining({ text, user, attachments }), - true + const ingestSpy = channel.messagePaginator.ingestItem as jasmine.Spy; + const preview = ingestSpy.calls.first().args[0] as LocalMessage; + + // optimistic update + expect(preview).toEqual( + jasmine.objectContaining({ + text, + user, + attachments, + status: 'sending', + custom: customData, + cid: channel.cid, + }) + ); + expect(preview.created_at).toEqual(jasmine.any(Number)); + + expect(ingestSpy.calls.mostRecent().args[0]).toEqual( + jasmine.objectContaining({ id: preview.id, status: 'received' }) ); expect(latestMessage.text).toBe(text); + expect(latestMessage.id).toBe(preview.id); + expect(latestMessage.status).toBe('received'); expect(messageCount).toEqual(prevMessageCount + 1); + expect(result.id).toBe(preview.id); + expect(result.status).toBe('received'); + }); + + it('should display optimistic and failed messages (stream-chat only accepts messages of the channel)', async () => { + await init(); + const channel = getActiveChannel(); + mockPaginator(channel).requiredCid = channel.cid; + let resolveRequest!: () => void; + spyOn(channel, 'sendMessage').and.returnValue( + new Promise((_, reject) => { + resolveRequest = () => reject({ status: 500 }); + }) + ); + const resultPromise = service.sendMessage('optimistic'); + await settle(); + let messages = getLatestValue(service.activeChannelMessages$); + + expect(messages[messages.length - 1].text).toBe('optimistic'); + expect(messages[messages.length - 1].status).toBe('sending'); + + resolveRequest(); + const result = await resultPromise; + messages = getLatestValue(service.activeChannelMessages$); + + expect(result.status).toBe('failed'); + expect(messages[messages.length - 1].id).toBe(result.id); + expect(messages[messages.length - 1].status).toBe('failed'); + }); + + it('should send thread reply', async () => { + await init(); + const channel = getActiveChannel(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[0]; + await service.setAsActiveParentMessage(parentMessage); + const thread = mockChatClient.threads.get(parentMessage.id)!; + spyOn(channel.messagePaginator, 'ingestItem').and.callThrough(); + spyOn(channel, 'sendMessage').and.callThrough(); + const result = await service.sendMessage('Hi', [], [], parentMessage.id); + + expect(channel.sendMessage).toHaveBeenCalledWith({ + message: jasmine.objectContaining({ parent_id: parentMessage.id }), + }); + expect(channel.messagePaginator.ingestItem).not.toHaveBeenCalled(); + expect( + thread.messagePaginator.items![thread.messagePaginator.items!.length - 1] + .id + ).toBe(result.id); + + const replies = getLatestValue(service.activeThreadMessages$); + + expect(replies[replies.length - 1].id).toBe(result.id); + expect(result.status).toBe('received'); + expect(result.parent_id).toBe(parentMessage.id); }); it('should send message - #beforeSendMessage hook is provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'sendMessage').and.callThrough(); - spyOn(channel.state, 'addMessageSorted').and.callThrough(); + spyOn(channel.messagePaginator, 'ingestItem').and.callThrough(); const text = 'Hi'; - const attachments = [{ fallback: 'image.png', url: 'http://url/to/image' }]; - const mentionedUsers = [{ id: 'sara', name: 'Sara' }]; + const attachments = [ + { fallback: 'image.png', url: 'http://url/to/image' }, + ] as unknown as Attachment[]; + const mentionedUsers = [{ id: 'sara', name: 'Sara' }] as UserResponse[]; const spy = jasmine.createSpy(); service.beforeSendMessage = spy; spy.and.callFake((i: MessageInput) => { - // @ts-expect-error testing custom proerpty - i.customData = { custom: 'red' }; + i.customData = { custom: 'red' } as unknown as CustomMessageData; return i; }); await service.sendMessage(text, attachments, mentionedUsers); expect(channel.sendMessage).toHaveBeenCalledWith({ - text, - attachments, - mentioned_users: ['sara'], - id: jasmine.any(String), - parent_id: undefined, - quoted_message_id: undefined, - // @ts-expect-error testing custom proerpty - custom: 'red', - poll_id: undefined, + message: { + text, + attachments, + mentioned_users: ['sara'], + id: jasmine.any(String), + parent_id: undefined, + quoted_message_id: undefined, + custom: { custom: 'red' } as unknown as CustomMessageData, + poll_id: undefined, + }, }); - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( - jasmine.objectContaining({ custom: 'red' }), - true + expect(channel.messagePaginator.ingestItem).toHaveBeenCalledWith( + jasmine.objectContaining({ custom: { custom: 'red' }, status: 'sending' }) ); spy.and.callFake((i: MessageInput) => { @@ -1313,25 +1827,26 @@ describe('ChannelService', () => { await service.sendMessage(text, attachments, mentionedUsers); expect(channel.sendMessage).toHaveBeenCalledWith({ - text: 'censored', - attachments, - mentioned_users: ['sara'], - id: jasmine.any(String), - parent_id: undefined, - quoted_message_id: undefined, - poll_id: undefined, + message: { + text: 'censored', + attachments, + mentioned_users: ['sara'], + id: jasmine.any(String), + parent_id: undefined, + quoted_message_id: undefined, + custom: {}, + poll_id: undefined, + }, }); - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( - jasmine.objectContaining({ text: 'censored' }), - true + expect(channel.messagePaginator.ingestItem).toHaveBeenCalledWith( + jasmine.objectContaining({ text: 'censored', status: 'sending' }) ); }); it('should send action', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const giphy = { thumb_url: 'https://media4.giphy.com/media/Q9GYuPJTT8RomJTRot/giphy.gif?cid=c4b036752at3vu1m2vwt7nvnfumyer5620wbdhosrpmds52e&rid=giphy.gif&ct=g', @@ -1339,71 +1854,128 @@ describe('ChannelService', () => { title_link: 'https://giphy.com/gifs/Q9GYuPJTT8RomJTRot', type: 'giphy', }; - spyOn(channel, 'sendAction').and.resolveTo({ + (spyOn(channel, 'sendAction') as jasmine.Spy).and.resolveTo({ message: { id: 'message-1', attachments: [giphy], }, - } as any as SendMessageAPIResponse); + }); let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( (m) => (latestMessage = m[m.length - 1]) ); await service.sendAction('message-1', { image_action: 'send' }); - expect(latestMessage.attachments![0]).toBe(giphy); + expect(channel.sendAction).toHaveBeenCalledWith('message-1', { + image_action: 'send', + }); + expect(latestMessage.id).toBe('message-1'); + expect(latestMessage.status).toBe('received'); + expect(latestMessage.attachments[0]).toEqual( + giphy as unknown as Attachment + ); }); it('should remove message after action, if no message is returned', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - spyOn(channel, 'sendAction').and.resolveTo( - {} as any as SendMessageAPIResponse - ); - spyOn(channel.state, 'removeMessage'); + const channel = getActiveChannel(); + (spyOn(channel, 'sendAction') as jasmine.Spy).and.resolveTo({}); + spyOn(channel.messagePaginator, 'removeItem').and.callThrough(); await service.sendAction('1', { image_action: 'send' }); - expect(channel.state.removeMessage).toHaveBeenCalledWith({ + expect(channel.messagePaginator.removeItem).toHaveBeenCalledWith({ id: '1', - parent_id: undefined, }); + expect( + service.activeChannelMessages.find((m) => m.id === '1') + ).toBeUndefined(); + }); + + it('should remove thread message after action, if no message is returned', async () => { + await init(); + const channel = getActiveChannel(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[0]; + await service.setAsActiveParentMessage(parentMessage); + const thread = mockChatClient.threads.get(parentMessage.id)!; + (spyOn(channel, 'sendAction') as jasmine.Spy).and.resolveTo({}); + spyOn(thread.messagePaginator, 'removeItem').and.callThrough(); + spyOn(channel.messagePaginator, 'removeItem').and.callThrough(); + await service.sendAction('1', { image_action: 'send' }, parentMessage.id); + + expect(thread.messagePaginator.removeItem).toHaveBeenCalledWith({ + id: '1', + }); + expect(channel.messagePaginator.removeItem).not.toHaveBeenCalled(); }); - it('should update message', () => { + it('should update message', async () => { const message = mockMessage(); + mockChatClient.updateMessage.and.resolveTo({ + message: toLocalMessage(message), + }); // @ts-expect-error we exclude this delete message.readBy; - void service.updateMessage(message); + const result = await service.updateMessage(message); + + expect(mockChatClient.updateMessage).toHaveBeenCalledWith( + { id: message.id }, + { + message: jasmine.objectContaining({ + id: message.id, + text: message.text, + custom: {}, + }), + } + ); + expect(result.id).toBe(message.id); + expect(result.created_at).toEqual(message.created_at); + }); - expect(mockChatClient.updateMessage).toHaveBeenCalledWith(message); + it('should display notification if updated message was rejected by moderation', async () => { + const message = mockMessage(); + mockChatClient.updateMessage.and.resolveTo({ + message: { + ...toLocalMessage(message), + type: 'error', + moderation: { action: 'bounce' }, + }, + }); + const notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'addTemporaryNotification'); + const result = await service.updateMessage(message); + + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.This message did not meet our content guidelines' + ); + expect(result).toBe(message); }); it('should resend instead of update if message was bounced', async () => { await init(); const message = mockMessage(); - const channel = service.activeChannel!; - spyOn(channel, 'sendMessage'); - message.moderation_details = { + const channel = getActiveChannel(); + spyOn(channel, 'sendMessage').and.callThrough(); + (message as { type: string }).type = 'error'; + message.moderation = { + action: 'bounce', original_text: 'Ricciardo should retire', - action: 'MESSAGE_RESPONSE_ACTION_BOUNCE', - harms: [ - { - name: 'hammurabi_filter', - phrase_list_ids: [139], - }, - ], - error_msg: 'this message did not meet our content guidelines', - }; - void service.updateMessage(message); + } as unknown as StreamMessage['moderation']; + await service.updateMessage({ ...message, text: 'Edited text' }); - expect(mockChatClient.updateMessage).not.toHaveBeenCalledWith(); - expect(channel.sendMessage).not.toHaveBeenCalledWith(message as Message); + expect(mockChatClient.updateMessage).not.toHaveBeenCalled(); + expect(channel.sendMessage).toHaveBeenCalledWith({ + message: jasmine.objectContaining({ + id: message.id, + text: 'Edited text', + }), + }); }); it('should update message - #beforeUpdateMessage is provided', async () => { const message = mockMessage(); - mockChatClient.updateMessage.and.resolveTo({ message }); + mockChatClient.updateMessage.and.resolveTo({ + message: toLocalMessage(message), + }); const spy = jasmine.createSpy(); service.beforeUpdateMessage = spy; spy.and.callFake((m: StreamMessage) => { @@ -1413,45 +1985,62 @@ describe('ChannelService', () => { await service.updateMessage(message); expect(mockChatClient.updateMessage).toHaveBeenCalledWith( - jasmine.objectContaining({ text: 'Testing beforeUpdateMessage hook' }) + { id: message.id }, + { + message: jasmine.objectContaining({ + text: 'Testing beforeUpdateMessage hook', + }), + } ); }); - it('should remove translation object and readyBy before updating message', () => { + it('should remove translation object and readyBy before updating message', async () => { const message = mockMessage(); - // @ts-expect-error we exclude this - delete message.readBy; - void service.updateMessage({ + mockChatClient.updateMessage.and.resolveTo({ + message: toLocalMessage(message), + }); + await service.updateMessage({ ...message, i18n: { en_text: 'Translation', language: 'en' }, + translation: 'Translation', readBy: [], - }); + } as StreamMessage); + + const request = ( + mockChatClient.updateMessage.calls.mostRecent().args[1] as { + message: Record; + } + ).message; - expect(mockChatClient.updateMessage).toHaveBeenCalledWith(message); + expect(request['id']).toBe(message.id); + expect(Object.keys(request)).not.toContain('i18n'); + expect(Object.keys(request)).not.toContain('readBy'); + expect(Object.keys(request)).not.toContain('translation'); }); it('should delete message', async () => { await init(); - const channel = service.activeChannel; - spyOn(channel!.state, 'removeMessage'); + const channel = getActiveChannel(); + spyOn(channel.messagePaginator, 'removeItem'); const message = mockMessage(); - void service.deleteMessage(message); + await service.deleteMessage(message); - expect(mockChatClient.deleteMessage).toHaveBeenCalledWith(message.id); - expect(channel!.state.removeMessage).not.toHaveBeenCalled(); + expect(mockChatClient.deleteMessage).toHaveBeenCalledWith({ + id: message.id, + }); + expect(channel.messagePaginator.removeItem).not.toHaveBeenCalled(); }); it('should delete message - local', async () => { await init(); const message = mockMessage(); - const channel = service.activeChannel; - spyOn(channel!.state, 'removeMessage'); - void service.deleteMessage(message, true); + const channel = getActiveChannel(); + spyOn(channel.messagePaginator, 'removeItem'); + await service.deleteMessage(message, true); - expect(mockChatClient.deleteMessage).not.toHaveBeenCalledWith(); - expect(channel!.state.removeMessage).toHaveBeenCalledWith({ + expect(mockChatClient.deleteMessage).not.toHaveBeenCalled(); + expect(channel.messagePaginator.removeItem).toHaveBeenCalledWith({ id: message.id, - parent_id: undefined, }); }); @@ -1463,62 +2052,75 @@ describe('ChannelService', () => { await service.deleteMessage(message); expect(spy).toHaveBeenCalledWith(message); - expect(mockChatClient.deleteMessage).not.toHaveBeenCalledWith(message.id); + expect(mockChatClient.deleteMessage).not.toHaveBeenCalled(); spy.and.resolveTo(true); spy.calls.reset(); await service.deleteMessage(message); expect(spy).toHaveBeenCalledWith(message); - expect(mockChatClient.deleteMessage).toHaveBeenCalledWith(message.id); + expect(mockChatClient.deleteMessage).toHaveBeenCalledWith({ + id: message.id, + }); }); it('should resend message', async () => { await init(); + const channel = getActiveChannel(); + const items = channel.messagePaginator.items!; + channel.messagePaginator.ingestItem({ + ...items[items.length - 1], + status: 'failed', + }); let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe((m) => { latestMessage = m[m.length - 1]; }); - latestMessage.status = 'failed'; - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + + expect(latestMessage.status).toBe('failed'); + spyOn(channel, 'sendMessage').and.callThrough(); - spyOn(channel.state, 'addMessageSorted').and.callThrough(); + spyOn(channel.messagePaginator, 'ingestItem').and.callThrough(); await service.resendMessage(latestMessage); - expect(channel.sendMessage).toHaveBeenCalledWith( - jasmine.objectContaining({ + expect(channel.sendMessage).toHaveBeenCalledWith({ + message: jasmine.objectContaining({ id: latestMessage.id, - }) - ); + }), + }); + + const ingestSpy = channel.messagePaginator.ingestItem as jasmine.Spy; - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( + expect(ingestSpy.calls.first().args[0]).toEqual( jasmine.objectContaining({ + id: latestMessage.id, status: 'sending', - errorStatusCode: undefined, - }), - true + }) ); - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( + expect(ingestSpy.calls.mostRecent().args[0]).toEqual( jasmine.objectContaining({ + id: latestMessage.id, status: 'received', - }), - true + }) ); expect(latestMessage.status).toBe('received'); + expect(latestMessage.errorStatusCode).toBeUndefined(); }); it('should ignore error if resend failed because message already exists', async () => { await init(); + const channel = getActiveChannel(); + const items = channel.messagePaginator.items!; + channel.messagePaginator.ingestItem({ + ...items[items.length - 1], + status: 'failed', + }); let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe((m) => { latestMessage = m[m.length - 1]; }); - latestMessage.status = 'failed'; - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); spyOn(channel, 'sendMessage').and.rejectWith({ code: 4, status: 400, @@ -1529,24 +2131,23 @@ describe('ChannelService', () => { }, }, }); - spyOn(channel.state, 'addMessageSorted').and.callThrough(); + spyOn(channel.messagePaginator, 'ingestItem').and.callThrough(); await service.resendMessage(latestMessage); - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( + expect(channel.messagePaginator.ingestItem).toHaveBeenCalledWith( jasmine.objectContaining({ status: 'received', - }), - true + }) ); expect(latestMessage.status).toBe('received'); + expect(latestMessage.errorStatusCode).toBeUndefined(); }); it('should set message state while sending', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - spyOn(channel, 'sendMessage'); + const channel = getActiveChannel(); + spyOn(channel, 'sendMessage').and.returnValue(new Promise(() => {})); const text = 'Hi'; let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( @@ -1554,41 +2155,31 @@ describe('ChannelService', () => { ); void service.sendMessage(text); + expect(latestMessage.text).toBe(text); expect(latestMessage.status).toBe('sending'); }); it('should set message state after message is sent', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - spyOn(channel, 'sendMessage'); const text = 'Hi'; let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( (m) => (latestMessage = m[m.length - 1]) ); await service.sendMessage(text); - channel.state.messages.push({ - id: latestMessage.id, - } as any as StreamMessage); - (channel as MockChannel).handleEvent('message.new', { - id: latestMessage.id, - }); + expect(latestMessage.text).toBe(text); expect(latestMessage.status).toBe('received'); }); // this could happen when a message was added to the local state while offline it('should handle message order change', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - const localMessage = channel.state.messages.splice( - channel.state.messages.length - 2, - 1 - )[0]; - channel.state.messages.push(localMessage); - (channel as MockChannel).handleEvent('message.new', localMessage); + const channel = getActiveChannel(); + const items = [...channel.messagePaginator.items!]; + const localMessage = items.splice(items.length - 2, 1)[0]; + items.push(localMessage); + channel.messagePaginator.setItems(items); let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( (m) => (latestMessage = m[m.length - 1]) @@ -1599,30 +2190,57 @@ describe('ChannelService', () => { it('should set message state, if an error occured', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'sendMessage').and.callFake(() => Promise.reject({ status: 500 }) ); + spyOn(channel.messagePaginator, 'ingestItem').and.callThrough(); const text = 'Hi'; let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( (m) => (latestMessage = m[m.length - 1]) ); - await service.sendMessage(text); + const result = await service.sendMessage(text); + expect(channel.messagePaginator.ingestItem).toHaveBeenCalledWith( + jasmine.objectContaining({ status: 'failed' }) + ); expect(latestMessage.status).toBe('failed'); expect(latestMessage.errorStatusCode).toBe(500); + expect(result.status).toBe('failed'); + expect(result.errorStatusCode).toBe(500); + + // resend clears the error status code + (channel.sendMessage as jasmine.Spy).and.callThrough(); + await service.resendMessage(latestMessage); + + expect(latestMessage.status).toBe('received'); + expect(latestMessage.errorStatusCode).toBeUndefined(); + }); + + it('should clear error status code if failed message is deleted locally', async () => { + await init(); + const channel = getActiveChannel(); + spyOn(channel, 'sendMessage').and.rejectWith({ status: 500 }); + const failedMessage = await service.sendMessage('Hi'); + + expect(failedMessage.errorStatusCode).toBe(500); + + await service.deleteMessage(failedMessage, true); + + expect( + service.activeChannelMessages.find((m) => m.id === failedMessage.id) + ).toBeUndefined(); + expect(service['errorStatusCodes'].has(failedMessage.id)).toBeFalse(); }); it('should add sent message to message list', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - spyOn(channel, 'sendMessage').and.callFake(() => + const channel = getActiveChannel(); + (spyOn(channel, 'sendMessage') as jasmine.Spy).and.callFake(() => Promise.resolve({ message: { id: 'new-message' }, - } as SendMessageAPIResponse) + }) ); let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( @@ -1635,57 +2253,59 @@ describe('ChannelService', () => { it(`shouldn't duplicate new message`, async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); await service.sendMessage('Hi'); - let newMessageId!: string; + let newMessage!: StreamMessage; service.activeChannelMessages$.pipe(first()).subscribe((m) => { - newMessageId = m[m.length - 1].id; + newMessage = m[m.length - 1]; }); - (channel as MockChannel).handleEvent('message.new', { id: newMessageId }); + // stream-chat ingests the message from the message.new event + channel.messagePaginator.ingestItem(toLocal(newMessage)); + channel.handleEvent('message.new', { message: toLocal(newMessage) }); let messages!: StreamMessage[]; service.activeChannelMessages$.pipe(first()).subscribe((m) => { messages = m; }); - expect(messages.filter((m) => m.id === newMessageId).length).toEqual(1); + expect(messages.filter((m) => m.id === newMessage.id).length).toEqual(1); }); it('should upload attachments', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); - spyOn(channel, 'sendImage').and.callFake((file: File) => { - switch (file.name) { - case 'file_error.jpg': - return Promise.reject({ - response: { - data: { - code: 4, - message: - 'UploadImage failed with error: "File extension .jpg is not supported"', + const channel = getActiveChannel(); + (spyOn(channel, 'uploadImage') as jasmine.Spy).and.callFake( + ({ file }: { file: File }) => { + switch (file.name) { + case 'file_error.jpg': + return Promise.reject({ + response: { + data: { + code: 4, + message: + 'UploadImage failed with error: "File extension .jpg is not supported"', + }, }, - }, - }); - case 'file_too_big.jpg': - return Promise.reject({ - response: { - data: { - code: 4, - message: - 'UploadImage failed with error: "File size is above the size limit (1048576 bytes)"', + }); + case 'file_too_big.jpg': + return Promise.reject({ + response: { + data: { + code: 4, + message: + 'UploadImage failed with error: "File size is above the size limit (1048576 bytes)"', + }, }, - }, - }); - default: - return Promise.resolve({ - file: 'http://url/to/image', - duration: '200ms', - }); + }); + default: + return Promise.resolve({ + file: 'http://url/to/image', + duration: '200ms', + }); + } } - }); - spyOn(channel, 'sendFile').and.callFake( - (file: File, _: string, type: string) => { + ); + (spyOn(channel, 'uploadFile') as jasmine.Spy).and.callFake( + ({ file }: { file: File }) => { switch (file.name) { case 'file_error.pdf': return Promise.reject({ @@ -1702,7 +2322,7 @@ describe('ChannelService', () => { file: 'http://url/to/file', duration: '200ms', thumb_url: - type && type.startsWith('video') + file.type && file.type.startsWith('video') ? 'http://url/to/poster' : undefined, }); @@ -1769,6 +2389,9 @@ describe('ChannelService', () => { }, ]; + expect(channel.uploadImage).toHaveBeenCalledWith({ file: file1 }); + expect(channel.uploadFile).toHaveBeenCalledWith({ file: file3 }); + expectedResult.forEach((r, i) => { expect(r).toEqual(result[i]); }); @@ -1776,8 +2399,7 @@ describe('ChannelService', () => { it('should delete image attachment', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'deleteImage'); const url = 'http://url/to/image'; await service.deleteAttachment({ @@ -1787,13 +2409,12 @@ describe('ChannelService', () => { file: {} as any as File, }); - expect(channel.deleteImage).toHaveBeenCalledWith(url); + expect(channel.deleteImage).toHaveBeenCalledWith({ url }); }); it('should delete file attachment', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'deleteFile'); const url = 'http://url/to/file'; await service.deleteAttachment({ @@ -1803,13 +2424,12 @@ describe('ChannelService', () => { file: {} as any as File, }); - expect(channel.deleteFile).toHaveBeenCalledWith(url); + expect(channel.deleteFile).toHaveBeenCalledWith({ url }); }); it('should update #readBy array, if active channel is read', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); let latestMessage!: StreamMessage; service.activeChannelMessages$.subscribe( (messages) => (latestMessage = messages[messages.length - 1]) @@ -1818,37 +2438,112 @@ describe('ChannelService', () => { expect(latestMessage.readBy.length).toBe(0); const user = { id: 'jack', name: 'Jack' } as UserResponse; + const read = { + [user.id]: { + last_read: dateToNs(new Date()), + user, + unread_messages: 0, + }, + } as unknown as Channel['state']['read']; + // stream-chat updates the read state on message.read events + channel.state.read = read; + (channel.state as unknown as MockStateStore<{ read: unknown }>).partialNext( + { read } + ); + + expect( + latestMessage.readBy.find((u) => u.id === 'jack') + ).not.toBeUndefined(); + }); + + it('should only update #readBy of the latest message, if read state changes', async () => { + await init(); + const channel = getActiveChannel(); + const before = getLatestValue(service.activeChannelMessages$); + const user = { id: 'jack', name: 'Jack' } as UserResponse; + // jack read every message + const read = { + [user.id]: { + last_read: dateToNs(new Date()), + user, + unread_messages: 0, + }, + } as unknown as Channel['state']['read']; + channel.state.read = read; + (channel.state as unknown as MockStateStore<{ read: unknown }>).partialNext( + { read } + ); + const after = getLatestValue(service.activeChannelMessages$); + + expect(after).not.toBe(before); + expect(after[after.length - 1]).not.toBe(before[before.length - 1]); + expect(after[after.length - 1].readBy.map((u) => u.id)).toEqual(['jack']); + after.slice(0, -1).forEach((m, i) => expect(m).toBe(before[i])); + }); + + it('should update #readBy of every message, if a user is updated', async () => { + await init(); + const channel = getActiveChannel(); + const user = { id: 'jack', name: 'Jack' } as UserResponse; + const read = { + [user.id]: { + last_read: dateToNs(new Date()), + user, + unread_messages: 0, + }, + } as unknown as Channel['state']['read']; + channel.state.read = read; + // Simulate a channel switch, so readBy is calculated for every message + service.deselectActiveChannel(); + service.setAsActiveChannel(channel); + const before = getLatestValue(service.activeChannelMessages$); + + expect(before.every((m) => m.readBy[0]?.name === 'Jack')).toBeTrue(); + channel.state.read = { - [user.id]: { last_read: new Date(), user, unread_messages: 0 }, - }; - (channel as MockChannel).handleEvent('message.read', { - user, - }); + [user.id]: { ...read[user.id], user: { ...user, name: 'Jack Updated' } }, + } as unknown as Channel['state']['read']; + events$.next({ + eventType: 'user.updated', + event: { type: 'user.updated', user }, + } as unknown as ClientEvent); + const after = getLatestValue(service.activeChannelMessages$); - expect( - latestMessage.readBy.find((u) => u.id === 'jack') - ).not.toBeUndefined(); + expect(after.every((m) => m.readBy[0]?.name === 'Jack Updated')).toBeTrue(); }); it(`should unsubscribe from active channel events, after active channel changed`, async () => { await init(); const spy = jasmine.createSpy('activeMessagesSpy'); service.activeChannelMessages$.subscribe(spy); - let prevActiveChannel!: Channel; - service.activeChannel$ - .pipe(first()) - .subscribe((c) => (prevActiveChannel = c!)); + const pinnedMessagesSpy = jasmine.createSpy(); + service.activeChannelPinnedMessages$.subscribe(pinnedMessagesSpy); + const paginationStateSpy = jasmine.createSpy(); + service.activeChannelMessagesPaginationState$.subscribe(paginationStateSpy); + const prevActiveChannel = getActiveChannel(); const newActiveChannel = generateMockChannels()[1]; service.setAsActiveChannel(newActiveChannel); spy.calls.reset(); - (prevActiveChannel as MockChannel).handleEvent('message.new', { - user: { id: 'jill' }, + pinnedMessagesSpy.calls.reset(); + paginationStateSpy.calls.reset(); + prevActiveChannel.handleEvent('message.new', { + message: { ...toLocal(mockMessage(100)), user: { id: 'jill' } }, }); - (prevActiveChannel as MockChannel).handleEvent('reaction.new', { - message: mockMessage(), + prevActiveChannel.messagePaginator.ingestItem(toLocal(mockMessage(100))); + mockPaginator(prevActiveChannel).state.partialNext({ + hasMoreHead: true, + isLoading: true, }); + prevActiveChannel.pinnedMessagesPaginator.setItems( + generateMockLocalMessages() + ); + ( + prevActiveChannel.state as unknown as MockStateStore<{ read: unknown }> + ).partialNext({ read: {} }); expect(spy).not.toHaveBeenCalled(); + expect(pinnedMessagesSpy).not.toHaveBeenCalled(); + expect(paginationStateSpy).not.toHaveBeenCalled(); }); it('should query members, less than 100 members', async () => { @@ -1860,7 +2555,7 @@ describe('ChannelService', () => { john: { user: { id: 'john' } }, [user.id]: { user }, } as any as Record; - channel.data = { member_count: 3, own_capabilities: [] }; + channel.data = { member_count: 3, own_capabilities: [] } as any; service.setAsActiveChannel(channel); const result = await service.autocompleteMembers('ja'); const expectedResult = [ @@ -1868,14 +2563,14 @@ describe('ChannelService', () => { { user: { id: 'john' } }, ]; - expect(result).toEqual(expectedResult); + expect(result as unknown).toEqual(expectedResult); }); it('should query members, more than 100 members', async () => { await init(); const channel = generateMockChannels(1)[0]; channel.cid = 'new-channel'; - spyOn(channel, 'queryMembers').and.resolveTo({ + (spyOn(channel, 'queryMembers') as jasmine.Spy).and.resolveTo({ members: [ { user_id: mockCurrentUser().id }, { user_id: 'jack' }, @@ -1884,15 +2579,22 @@ describe('ChannelService', () => { }); const users = Array.from({ length: 101 }, (_, i) => ({ id: `${i}` })); channel.state.members = {} as any as Record; - users.forEach((u) => (channel.state.members[u.id] = { user: u })); + users.forEach( + (u) => + (channel.state.members[u.id] = { + user: u, + } as unknown as ChannelMemberResponse) + ); service.setAsActiveChannel(channel); const result = await service.autocompleteMembers('ja'); - expect(channel.queryMembers).toHaveBeenCalledWith( - jasmine.objectContaining({ - name: { $autocomplete: 'ja' }, - }) - ); + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: { + name: { $autocomplete: 'ja' }, + }, + }, + }); expect(result.length).toBe(1); }); @@ -1904,7 +2606,20 @@ describe('ChannelService', () => { const message = mockMessage(); service.selectMessageToQuote(message); - expect(spy).toHaveBeenCalledWith(message); + expect(spy).toHaveBeenCalledWith( + jasmine.objectContaining({ id: message.id, text: message.text }) + ); + + // LocalMessage is converted to StreamMessage, dates stay TimestampNS + const localMessage = getActiveChannel().messagePaginator.items![0]; + service.selectMessageToQuote(localMessage); + + expect(spy).toHaveBeenCalledWith( + jasmine.objectContaining({ + id: localMessage.id, + created_at: localMessage.created_at, + }) + ); }); it('should deselect message to be quoted', async () => { @@ -1918,8 +2633,7 @@ describe('ChannelService', () => { it('should notify channel if typing started', async () => { await init(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'keystroke'); await service.typingStarted(); @@ -1928,8 +2642,7 @@ describe('ChannelService', () => { it('should notify channel if typing stopped', async () => { await init(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'stopTyping'); await service.typingStopped(); @@ -1944,8 +2657,7 @@ describe('ChannelService', () => { service.usersTypingInThread$.subscribe((e) => { usersTypingInThreadSpy(e); }); - let channel!: MockChannel; - service.activeChannel$.subscribe((c) => (channel = c as MockChannel)); + const channel = getActiveChannel(); usersTypingInThreadSpy.calls.reset(); usersTypingInChannelSpy.calls.reset(); channel.handleEvent('typing.start', { @@ -1991,29 +2703,56 @@ describe('ChannelService', () => { expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); }); + it('should emit users that are currently typing in the active thread', async () => { + await init(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[0]; + await service.setAsActiveParentMessage(parentMessage); + const usersTypingInThreadSpy = jasmine.createSpy(); + service.usersTypingInThread$.subscribe(usersTypingInThreadSpy); + usersTypingInThreadSpy.calls.reset(); + const channel = getActiveChannel(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'sara' }, + parent_id: parentMessage.id, + }); + + expect(usersTypingInThreadSpy).toHaveBeenCalledWith([{ id: 'sara' }]); + + usersTypingInThreadSpy.calls.reset(); + channel.handleEvent('typing.start', { + type: 'typing.start', + user: { id: 'john' }, + parent_id: 'other-thread', + }); + + expect(usersTypingInThreadSpy).not.toHaveBeenCalled(); + }); + it('should emit the date of latest messages sent by the user by channels', async () => { await init(); - let activeChannel!: Channel; - service.activeChannel$ - .pipe(first()) - .subscribe((c) => (activeChannel = c as Channel)); - const newMessage = mockMessage(); - newMessage.cid = 'channel1'; - newMessage.created_at = new Date(); - newMessage.user_id = user.id; + const activeChannel = getActiveChannel(); + const createdAt = new Date(); + const newMessage = { + ...toLocal(mockMessage()), + cid: 'channel1', + created_at: dateToNs(createdAt), + user_id: user.id, + }; const spy = jasmine.createSpy(); service.latestMessageDateByUserByChannels$.subscribe(spy); - (activeChannel as MockChannel).handleEvent('message.new', { + activeChannel.handleEvent('message.new', { message: newMessage, }); - expect(spy).toHaveBeenCalledWith({ channel1: newMessage.created_at }); + expect(spy).toHaveBeenCalledWith({ + channel1: nsToDate(dateToNs(createdAt)), + }); }); it('should call custom #customFileUploadRequest and #customImageUploadRequest if provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const customImageUploadRequest = jasmine .createSpy() .and.callFake((file: File) => { @@ -2039,8 +2778,8 @@ describe('ChannelService', () => { }); service.customImageUploadRequest = customImageUploadRequest; service.customFileUploadRequest = customFileUploadRequest; - spyOn(channel, 'sendImage'); - spyOn(channel, 'sendFile'); + spyOn(channel, 'uploadImage'); + spyOn(channel, 'uploadFile'); const file1 = { name: 'food.png' } as File; const file2 = { name: 'file_error.jpg' } as File; const file3 = { name: 'menu.pdf' } as File; @@ -2083,8 +2822,10 @@ describe('ChannelService', () => { }, ]; - expect(channel.sendImage).not.toHaveBeenCalled(); - expect(channel.sendFile).not.toHaveBeenCalled(); + expect(customImageUploadRequest).toHaveBeenCalledWith(file1, channel); + expect(customFileUploadRequest).toHaveBeenCalledWith(file3, channel); + expect(channel.uploadImage).not.toHaveBeenCalled(); + expect(channel.uploadFile).not.toHaveBeenCalled(); expectedResult.forEach((r, i) => { expect(r).toEqual(result[i]); @@ -2093,8 +2834,7 @@ describe('ChannelService', () => { it('should call custom #customImageDeleteRequest if provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const customImageDeleteRequest = jasmine.createSpy(); service.customImageDeleteRequest = customImageDeleteRequest; spyOn(channel, 'deleteImage'); @@ -2112,8 +2852,7 @@ describe('ChannelService', () => { it('should call custom #customFileDeleteRequest if provided', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const customFileDeleteRequest = jasmine.createSpy(); service.customFileDeleteRequest = customFileDeleteRequest; spyOn(channel, 'deleteFile'); @@ -2134,10 +2873,8 @@ describe('ChannelService', () => { mockChatClient.queryChannels.calls.reset(); events$.next({ eventType: 'connection.recovered' } as ClientEvent); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - jasmine.any(Object), - jasmine.any(Object), - jasmine.any(Object) + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ filter_conditions: filters }) ); }); @@ -2147,24 +2884,54 @@ describe('ChannelService', () => { events$.next({ eventType: 'connection.recovered' } as ClientEvent); events$.next({ eventType: 'connection.recovered' } as ClientEvent); - expect(mockChatClient.queryChannels).toHaveBeenCalledTimes(1); + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledTimes(1); }); it('should reset pagination options after reconnect', async () => { await init(undefined, undefined, { limit: 20 }); + await service.loadMoreChannels(); mockChatClient.queryChannels.calls.reset(); events$.next({ eventType: 'connection.recovered' } as ClientEvent); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - jasmine.any(Object), - jasmine.any(Object), - { - limit: 20, - state: true, - presence: true, - watch: true, - message_limit: 25, - } + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith({ + limit: 20, + state: true, + presence: true, + watch: true, + message_limit: 25, + filter_conditions: filters, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + }); + + it('should keep active thread open and reselect message to quote after connection recovered', async () => { + await init(); + const channel = getActiveChannel(); + const messages = getLatestValue(service.activeChannelMessages$); + await service.setAsActiveParentMessage(messages[0]); + service.selectMessageToQuote(messages[1]); + const parentMessageIdSpy = jasmine.createSpy(); + service.activeParentMessageId$.subscribe(parentMessageIdSpy); + const messageToQuoteSpy = jasmine.createSpy(); + service.messageToQuote$.subscribe(messageToQuoteSpy); + parentMessageIdSpy.calls.reset(); + messageToQuoteSpy.calls.reset(); + // stream-chat reloads the active channel + const items = [...channel.messagePaginator.items!]; + items[1] = { ...items[1], text: 'updated while offline' }; + channel.messagePaginator.setItems(items); + mockChatClient.queryChannels.and.resolveTo(service.channels); + events$.next({ eventType: 'connection.recovered' } as ClientEvent); + await settle(); + + // stream-chat reloads the activated thread, so it can stay open + expect(parentMessageIdSpy).not.toHaveBeenCalledWith(undefined); + expect(service['activeThread']?.id).toBe(messages[0].id); + expect(messageToQuoteSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + id: messages[1].id, + text: 'updated while offline', + }) ); }); @@ -2176,6 +2943,8 @@ describe('ChannelService', () => { const messagesSpy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(messagesSpy); messagesSpy.calls.reset(); + const paginator = mockPaginator(getActiveChannel()); + spyOn(paginator, 'jumpToMessage').and.callThrough(); const messageId = '1232121123'; const response = service.jumpToMessage(messageId); @@ -2183,6 +2952,11 @@ describe('ChannelService', () => { await response; + expect(paginator.jumpToMessage).toHaveBeenCalledWith(messageId, { + pageSize: 25, + suppressFocusSignal: true, + }); + expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ id: messageId, parentId: undefined, @@ -2194,6 +2968,26 @@ describe('ChannelService', () => { expect(service.isMessageLoadingInProgress).toBeFalse(); }); + it('should jump to latest message', async () => { + await init(); + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + const paginator = mockPaginator(getActiveChannel()); + spyOn(paginator, 'jumpToTheLatestMessage').and.callThrough(); + spyOn(paginator, 'jumpToMessage').and.callThrough(); + await service.jumpToMessage('latest'); + + expect(paginator.jumpToTheLatestMessage).toHaveBeenCalledWith({ + suppressFocusSignal: true, + }); + expect(paginator.jumpToMessage).not.toHaveBeenCalled(); + expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ + id: 'latest', + parentId: undefined, + }); + }); + it(`should display error notification if message couldn't be loaded`, async () => { await init(); const jumpToMessageIdSpy = jasmine.createSpy(); @@ -2202,10 +2996,9 @@ describe('ChannelService', () => { const messagesSpy = jasmine.createSpy(); service.activeChannelMessages$.subscribe(messagesSpy); messagesSpy.calls.reset(); - let activeChannel: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); + const paginator = mockPaginator(getActiveChannel()); const error = new Error(); - spyOn(activeChannel!.state, 'loadMessageIntoState').and.rejectWith(error); + spyOn(paginator, 'jumpToMessage').and.rejectWith(error); const notificationService = TestBed.inject(NotificationService); spyOn(notificationService, 'addTemporaryNotification'); const messageId = '1232121123'; @@ -2219,12 +3012,291 @@ describe('ChannelService', () => { expect(service.isMessageLoadingInProgress).toBeFalse(); }); + it(`should display error notification if message wasn't found`, async () => { + await init(); + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + const paginator = mockPaginator(getActiveChannel()); + paginator.loadAround = () => undefined; + const notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'addTemporaryNotification'); + await expectAsync(service.jumpToMessage('not-found')).toBeRejected(); + + expect(jumpToMessageIdSpy).not.toHaveBeenCalled(); + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.Message not found' + ); + expect(service.isMessageLoadingInProgress).toBeFalse(); + }); + + it('should jump to thread message', async () => { + await init(); + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + const channelPaginator = mockPaginator(getActiveChannel()); + spyOn(channelPaginator, 'jumpToMessage').and.callThrough(); + const parentMessage = channelPaginator.items![5]; + const messageId = 'thread-reply'; + await service.jumpToMessage(messageId, parentMessage.id); + const thread = mockChatClient.threads.get(parentMessage.id)!; + + // parent message is already loaded + expect(channelPaginator.jumpToMessage).not.toHaveBeenCalled(); + expect(getLatestValue(service.activeParentMessageId$)).toBe( + parentMessage.id + ); + expect(thread.messagePaginator.messageFocusSignal.getLatestValue()).toEqual( + { signal: { messageId } } + ); + expect( + getLatestValue(service.activeThreadMessages$).find( + (m) => m.id === messageId + ) + ).toBeDefined(); + expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ + id: messageId, + parentId: parentMessage.id, + }); + }); + + it('should jump to thread message - parent message not loaded', async () => { + await init(); + const channel = getActiveChannel(); + const channelPaginator = mockPaginator(channel); + spyOn(channelPaginator, 'jumpToMessage').and.callThrough(); + const parentMessageId = 'parent-message'; + const messageId = 'thread-reply'; + await service.jumpToMessage(messageId, parentMessageId); + + expect(channelPaginator.jumpToMessage).toHaveBeenCalledWith( + parentMessageId, + { pageSize: 25, suppressFocusSignal: true } + ); + expect(getLatestValue(service.activeParentMessageId$)).toBe( + parentMessageId + ); + expect(getLatestValue(service.activeParentMessage$)?.id).toBe( + parentMessageId + ); + expect(getLatestValue(service.jumpToMessage$)).toEqual({ + id: messageId, + parentId: parentMessageId, + }); + }); + + it('should jump to first unread message', async () => { + await init(); + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + const paginator = mockPaginator(getActiveChannel()); + spyOn(paginator, 'jumpToTheFirstUnreadMessage').and.callFake(() => { + paginator.messageFocusSignal.next({ signal: { messageId: '10' } }); + return Promise.resolve(true); + }); + + const result = await service.jumpToFirstUnreadMessage(); + + expect(result).toBeTrue(); + expect(paginator.jumpToTheFirstUnreadMessage).toHaveBeenCalledWith({ + pageSize: 25, + }); + expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ id: '10' }); + expect(service.isMessageLoadingInProgress).toBeFalse(); + }); + + it('should jump to first unread message - unsuccessful', async () => { + expect(await service.jumpToFirstUnreadMessage()).toBeFalse(); + + await init(); + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + const result = await service.jumpToFirstUnreadMessage(); + + expect(result).toBeFalse(); + expect(jumpToMessageIdSpy).not.toHaveBeenCalled(); + expect(service.isMessageLoadingInProgress).toBeFalse(); + }); + + it('should jump to first unread message - error', async () => { + await init(); + const paginator = mockPaginator(getActiveChannel()); + const error = new Error('error'); + spyOn(paginator, 'jumpToTheFirstUnreadMessage').and.rejectWith(error); + const notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'addTemporaryNotification'); + + await expectAsync(service.jumpToFirstUnreadMessage()).toBeRejectedWith( + error + ); + + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.Message not found' + ); + expect(service.isMessageLoadingInProgress).toBeFalse(); + }); + + it('should set active parent message', async () => { + await init(); + const channel = getActiveChannel(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[3]; + const ensureSpy = spyOn(mockChatClient.threads, 'ensure').and.callThrough(); + const threadMessagesSpy = jasmine.createSpy(); + service.activeThreadMessages$.subscribe(threadMessagesSpy); + await service.setAsActiveParentMessage(parentMessage); + const thread = mockChatClient.threads.get(parentMessage.id)!; + + expect(ensureSpy).toHaveBeenCalledWith({ + channel, + parentMessage: channel.messagePaginator.getItem(parentMessage.id)!, + }); + expect(getLatestValue(service.activeParentMessageId$)).toBe( + parentMessage.id + ); + expect(getLatestValue(service.activeParentMessage$)?.id).toBe( + parentMessage.id + ); + expect(thread.state.getLatestValue().active).toBeTrue(); + expect( + (threadMessagesSpy.calls.mostRecent().args[0] as StreamMessage[]).map( + (m) => m.id + ) + ).toEqual(thread.messagePaginator.items!.map((m) => m.id)); + + // thread paginator changes are emitted + threadMessagesSpy.calls.reset(); + const newReply = { + ...toLocal(mockMessage(100)), + parent_id: parentMessage.id, + }; + thread.messagePaginator.ingestItem(newReply); + + const replies = threadMessagesSpy.calls.mostRecent() + .args[0] as StreamMessage[]; + + expect(replies[replies.length - 1].id).toBe(newReply.id); + // thread replies don't have readBy + expect(replies[replies.length - 1].readBy).toEqual([]); + + // same thread selected twice + ensureSpy.calls.reset(); + await service.setAsActiveParentMessage(parentMessage); + + expect(ensureSpy).not.toHaveBeenCalled(); + + // deselect + threadMessagesSpy.calls.reset(); + await service.setAsActiveParentMessage(undefined); + + expect(thread.state.getLatestValue().active).toBeFalse(); + expect(threadMessagesSpy).toHaveBeenCalledWith([]); + expect(getLatestValue(service.activeParentMessageId$)).toBeUndefined(); + + threadMessagesSpy.calls.reset(); + thread.messagePaginator.ingestItem({ + ...toLocal(mockMessage(101)), + parent_id: parentMessage.id, + }); + + expect(threadMessagesSpy).not.toHaveBeenCalled(); + }); + + it('should reuse existing thread instance', async () => { + await init(); + const channel = getActiveChannel(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[3]; + const thread = mockChatClient.threads.ensure({ + channel, + parentMessage: channel.messagePaginator.getItem(parentMessage.id)!, + }); + const ensureSpy = spyOn(mockChatClient.threads, 'ensure').and.callThrough(); + spyOn(thread, 'activate').and.callThrough(); + await service.setAsActiveParentMessage(parentMessage); + + expect(ensureSpy).not.toHaveBeenCalled(); + expect(thread.activate).toHaveBeenCalledWith(); + }); + + it('should reload thread if it has stale state', async () => { + await init(); + const channel = getActiveChannel(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[3]; + const thread: MockThread = mockChatClient.threads.ensure({ + channel, + parentMessage: channel.messagePaginator.getItem(parentMessage.id)!, + }); + thread.hasStaleState = true; + const callOrder: string[] = []; + spyOn(thread, 'reload').and.callFake(() => { + callOrder.push('reload'); + return Promise.resolve(); + }); + spyOn(thread, 'activate').and.callFake(() => { + callOrder.push('activate'); + return () => {}; + }); + await service.setAsActiveParentMessage(parentMessage); + + expect(callOrder).toEqual(['reload', 'activate']); + }); + + it('should display error if thread reload failed', async () => { + await init(); + const channel = getActiveChannel(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[3]; + const thread: MockThread = mockChatClient.threads.ensure({ + channel, + parentMessage: channel.messagePaginator.getItem(parentMessage.id)!, + }); + thread.hasStaleState = true; + const error = new Error('reload failed'); + spyOn(thread, 'reload').and.rejectWith(error); + const notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'addTemporaryNotification'); + await service.setAsActiveParentMessage(parentMessage); + + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.Error loading messages' + ); + expect( + getLatestValue(service.activeThreadMessagesPaginationState$) + ).toEqual(jasmine.objectContaining({ isLoading: false, error })); + }); + + it('should emit parent message from thread, if parent message is not in the channel message list', async () => { + await init(); + const parentMessage = mockMessage(999); + await service.setAsActiveParentMessage(parentMessage); + + const activeParentMessage = getLatestValue(service.activeParentMessage$); + + expect(activeParentMessage?.id).toBe(parentMessage.id); + expect(activeParentMessage?.created_at).toEqual(parentMessage.created_at); + }); + + it('should release active thread on channel switch', async () => { + await init(); + const parentMessage = getLatestValue(service.activeChannelMessages$)[3]; + await service.setAsActiveParentMessage(parentMessage); + const thread = mockChatClient.threads.get(parentMessage.id)!; + + expect(thread.state.getLatestValue().active).toBeTrue(); + + service.setAsActiveChannel(service.channels[1]); + + expect(thread.state.getLatestValue().active).toBeFalse(); + expect(getLatestValue(service.activeThreadMessages$)).toEqual([]); + }); + it('should pin message', async () => { await init(); const message = mockMessage(); - void service.pinMessage(message); + await service.pinMessage(message); - expect(mockChatClient.pinMessage).toHaveBeenCalledWith(message); + expect(mockChatClient.pinMessage).toHaveBeenCalledWith({ id: message.id }); }); it('should display error notification if pinning was unsuccesful', async () => { @@ -2245,9 +3317,11 @@ describe('ChannelService', () => { it('should unpin message', async () => { await init(); const message = mockMessage(); - void service.unpinMessage(message); + await service.unpinMessage(message); - expect(mockChatClient.unpinMessage).toHaveBeenCalledWith(message); + expect(mockChatClient.unpinMessage).toHaveBeenCalledWith({ + id: message.id, + }); }); it('should display error notification if unpinning was unsuccesful', async () => { @@ -2265,31 +3339,30 @@ describe('ChannelService', () => { ); }); - it('should relaod active channel if active channel is not present after state reconnect', fakeAsync(async () => { + it('should keep active channel if active channel is not present after state reconnect', async () => { await init(); - flush(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - let channels!: Channel[]; - service.channels$.subscribe((c) => (channels = c!)); - channels = channels.filter((c) => c.id !== activeChannel.id); - spyOn(activeChannel, 'watch'); + const activeChannel = getActiveChannel(); + const channels = service.channels.filter( + (c) => c.cid !== activeChannel.cid + ); mockChatClient.queryChannels.and.resolveTo(channels); - events$.next({ eventType: 'connection.recovered' } as ClientEvent); - tick(); - flush(); const spy = jasmine.createSpy(); service.activeChannel$.subscribe(spy); + spy.calls.reset(); + spyOn(service, 'deselectActiveChannel').and.callThrough(); + events$.next({ eventType: 'connection.recovered' } as ClientEvent); + await settle(); - expect(spy).toHaveBeenCalledWith(activeChannel); - expect(activeChannel.watch).toHaveBeenCalledWith(); - })); + // The active channel is activated, so stream-chat reloads it after reconnect + expect(service.deselectActiveChannel).not.toHaveBeenCalled(); + expect(service.activeChannel).toBe(activeChannel); + expect(service.channels).toContain(activeChannel); + }); - it(`shouldn't deselect active channel if active channel is present after state reconnect`, fakeAsync(async () => { + it(`shouldn't deselect active channel if active channel is present after state reconnect`, async () => { await init(); - let channels!: Channel[]; - service.channels$.subscribe((c) => (channels = c!)); - const activeChannel = channels[0]; + const channels = service.channels; + const activeChannel = channels[0] as MockChannel; const spy = jasmine.createSpy(); service.activeChannel$.subscribe(spy); spy.calls.reset(); @@ -2298,19 +3371,23 @@ describe('ChannelService', () => { messagesSpy.calls.reset(); mockChatClient.queryChannels.and.resolveTo(channels); spyOn(service, 'deselectActiveChannel').and.callThrough(); - const newMessage = generateMockMessages()[0]; - newMessage.text = 'new message received while offline'; - activeChannel.state.messages.push(newMessage); events$.next({ eventType: 'connection.recovered' } as ClientEvent); - tick(); - flush(); + // stream-chat reloads the active channel + const newMessage = { + ...toLocal(mockMessage(100)), + text: 'new message received while offline', + }; + activeChannel.messagePaginator.ingestItem(newMessage); + await settle(); expect(spy).not.toHaveBeenCalled(); expect(service.deselectActiveChannel).not.toHaveBeenCalled(); expect(messagesSpy).toHaveBeenCalledWith( - jasmine.arrayContaining([newMessage]) + jasmine.arrayContaining([ + jasmine.objectContaining({ id: newMessage.id, text: newMessage.text }), + ]) ); - })); + }); it('should add new channel to channel list', () => { const channelsSpy = jasmine.createSpy(); @@ -2340,20 +3417,22 @@ describe('ChannelService', () => { const spy = jasmine.createSpy(); service.activeChannel$.subscribe(spy); spy.calls.reset(); + spyOn(activeChannel, 'activate').and.callThrough(); service.setAsActiveChannel(activeChannel); expect(spy).not.toHaveBeenCalled(); + expect(activeChannel.activate).not.toHaveBeenCalled(); }); it('should set last read message id', () => { const activeChannel = generateMockChannels()[0]; activeChannel.id = 'next-active-channel'; activeChannel.state.read[user.id] = { - last_read: new Date(), + last_read: dateToNs(new Date()), last_read_message_id: 'last-read-message-id', unread_messages: 5, user: user, - }; + } as unknown as Channel['state']['read'][string]; service.setAsActiveChannel(activeChannel); @@ -2365,16 +3444,17 @@ describe('ChannelService', () => { const activeChannel = generateMockChannels()[0]; activeChannel.id = 'next-active-channel'; activeChannel.state.read[user.id] = { - last_read: new Date(), + last_read: dateToNs(new Date()), last_read_message_id: 'last-read-message-id', unread_messages: 0, user: user, + } as unknown as Channel['state']['read'][string]; + const items = [...activeChannel.messagePaginator.items!]; + items[items.length - 1] = { + ...items[items.length - 1], + id: 'last-read-message-id', }; - activeChannel.state.latestMessages = [ - { - id: 'last-read-message-id', - } as any as FormatMessageResponse, - ]; + activeChannel.messagePaginator.setItems(items); service.setAsActiveChannel(activeChannel); @@ -2385,8 +3465,7 @@ describe('ChannelService', () => { it('should be able to select empty channel as active channel', () => { const channel = generateMockChannels()[0]; channel.id = 'new-empty-channel'; - channel.state.messages = []; - channel.state.latestMessages = []; + channel.messagePaginator.setItems([]); service.setAsActiveChannel(channel); @@ -2396,40 +3475,45 @@ describe('ChannelService', () => { expect(spy).toHaveBeenCalledWith(channel); expect(service.activeChannelLastReadMessageId).toBeUndefined(); expect(service.activeChannelUnreadCount).toBe(0); + expect(getLatestValue(service.activeChannelMessages$)).toEqual([]); }); it('should update user references on `user.updated` event', async () => { await init(); const spy = jasmine.createSpy(); service.activeChannel$.subscribe(spy); - let activeChannel!: Channel; - service.activeChannel$.pipe(take(1)).subscribe((c) => (activeChannel = c!)); + const channelsSpy = jasmine.createSpy(); + service.channels$.subscribe(channelsSpy); + const activeChannel = getActiveChannel(); + // stream-chat updates the user references activeChannel.state.members['jack'].user!.name = 'John'; - mockChatClient.activeChannels[activeChannel.cid] = activeChannel; spy.calls.reset(); + channelsSpy.calls.reset(); events$.next({ eventType: 'user.updated' } as ClientEvent); const updatedChannel = spy.calls.mostRecent().args[0] as Channel; expect(updatedChannel.state.members['jack'].user!.name).toBe('John'); + expect(channelsSpy).toHaveBeenCalledWith( + jasmine.arrayContaining([activeChannel]) + ); }); it('should load message reactions', async () => { await init(); - const activeChannel = service.activeChannel!; const message = service.activeChannelMessages[0]; const mockReactions = [ { type: 'wow', user: { id: 'jack' } }, ] as ReactionResponse[]; - spyOn(activeChannel, 'getReactions').and.resolveTo({ + mockChatClient.getReactions.and.resolveTo({ reactions: mockReactions, duration: '', }); const reactions = await service.getMessageReactions(message.id); - expect(activeChannel.getReactions).toHaveBeenCalledWith( - message.id, - jasmine.anything() + expect(mockChatClient.getReactions).toHaveBeenCalledWith( + { id: message.id }, + jasmine.objectContaining({ offset: 0, limit: 300 }) ); expect(reactions).toEqual(mockReactions); @@ -2437,7 +3521,6 @@ describe('ChannelService', () => { it('should load message reactions - multiple pages', async () => { await init(); - const activeChannel = service.activeChannel!; const message = service.activeChannelMessages[0]; const mockReactionsFirstPage = new Array(300) .fill(null) @@ -2446,7 +3529,7 @@ describe('ChannelService', () => { .fill(null) .map(() => ({ type: 'wow', user: { id: 'jack' } })) as ReactionResponse[]; let counter = 0; - spyOn(activeChannel, 'getReactions').and.callFake(() => { + mockChatClient.getReactions.and.callFake(() => { if (counter === 0) { counter++; return Promise.resolve({ @@ -2462,6 +3545,10 @@ describe('ChannelService', () => { }); const reactions = await service.getMessageReactions(message.id); + expect(mockChatClient.getReactions).toHaveBeenCalledWith( + { id: message.id }, + jasmine.objectContaining({ offset: 300 }) + ); expect(reactions).toEqual([ ...mockReactionsFirstPage, ...mockReactionsSecondPage, @@ -2470,12 +3557,11 @@ describe('ChannelService', () => { it('should load message reactions - but no more than 1200', async () => { await init(); - const activeChannel = service.activeChannel!; const message = service.activeChannelMessages[0]; const mockReactionsPage = new Array(300) .fill(null) .map(() => ({ type: 'wow', user: { id: 'jack' } })) as ReactionResponse[]; - spyOn(activeChannel, 'getReactions').and.resolveTo({ + mockChatClient.getReactions.and.resolveTo({ reactions: mockReactionsPage, duration: '', }); @@ -2486,10 +3572,9 @@ describe('ChannelService', () => { it('should load message reactions - error', async () => { await init(); - const activeChannel = service.activeChannel!; const message = service.activeChannelMessages[0]; const error = new Error('Reactions loading failed'); - spyOn(activeChannel, 'getReactions').and.rejectWith(error); + mockChatClient.getReactions.and.rejectWith(error); const notificationService = TestBed.inject(NotificationService); const notificationSpy = jasmine.createSpy(); notificationService.notifications$.subscribe(notificationSpy); @@ -2506,7 +3591,7 @@ describe('ChannelService', () => { it('should mark message as unread', async () => { await init(); - const activeChannel = service.activeChannel!; + const activeChannel = getActiveChannel(); const message = service.activeChannelMessages[0]; spyOn(activeChannel, 'markUnread').and.resolveTo(); @@ -2519,7 +3604,7 @@ describe('ChannelService', () => { it('should mark message as unread - error', async () => { await init(); - const activeChannel = service.activeChannel!; + const activeChannel = getActiveChannel(); const message = service.activeChannelMessages[0]; const error = { @@ -2565,10 +3650,11 @@ describe('ChannelService', () => { events$.next({ eventType: 'notification.mark_unread', event: { + type: 'notification.mark_unread', channel_id: service.activeChannel?.id, unread_messages: 12, last_read_message_id: 'last-read-message', - } as Event, + } as unknown as Event, }); expect(service.activeChannelLastReadMessageId).toBe('last-read-message'); @@ -2577,10 +3663,11 @@ describe('ChannelService', () => { events$.next({ eventType: 'notification.mark_unread', event: { + type: 'notification.mark_unread', channel_id: 'not-active-channel', unread_messages: 20, last_read_message_id: 'different id', - } as Event, + } as unknown as Event, }); expect(service.activeChannelLastReadMessageId).toBe('last-read-message'); @@ -2589,18 +3676,21 @@ describe('ChannelService', () => { it('should halt marking the channel as read if an unread call was made in that session', async () => { await init(); - const activeChannel = service.activeChannel!; - spyOn(activeChannel, 'markRead'); + await waitForMarkReadThrottle(); + const activeChannel = getActiveChannel(); + spyOn(activeChannel, 'markReadViaReporter'); await service.markMessageUnread('message-id'); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(100)), + }); - expect(activeChannel.markRead).not.toHaveBeenCalled(); + expect(activeChannel.markReadViaReporter).not.toHaveBeenCalled(); service.deselectActiveChannel(); service.setAsActiveChannel(activeChannel); - expect(activeChannel.markRead).toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledWith(); }); it('channel list setter should respect channel order', async () => { @@ -2608,8 +3698,6 @@ describe('ChannelService', () => { const currentChannels = service.channels; const newChannel = generateMockChannels()[0]; const newChannels = [currentChannels[0], newChannel, currentChannels[1]]; - // @ts-expect-error this is how we can differentiate between Channel and ChannelResponse - newChannels.forEach((c) => (c._client = {})); const spy = jasmine.createSpy(); service.channels$.subscribe(spy); spy.calls.reset(); @@ -2627,8 +3715,6 @@ describe('ChannelService', () => { const unsubscribeSpy = jasmine.createSpy(); spyOn(newChannel, 'on').and.returnValue({ unsubscribe: unsubscribeSpy }); const newChannels = [currentChannels[0], newChannel, currentChannels[1]]; - // @ts-expect-error this is how we can differentiate between Channel and ChannelResponse - newChannels.forEach((c) => (c._client = {})); service['channelListSetter'](newChannels); @@ -2651,6 +3737,13 @@ describe('ChannelService', () => { expect(customQuery).toHaveBeenCalledWith('first-page'); expect(service['shouldSetActiveChannel']).toBeFalse(); expect(service['messagePageSize']).toBe(30); + expect(mockChatClient.config.setConfig).toHaveBeenCalledWith('channel', { + messagePaginator: { pageSize: 30 }, + }); + expect(mockChatClient.config.setConfig).toHaveBeenCalledWith('thread', { + messagePaginator: { pageSize: 30 }, + }); + expect(mockChatClient.threads.registerSubscriptions).toHaveBeenCalledWith(); expect(hasMoreSpy).toHaveBeenCalledWith(true); customQuery.calls.reset(); @@ -2670,72 +3763,76 @@ describe('ChannelService', () => { it('should throttle mark read API calls', async () => { await init(); // wait for mark read throttle time - await new Promise((resolve) => { - setTimeout(resolve, service['markReadThrottleTime']); - }); + await waitForMarkReadThrottle(); - const activeChannel = service.activeChannel!; - spyOn(activeChannel, 'markRead'); + const activeChannel = getActiveChannel(); + spyOn(activeChannel, 'markReadViaReporter'); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(100)), + }); - expect(activeChannel.markRead).toHaveBeenCalledTimes(1); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(1); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(101)), + }); - expect(activeChannel.markRead).toHaveBeenCalledTimes(1); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(1); // wait for mark read throttle time - await new Promise((resolve) => { - setTimeout(resolve, service['markReadThrottleTime']); - }); + await waitForMarkReadThrottle(); - expect(activeChannel.markRead).toHaveBeenCalledTimes(2); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(2); }); it('should throttle mark read API calls - channel change', async () => { await init(); // wait for mark read throttle time - await new Promise((resolve) => { - setTimeout(resolve, service['markReadThrottleTime']); - }); + await waitForMarkReadThrottle(); - const activeChannel = service.activeChannel!; - spyOn(activeChannel, 'markRead'); + const activeChannel = getActiveChannel(); + spyOn(activeChannel, 'markReadViaReporter'); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(100)), + }); - expect(activeChannel.markRead).toHaveBeenCalledTimes(1); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(1); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(101)), + }); - expect(activeChannel.markRead).toHaveBeenCalledTimes(1); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(1); service.setAsActiveChannel(service.channels[1]); - expect(activeChannel.markRead).toHaveBeenCalledTimes(2); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(2); }); it('should throttle mark read API calls - reset', async () => { await init(); // wait for mark read throttle time - await new Promise((resolve) => { - setTimeout(resolve, service['markReadThrottleTime']); - }); + await waitForMarkReadThrottle(); - const activeChannel = service.activeChannel!; - spyOn(activeChannel, 'markRead'); + const activeChannel = getActiveChannel(); + spyOn(activeChannel, 'markReadViaReporter'); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(100)), + }); - expect(activeChannel.markRead).toHaveBeenCalledTimes(1); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(1); - (activeChannel as MockChannel).handleEvent('message.new', mockMessage()); + activeChannel.handleEvent('message.new', { + message: toLocal(mockMessage(101)), + }); - expect(activeChannel.markRead).toHaveBeenCalledTimes(1); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(1); service.reset(); - expect(activeChannel.markRead).toHaveBeenCalledTimes(2); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledTimes(2); }); }); diff --git a/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts b/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts index d0a1b19f..97ef4e82 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.thread.spec.ts @@ -2,86 +2,100 @@ import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { Subject } from 'rxjs'; import { first } from 'rxjs/operators'; import { + Attachment, Channel, - ChannelOptions, - ChannelSort, + dateToNs, Event, - GetRepliesAPIResponse, - SendMessageAPIResponse, + LocalMessage, + nowNs, UserResponse, } from 'stream-chat'; import { ChannelService } from './channel.service'; import { ChatClientService, ClientEvent } from './chat-client.service'; import { generateMockChannels, + generateMockLocalMessages, MockChannel, - mockCurrentUser, mockMessage, + MockMessagePaginator, + mockStreamChatClient, + MockStreamChatClient, + MockThread, } from './mocks'; import { NotificationService } from './notification.service'; import { StreamMessage } from './types'; +import { toLocalMessage } from './local-message'; + +const getPaginator = (channel: Channel | undefined) => + channel!.messagePaginator as unknown as MockMessagePaginator; describe('ChannelService - threads', () => { let service: ChannelService; - let mockChatClient: { - queryChannels: jasmine.Spy; - channel: jasmine.Spy; - updateMessage: jasmine.Spy; - deleteMessage: jasmine.Spy; - userID: string; - }; + let client: MockStreamChatClient; let events$: Subject; - let connectionState$: Subject<'online' | 'offline'>; - let init: ( - c?: Channel[], - sort?: ChannelSort, - options?: ChannelOptions - ) => Promise; - let user: UserResponse; + let init: (c?: Channel[]) => Promise; + let notificationService: NotificationService; const filters = { type: 'messaging' }; + const getActiveChannel = () => service.activeChannel as MockChannel; + + const getThread = (parentId: string) => client.threads.get(parentId)!; + + const createThread = (parentId: string) => { + const parentMessage = toLocalMessage(mockMessage()); + parentMessage.id = parentId; + return client.threads.ensure({ + channel: getActiveChannel(), + parentMessage, + }); + }; + + const getLatestValue = (observable: { + subscribe: (cb: (v: T) => void) => { unsubscribe: () => void }; + }) => { + let value!: T; + observable.subscribe((v) => (value = v)).unsubscribe(); + return value; + }; + + const getChannelMessages = () => + getLatestValue(service.activeChannelMessages$); + beforeEach(() => { - user = mockCurrentUser(); - connectionState$ = new Subject<'online' | 'offline'>(); - mockChatClient = { - queryChannels: jasmine - .createSpy() - .and.returnValue(generateMockChannels()), - channel: jasmine.createSpy(), - updateMessage: jasmine.createSpy(), - deleteMessage: jasmine.createSpy(), - userID: user.id, - }; + client = mockStreamChatClient(); events$ = new Subject(); TestBed.configureTestingModule({ providers: [ { provide: ChatClientService, useValue: { - chatClient: { ...mockChatClient, user }, + chatClient: client, events$, - connectionState$, + logger: jasmine.createSpyObj('logger', ['warn', 'error', 'info']), }, }, NotificationService, ], }); service = TestBed.inject(ChannelService); - init = async ( - channels?: Channel[], - sort?: ChannelSort, - options?: ChannelOptions - ) => { - mockChatClient.queryChannels.and.returnValue( + notificationService = TestBed.inject(NotificationService); + init = async (channels?: Channel[]) => { + client.queryChannelsAndHydrate.and.returnValue( channels || generateMockChannels() ); - await service.init(filters, sort, options); + await service.init(filters); }; }); it('should reset active parent message and thread messages after channel changed', async () => { await init(); + const parentMessage = getChannelMessages()[0]; + await service.setAsActiveParentMessage(parentMessage); + const thread = getThread(parentMessage.id); + + expect(thread.state.getLatestValue().active).toBeTrue(); + const mockChannels = generateMockChannels(); const messagesSpy = jasmine.createSpy(); const activeParentMessageIdSpy = jasmine.createSpy(); @@ -98,6 +112,8 @@ describe('ChannelService - threads', () => { expect(messagesSpy).toHaveBeenCalledWith([]); expect(activeParentMessageIdSpy).toHaveBeenCalledWith(undefined); expect(activeParentMessageSpy).toHaveBeenCalledWith(undefined); + // thread is released + expect(thread.state.getLatestValue().active).toBeFalse(); }); it('should set active parent message and emit thread messages', async () => { @@ -111,28 +127,129 @@ describe('ChannelService - threads', () => { messagesSpy.calls.reset(); activeParentMessageIdSpy.calls.reset(); activeParentMessageSpy.calls.reset(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); - let parentMessage!: StreamMessage; - service.activeChannelMessages$.subscribe((m) => (parentMessage = m[0])); - const replies = [mockMessage(), mockMessage(), mockMessage()]; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); + const channel = getActiveChannel(); + const parentMessage = getLatestValue( + service.activeChannelMessages$ + )[0]; + spyOn(client.threads, 'ensure').and.callThrough(); await service.setAsActiveParentMessage(parentMessage); - expect(messagesSpy).toHaveBeenCalledWith(replies); + expect(client.threads.ensure).toHaveBeenCalledWith({ + channel, + // the message object stored by stream-chat is used + parentMessage: getPaginator(channel).getItem(parentMessage.id)!, + }); + + const thread = getThread(parentMessage.id); + const replies = thread.messagePaginator.items!; + + expect(replies.length).toBeGreaterThan(0); + expect(messagesSpy).toHaveBeenCalledWith( + replies.map((r) => jasmine.objectContaining({ id: r.id })) + ); + expect(activeParentMessageIdSpy).toHaveBeenCalledWith(parentMessage.id); expect(activeParentMessageSpy).toHaveBeenCalledWith(parentMessage); + expect(thread.state.getLatestValue().active).toBeTrue(); + }); + + it('should reuse existing thread instance', async () => { + await init(); + const parentMessage = getChannelMessages()[0]; + const thread = createThread(parentMessage.id); + spyOn(client.threads, 'ensure').and.callThrough(); + const replies = generateMockLocalMessages(100).map((m) => ({ + ...m, + parent_id: parentMessage.id, + })); + thread.messagePaginator.setItems(replies); + await service.setAsActiveParentMessage(parentMessage); + + expect(client.threads.ensure).not.toHaveBeenCalled(); + expect( + getLatestValue(service.activeThreadMessages$).map( + (m) => m.id + ) + ).toEqual(replies.map((r) => r.id)); + + expect(thread.state.getLatestValue().active).toBeTrue(); + }); + + it('should reload stale thread before activating it', async () => { + await init(); + const parentMessage = getChannelMessages()[0]; + const thread = createThread(parentMessage.id); + thread.hasStaleState = true; + let isActiveDuringReload: boolean | undefined; + let isLoadingDuringReload: boolean | undefined; + const reloadedReplies = generateMockLocalMessages(200).map((m) => ({ + ...m, + parent_id: parentMessage.id, + })); + spyOn(thread, 'reload').and.callFake(() => { + isActiveDuringReload = thread.state.getLatestValue().active; + isLoadingDuringReload = getLatestValue<{ isLoading: boolean }>( + service.activeThreadMessagesPaginationState$ + ).isLoading; + thread.messagePaginator.setItems(reloadedReplies); + return Promise.resolve(); + }); + spyOn(thread, 'activate').and.callThrough(); + await service.setAsActiveParentMessage(parentMessage); + + expect(thread.reload).toHaveBeenCalledWith(); + expect(isActiveDuringReload).toBeFalse(); + expect(isLoadingDuringReload).toBeTrue(); + expect(thread.activate).toHaveBeenCalledWith(); + expect(thread.state.getLatestValue().active).toBeTrue(); + expect( + getLatestValue(service.activeThreadMessages$).map( + (m) => m.id + ) + ).toEqual(reloadedReplies.map((r) => r.id)); + }); + + it(`shouldn't reload thread if state isn't stale`, async () => { + await init(); + const parentMessage = getChannelMessages()[0]; + const thread = createThread(parentMessage.id); + spyOn(thread, 'reload').and.callThrough(); + await service.setAsActiveParentMessage(parentMessage); + + expect(thread.reload).not.toHaveBeenCalled(); + expect(thread.state.getLatestValue().active).toBeTrue(); + }); + + it('should display notification if stale thread reload fails', async () => { + await init(); + const parentMessage = getChannelMessages()[0]; + const thread = createThread(parentMessage.id); + thread.hasStaleState = true; + const error = new Error('Network error'); + spyOn(thread, 'reload').and.rejectWith(error); + spyOn(notificationService, 'addTemporaryNotification'); + await service.setAsActiveParentMessage(parentMessage); + + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.Error loading messages' + ); + + const paginationState = getLatestValue<{ + isLoading: boolean; + error?: Error; + }>(service.activeThreadMessagesPaginationState$); + + expect(paginationState.isLoading).toBeFalse(); + expect(paginationState.error).toBe(error); }); it('should remove active parent message and reset thread messages', async () => { await init(); - let parentMessage!: StreamMessage; - service.activeChannelMessages$.subscribe((m) => (parentMessage = m[0])); + const parentMessage = getChannelMessages()[0]; const message = mockMessage(); message.parent_id = parentMessage.id; await service.setAsActiveParentMessage(parentMessage); + const thread = getThread(parentMessage.id); await service.jumpToMessage(message.id, message.parent_id); service.selectMessageToQuote(message); const messagesSpy = jasmine.createSpy(); @@ -160,19 +277,43 @@ describe('ChannelService - threads', () => { id: undefined, parentId: undefined, }); + + expect(thread.state.getLatestValue().active).toBeFalse(); + + // Thread paginator changes are no longer emitted + messagesSpy.calls.reset(); + thread.messagePaginator.setItems([]); + + expect(messagesSpy).not.toHaveBeenCalled(); }); - it('should handle if selected parent message is removed from message list', async () => { + it(`should use the thread's parent message if parent message isn't in the loaded message list`, async () => { await init(); - let parentMessage!: StreamMessage; - service.activeChannelMessages$.subscribe((m) => (parentMessage = m[0])); - parentMessage.id = 'parentMessage'; - service.activeParentMessage$.subscribe(); + const parentMessage = getChannelMessages()[0]; + const parentMessageSpy = jasmine.createSpy(); + service.activeParentMessage$.subscribe(parentMessageSpy); await service.setAsActiveParentMessage(parentMessage); spyOn(service, 'setAsActiveParentMessage').and.callThrough(); - await service.jumpToMessage('message-very-far-away'); + getPaginator(getActiveChannel()).loadAround = () => + generateMockLocalMessages(100); + await service.jumpToMessage('100'); + + expect( + getChannelMessages().find((m) => m.id === parentMessage.id) + ).toBeUndefined(); + + expect(service.setAsActiveParentMessage).not.toHaveBeenCalledWith( + undefined + ); + + expect(parentMessageSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ id: parentMessage.id }) + ); - expect(service.setAsActiveParentMessage).toHaveBeenCalledWith(undefined); + expect( + getLatestValue(service.activeParentMessage$) + ?.id + ).toBe(parentMessage.id); }); it(`shouldn't deselect message to quote, if not a thread reply`, async () => { @@ -225,10 +366,40 @@ describe('ChannelService - threads', () => { expect(activeParentMessageSpy).toHaveBeenCalledWith(undefined); }); - it('should deselect thread after reconnect', fakeAsync(async () => { + it('should mark the reply list of a new thread (without replies) as complete', async () => { await init(); - let parentMessage!: StreamMessage; - service.activeChannelMessages$.subscribe((m) => (parentMessage = m[0])); + const parentMessage = getChannelMessages()[0]; + const thread = client.threads.ensure({ + channel: service.activeChannel!, + parentMessage: service.activeChannel!.messagePaginator.getItem( + parentMessage.id + )!, + }); + // stream-chat doesn't load threads without replies, the paginator keeps its defaults + thread.state.partialNext({ replyCount: 0, isStateStale: false }); + thread.messagePaginator.state.next({ + items: undefined, + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + }); + await service.setAsActiveParentMessage(parentMessage); + + expect(thread.messagePaginator.hasMoreHead).toBeFalse(); + expect(thread.messagePaginator.hasMoreTail).toBeFalse(); + // left unset, so stream-chat places the replies of a reload (e.g. after reconnect) as a first page + expect(thread.messagePaginator.items).toBeUndefined(); + expect(getLatestValue(service.activeThreadMessages$)).toEqual([]); + expect( + getLatestValue(service.activeThreadMessagesPaginationState$) + ).toEqual( + jasmine.objectContaining({ hasMoreNewer: false, hasMoreOlder: false }) + ); + }); + + it('should keep thread open after reconnect (stream-chat reloads active threads)', fakeAsync(async () => { + await init(); + const parentMessage = getChannelMessages()[0]; await service.setAsActiveParentMessage(parentMessage); const spy = jasmine.createSpy(); service.activeParentMessage$.subscribe(spy); @@ -237,83 +408,125 @@ describe('ChannelService - threads', () => { tick(); flush(); - expect(spy).toHaveBeenCalledWith(undefined); + expect(spy).not.toHaveBeenCalledWith(undefined); + expect(getLatestValue(service.activeParentMessage$)?.id).toBe( + parentMessage.id + ); })); - it('should not add readBy field to messages', async () => { + it('should set empty readBy field for thread messages', async () => { await init(); - const messagesSpy = jasmine.createSpy(); - service.activeThreadMessages$.subscribe(messagesSpy); - messagesSpy.calls.reset(); const message = mockMessage(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); - const replies = [mockMessage(), mockMessage(), mockMessage()]; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(message); - let threadMessages!: StreamMessage[]; - service.activeThreadMessages$.subscribe((m) => (threadMessages = m)); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); expect(threadMessages.length).toBeGreaterThan(0); - threadMessages.forEach((m) => expect(m.readBy).toBeDefined()); + threadMessages.forEach((m) => expect(m.readBy).toEqual([])); }); it('should load more older messages', async () => { await init(); - const messagesSpy = jasmine.createSpy(); - service.activeThreadMessages$.subscribe(messagesSpy); - messagesSpy.calls.reset(); const parentMessage = mockMessage(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); - const replies = [mockMessage(), mockMessage(), mockMessage()]; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); - replies[0].id = 'firstreply'; await service.setAsActiveParentMessage(parentMessage); - let threadMessages!: StreamMessage[]; - (channel.getReplies as jasmine.Spy).calls.reset(); - channel.state.threads = { - [parentMessage.id]: [...replies, mockMessage(), mockMessage()], - }; + const paginator = getThread(parentMessage.id).messagePaginator; + const olderReplies = generateMockLocalMessages(100, true) + .slice(0, 2) + .map((m) => ({ ...m, parent_id: parentMessage.id })); + paginator.loadPage = jasmine + .createSpy() + .and.callFake((direction: 'tail' | 'head') => + direction === 'tail' ? olderReplies : [] + ); + spyOn(paginator, 'toTail').and.callThrough(); + const prevCount = getLatestValue( + service.activeThreadMessages$ + ).length; await service.loadMoreThreadReplies(); - service.activeThreadMessages$.subscribe((m) => (threadMessages = m)); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); - expect(channel.getReplies).toHaveBeenCalledWith(parentMessage.id, { - limit: 25, - id_lt: replies[0].id, - }); + expect(paginator.toTail).toHaveBeenCalledWith(); + expect(threadMessages.length).toBe(prevCount + 2); + expect(threadMessages[0].id).toBe(olderReplies[0].id); + }); - expect(threadMessages.length).toBe(5); + it(`shouldn't load older messages if there are no more older messages`, async () => { + await init(); + const parentMessage = mockMessage(); + await service.setAsActiveParentMessage(parentMessage); + const paginator = getThread(parentMessage.id).messagePaginator; + paginator.state.partialNext({ hasMoreTail: false }); + spyOn(paginator, 'toTail').and.callThrough(); + + expect(service.loadMoreThreadReplies('older')).toBeFalse(); + expect(paginator.toTail).not.toHaveBeenCalled(); }); - it('should do nothing if we want to load newer messages', async () => { + it('should do nothing if we want to load newer messages, but newest replies are loaded', async () => { await init(); - const messagesSpy = jasmine.createSpy(); - service.activeThreadMessages$.subscribe(messagesSpy); - messagesSpy.calls.reset(); const parentMessage = mockMessage(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); - const replies = [mockMessage(), mockMessage(), mockMessage()]; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); - replies[0].id = 'firstreply'; await service.setAsActiveParentMessage(parentMessage); - (channel.getReplies as jasmine.Spy).calls.reset(); - channel.state.threads = { - [parentMessage.id]: [mockMessage(), mockMessage(), ...replies], - }; + const paginator = getThread(parentMessage.id).messagePaginator; + spyOn(paginator, 'toHead').and.callThrough(); + + expect(paginator.hasMoreHead).toBeFalse(); + expect(service.loadMoreThreadReplies('newer')).toBeFalse(); + expect(paginator.toHead).not.toHaveBeenCalled(); + }); + + it('should load newer messages', async () => { + await init(); + const parentMessage = mockMessage(); + await service.setAsActiveParentMessage(parentMessage); + const paginator = getThread(parentMessage.id).messagePaginator; + paginator.state.partialNext({ hasMoreHead: true }); + const newerReplies = generateMockLocalMessages(100) + .slice(0, 3) + .map((m) => ({ ...m, parent_id: parentMessage.id })); + paginator.loadPage = (direction) => + direction === 'head' ? newerReplies : []; + spyOn(paginator, 'toHead').and.callThrough(); + const prevCount = getLatestValue( + service.activeThreadMessages$ + ).length; await service.loadMoreThreadReplies('newer'); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); + + expect(paginator.toHead).toHaveBeenCalledWith(); + expect(threadMessages.length).toBe(prevCount + 3); + expect(threadMessages[threadMessages.length - 1].id).toBe( + newerReplies[2].id + ); + }); + + it('should rethrow pagination error when loading thread replies', async () => { + await init(); + const parentMessage = mockMessage(); + await service.setAsActiveParentMessage(parentMessage); + const paginator = getThread(parentMessage.id).messagePaginator; + const error = new Error('Query failed'); + paginator.loadPage = () => { + paginator.state.partialNext({ lastQueryError: error }); + return []; + }; + + await expectAsync( + service.loadMoreThreadReplies() as Promise + ).toBeRejectedWith(error); + }); + + it(`shouldn't load thread replies if there is no active thread`, async () => { + await init(); - expect(channel.getReplies).not.toHaveBeenCalled(); + expect(service.loadMoreThreadReplies()).toBeFalse(); }); - it('should watch for new message events', async () => { + it('should emit new thread messages and mark channel read on new message', async () => { await init(); // wait for mark read throttle time await new Promise((resolve) => { @@ -323,105 +536,92 @@ describe('ChannelService - threads', () => { const parentMessage = mockMessage(); await service.setAsActiveParentMessage(parentMessage); service.activeThreadMessages$.subscribe(spy); - const prevCount = (spy.calls.mostRecent().args[0] as Channel[]).length; + const prevCount = (spy.calls.mostRecent().args[0] as StreamMessage[]) + .length; spy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); + const activeChannel = getActiveChannel(); const newMessage = mockMessage(); + newMessage.id = 'new-reply'; newMessage.parent_id = parentMessage.id; - activeChannel.state.threads = { [parentMessage.id]: [newMessage] }; - spyOn(activeChannel, 'markRead'); - (activeChannel as MockChannel).handleEvent('message.new', { + spyOn(activeChannel, 'markReadViaReporter'); + // stream-chat adds the message to the thread's paginator + getThread(parentMessage.id).messagePaginator.ingestItem( + toLocalMessage(newMessage) + ); + activeChannel.handleEvent('message.new', { message: newMessage, }); const newCount = (spy.calls.mostRecent().args[0] as StreamMessage[]).length; expect(newCount).toBe(prevCount + 1); - expect(activeChannel.markRead).toHaveBeenCalledWith(); + expect(activeChannel.markReadViaReporter).toHaveBeenCalledWith(); }); - it('should only add new message to thread, if the parent id is the same as active thread id', async () => { + it('should only emit thread messages from the active thread', async () => { await init(); const spy = jasmine.createSpy(); const parentMessage = mockMessage(); await service.setAsActiveParentMessage(parentMessage); service.activeThreadMessages$.subscribe(spy); spy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); + const otherThread = createThread('not' + parentMessage.id); const newMessage = mockMessage(); - newMessage.parent_id = 'not' + parentMessage.id; - activeChannel.state.threads = { - [newMessage.parent_id]: [newMessage], - [parentMessage.id]: [], - }; - (activeChannel as MockChannel).handleEvent('message.new', { - message: newMessage, - }); + newMessage.id = 'new-reply'; + newMessage.parent_id = otherThread.id; + otherThread.messagePaginator.ingestItem(toLocalMessage(newMessage)); expect(spy).not.toHaveBeenCalled(); }); - it('should watch for message update events', async () => { + it('should emit updated thread messages', async () => { await init(); const parentMessage = mockMessage(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - const messageToUpdate = mockMessage(); - messageToUpdate.parent_id = parentMessage.id; - spyOn(activeChannel, 'getReplies').and.resolveTo({ - messages: [messageToUpdate], - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); - messageToUpdate.text = 'updated'; - activeChannel.state.threads = { [parentMessage.id]: [messageToUpdate] }; - (activeChannel as MockChannel).handleEvent('message.updated', { - message: messageToUpdate, - }); - let message!: StreamMessage; - service.activeThreadMessages$.subscribe( - (messages) => (message = messages[0]) - ); - + const paginator = getThread(parentMessage.id).messagePaginator; + const messageToUpdate = paginator.items![0]; + paginator.ingestItem({ ...messageToUpdate, text: 'updated' }); + const message = getLatestValue( + service.activeThreadMessages$ + )[0]; + + expect(message.id).toBe(messageToUpdate.id); expect(message.text).toBe('updated'); }); - it('should watch for message deleted events', async () => { + it('should emit deleted thread messages', async () => { await init(); const parentMessage = mockMessage(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); - const messageToDelete = mockMessage(); - messageToDelete.parent_id = parentMessage.id; - spyOn(activeChannel, 'getReplies').and.resolveTo({ - messages: [messageToDelete], - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); - messageToDelete.deleted_at = new Date(); - activeChannel.state.threads = { [parentMessage.id]: [messageToDelete] }; - (activeChannel as MockChannel).handleEvent('message.deleted', { - message: messageToDelete, + const paginator = getThread(parentMessage.id).messagePaginator; + const messageToDelete = paginator.items![0]; + const deletedAt = dateToNs(new Date()); + paginator.ingestItem({ + ...messageToDelete, + type: 'deleted', + deleted_at: deletedAt, }); - let message!: StreamMessage; - service.activeThreadMessages$.subscribe( - (messages) => (message = messages[0]) - ); + const message = getLatestValue( + service.activeThreadMessages$ + )[0]; - expect(message.deleted_at).toBeDefined(); + expect(message.deleted_at).toBe(deletedAt); }); it('should handle if channel is truncated', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); + const parentMessage = getChannelMessages()[0]; + await service.setAsActiveParentMessage(parentMessage); + const thread = getThread(parentMessage.id); const messagesSpy = jasmine.createSpy(); service.activeThreadMessages$.subscribe(messagesSpy); const parentMessageSpy = jasmine.createSpy(); service.activeParentMessageId$.subscribe(parentMessageSpy); messagesSpy.calls.reset(); parentMessageSpy.calls.reset(); - (channel as MockChannel).handleEvent('channel.truncated', { + channel.handleEvent('channel.truncated', { type: 'channel.truncated', + cid: channel.cid, channel: { cid: channel.cid, }, @@ -429,6 +629,7 @@ describe('ChannelService - threads', () => { expect(messagesSpy).toHaveBeenCalledWith([]); expect(parentMessageSpy).toHaveBeenCalledWith(undefined); + expect(thread.state.getLatestValue().active).toBeFalse(); }); it('should call #customChannelTruncatedHandler, if channel is truncated and custom handler is provided', async () => { @@ -453,6 +654,7 @@ describe('ChannelService - threads', () => { service.customChannelTruncatedHandler = spy; const event = { type: 'channel.truncated', + cid: channel.cid, channel: { cid: channel.cid, name: 'New name', @@ -467,7 +669,7 @@ describe('ChannelService - threads', () => { (channel as MockChannel).handleEvent('channel.truncated', event); expect(spy).toHaveBeenCalledWith( - event, + jasmine.objectContaining({ type: 'channel.truncated', cid: channel.cid }), channel, jasmine.any(Function), jasmine.any(Function), @@ -479,62 +681,59 @@ describe('ChannelService - threads', () => { expect(parentMessageSpy).toHaveBeenCalledWith(undefined); }); - it('should watch for reaction events', async () => { + it('should emit thread messages if reactions change', async () => { await init(); - const spy = jasmine.createSpy(); - service.activeThreadMessages$.subscribe(spy); - spy.calls.reset(); - let activeChannel!: Channel; - service.activeChannel$.subscribe((c) => (activeChannel = c!)); const parentMessage = mockMessage(); - const replies = [mockMessage(), mockMessage()]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - spyOn(activeChannel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); - const message = replies[1]; - (activeChannel as MockChannel).handleEvent('reaction.new', { message }); - - expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); - - spy.calls.reset(); - (activeChannel as MockChannel).handleEvent('reaction.updated', { message }); - - expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); - + const spy = jasmine.createSpy(); + service.activeThreadMessages$.subscribe(spy); spy.calls.reset(); - (activeChannel as MockChannel).handleEvent('reaction.deleted', { message }); + const paginator = getThread(parentMessage.id).messagePaginator; + const message = paginator.items![1]; + // stream-chat updates the paginator on reaction.new/updated/deleted events + paginator.ingestItem({ + ...message, + reaction_groups: { + like: { + count: 1, + sum_scores: 1, + }, + }, + } as unknown as LocalMessage); - expect(spy).toHaveBeenCalledWith(jasmine.any(Object)); + expect(spy).toHaveBeenCalledWith( + jasmine.arrayContaining([ + jasmine.objectContaining({ + id: message.id, + reaction_groups: jasmine.objectContaining({ + like: jasmine.any(Object), + }), + }), + ]) + ); }); it('should send message', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parentId'; - const replies = [mockMessage(), mockMessage()]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - channel.state.threads[parentMessage.id] = replies; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); + const paginator = getThread(parentMessage.id).messagePaginator; spyOn(channel, 'sendMessage').and.callThrough(); - spyOn(channel.state, 'addMessageSorted').and.callThrough(); + spyOn(paginator, 'ingestItem').and.callThrough(); const text = 'Hi'; - const attachments = [{ fallback: 'image.png', url: 'http://url/to/image' }]; - const mentionedUsers = [{ id: 'sara', name: 'Sara' }]; + const attachments = [ + { fallback: 'image.png', image_url: 'http://url/to/image', custom: {} }, + ] as Attachment[]; + const mentionedUsers = [{ id: 'sara', name: 'Sara' }] as UserResponse[]; const customData = { isVote: true, options: ['A', 'B', 'C'], }; - let prevMessageCount!: number; - service.activeThreadMessages$ - .pipe(first()) - .subscribe((m) => (prevMessageCount = m.length)); + const prevMessageCount = getLatestValue( + service.activeThreadMessages$ + ).length; await service.sendMessage( text, attachments, @@ -543,189 +742,216 @@ describe('ChannelService - threads', () => { undefined, customData ); - let latestMessage!: StreamMessage; - let messageCount!: number; - service.activeThreadMessages$.subscribe((m) => { - latestMessage = m[m.length - 1]; - messageCount = m.length; - }); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); + const latestMessage = threadMessages[threadMessages.length - 1]; expect(channel.sendMessage).toHaveBeenCalledWith({ - text, - attachments, - mentioned_users: ['sara'], - id: jasmine.any(String), - parent_id: 'parentId', - quoted_message_id: undefined, - ...customData, - poll_id: undefined, + message: { + text, + attachments, + mentioned_users: ['sara'], + id: jasmine.any(String), + parent_id: 'parentId', + quoted_message_id: undefined, + poll_id: undefined, + custom: customData, + }, }); - expect(channel.state.addMessageSorted).toHaveBeenCalledWith( - jasmine.objectContaining({ text, user, attachments }), - true + // Optimistic update + expect(paginator.ingestItem).toHaveBeenCalledWith( + jasmine.objectContaining({ + text, + user: client.user, + attachments, + parent_id: 'parentId', + status: 'sending', + }) ); expect(latestMessage.text).toBe(text); - expect(messageCount).toEqual(prevMessageCount + 1); + expect(latestMessage.parent_id).toBe('parentId'); + expect(threadMessages.length).toEqual(prevMessageCount + 1); + // Channel message list isn't affected + expect( + getChannelMessages().find((m) => m.id === latestMessage.id) + ).toBeUndefined(); }); it('should send action', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parentId'; - const replies = [mockMessage(1), mockMessage(2)]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - channel.state.threads[parentMessage.id] = replies; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); + const replies = getThread(parentMessage.id).messagePaginator.items!; const giphy = { thumb_url: 'https://media4.giphy.com/media/Q9GYuPJTT8RomJTRot/giphy.gif?cid=c4b036752at3vu1m2vwt7nvnfumyer5620wbdhosrpmds52e&rid=giphy.gif&ct=g', title: 'dogs', title_link: 'https://giphy.com/gifs/Q9GYuPJTT8RomJTRot', type: 'giphy', + custom: {}, }; + const replyId = replies[replies.length - 1].id; spyOn(channel, 'sendAction').and.resolveTo({ message: { - id: replies[replies.length - 1].id, + ...replies[replies.length - 1], + created_at: nowNs(), + updated_at: nowNs(), + id: replyId, attachments: [giphy], parent_id: parentMessage.id, }, - } as any as SendMessageAPIResponse); - let message!: StreamMessage; - service.activeThreadMessages$.subscribe((m) => (message = m[m.length - 1])); - await service.sendAction(replies[replies.length - 1].id, { + } as never); + await service.sendAction( + replyId, + { + image_action: 'send', + }, + parentMessage.id + ); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); + const message = threadMessages.find((m) => m.id === replyId)!; + + expect(channel.sendAction).toHaveBeenCalledWith(replyId, { image_action: 'send', }); - expect(message.attachments![0]).toBe(giphy); + expect(message.attachments[0]).toEqual(jasmine.objectContaining(giphy)); }); it('should remove message after action, if no message is returned', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parentId'; - const replies = [mockMessage(), mockMessage()]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - channel.state.threads[parentMessage.id] = replies; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); - spyOn(channel, 'sendAction').and.resolveTo( - {} as any as SendMessageAPIResponse - ); - spyOn(channel.state, 'removeMessage'); + const paginator = getThread(parentMessage.id).messagePaginator; + const replies = paginator.items!; + const replyId = replies[replies.length - 1].id; + spyOn(channel, 'sendAction').and.resolveTo({} as never); + spyOn(paginator, 'removeItem').and.callThrough(); await service.sendAction( - replies[replies.length - 1].id, + replyId, { image_action: 'send', }, parentMessage.id ); - expect(channel.state.removeMessage).toHaveBeenCalledWith({ - id: replies[replies.length - 1].id, - parent_id: parentMessage.id, + expect(paginator.removeItem).toHaveBeenCalledWith({ + id: replyId, }); + + expect( + getLatestValue(service.activeThreadMessages$).find( + (m) => m.id === replyId + ) + ).toBeUndefined(); }); it('should set message state after message is sent', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parentId'; - const replies = [mockMessage(), mockMessage()]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - channel.state.threads[parentMessage.id] = replies; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); const text = 'Hi'; - spyOn(channel, 'sendMessage').and.resolveTo({ - message: { - id: 'new message', - parent_id: parentMessage.id, - text, - }, - } as SendMessageAPIResponse); - let latestMessage!: StreamMessage; - service.activeThreadMessages$.subscribe( - (m) => (latestMessage = m[m.length - 1]) + spyOn(channel, 'sendMessage').and.callFake(((request: { + message: { id: string }; + }) => + Promise.resolve({ + message: { + ...mockMessage(), + created_at: nowNs(), + updated_at: nowNs(), + id: request.message.id, + parent_id: parentMessage.id, + text, + }, + })) as never); + const latestMessage = await service.sendMessage( + text, + [], + [], + parentMessage.id + ); + const threadMessages = getLatestValue( + service.activeThreadMessages$ ); - await service.sendMessage(text, [], [], parentMessage.id); expect(latestMessage.status).toBe('received'); + expect(threadMessages[threadMessages.length - 1].status).toBe('received'); }); it('should set message state, if an error occured', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parentId'; - const replies = [mockMessage(), mockMessage()]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - channel.state.threads[parentMessage.id] = replies; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); const text = 'Hi'; spyOn(channel, 'sendMessage').and.rejectWith({ status: 500 }); - let latestMessage!: StreamMessage; - service.activeThreadMessages$.subscribe( - (m) => (latestMessage = m[m.length - 1]) - ); await service.sendMessage(text, [], [], parentMessage.id); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); + const latestMessage = threadMessages[threadMessages.length - 1]; + expect(latestMessage.text).toBe(text); expect(latestMessage.status).toBe('failed'); expect(latestMessage.errorStatusCode).toBe(500); }); it('should add sent message to message list', async () => { await init(); - let channel!: Channel; - service.activeChannel$.pipe(first()).subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parentId'; - const replies = [mockMessage(), mockMessage()]; - replies.forEach((r) => (r.parent_id = parentMessage.id)); - channel.state.threads[parentMessage.id] = replies; - spyOn(channel, 'getReplies').and.resolveTo({ - messages: replies, - } as any as GetRepliesAPIResponse); await service.setAsActiveParentMessage(parentMessage); const text = 'Hi'; - spyOn(channel, 'sendMessage').and.resolveTo({ - message: { - id: 'new message', - parent_id: parentMessage.id, - text, - }, - } as SendMessageAPIResponse); - let latestMessage!: StreamMessage; - service.activeThreadMessages$.subscribe( - (m) => (latestMessage = m[m.length - 1]) + spyOn(channel, 'sendMessage').and.callFake(((request: { + message: { id: string }; + }) => + Promise.resolve({ + message: { + ...mockMessage(), + created_at: nowNs(), + updated_at: nowNs(), + id: request.message.id, + parent_id: parentMessage.id, + text, + }, + })) as never); + const prevCount = getLatestValue( + service.activeThreadMessages$ + ).length; + const sentMessage = await service.sendMessage( + text, + [], + [], + parentMessage.id ); - await service.sendMessage(text, [], [], parentMessage.id); + const threadMessages = getLatestValue( + service.activeThreadMessages$ + ); + const latestMessage = threadMessages[threadMessages.length - 1]; - expect(latestMessage.id).toBe('new message'); + // preview is replaced, not duplicated + expect(threadMessages.length).toBe(prevCount + 1); + expect(latestMessage.id).toBe(sentMessage.id); + expect(latestMessage.text).toBe(text); + expect(latestMessage.created_at).toEqual(jasmine.any(Number)); }); it('should notify channel if typing started', async () => { await init(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'keystroke'); await service.typingStarted('parentId'); @@ -734,8 +960,7 @@ describe('ChannelService - threads', () => { it('should notify channel if typing stopped', async () => { await init(); - let channel!: Channel; - service.activeChannel$.subscribe((c) => (channel = c!)); + const channel = getActiveChannel(); spyOn(channel, 'stopTyping'); await service.typingStopped('parentId'); @@ -750,8 +975,7 @@ describe('ChannelService - threads', () => { service.usersTypingInThread$.subscribe((e) => { usersTypingInThreadSpy(e); }); - let channel!: MockChannel; - service.activeChannel$.subscribe((c) => (channel = c as MockChannel)); + const channel = getActiveChannel(); const parentMessage = mockMessage(); parentMessage.id = 'parent_id'; await service.setAsActiveParentMessage(parentMessage); @@ -803,54 +1027,164 @@ describe('ChannelService - threads', () => { expect(usersTypingInChannelSpy).not.toHaveBeenCalled(); }); - it('should load thread message into state', async () => { - await init(); - const jumpToMessageIdSpy = jasmine.createSpy(); - service.jumpToMessage$.subscribe(jumpToMessageIdSpy); - jumpToMessageIdSpy.calls.reset(); - const messagesSpy = jasmine.createSpy(); - service.activeChannelMessages$.subscribe(messagesSpy); - messagesSpy.calls.reset(); - const activeParentMessageSpy = jasmine.createSpy(); - service.activeParentMessageId$.subscribe(activeParentMessageSpy); - activeParentMessageSpy.calls.reset(); - const threadMessagesSpy = jasmine.createSpy(); - service.activeThreadMessages$.subscribe(threadMessagesSpy); - threadMessagesSpy.calls.reset(); + describe('jump to thread message', () => { const messageId = '1232121123'; const parentMessageId = '2222'; - await service.jumpToMessage(messageId, parentMessageId); + let thread: MockThread; + let channelPaginator: MockMessagePaginator; + + beforeEach(async () => { + await init(); + channelPaginator = getPaginator(getActiveChannel()); + // Parent message isn't part of the loaded message list + thread = createThread(parentMessageId); + }); + + it('should jump to parent message in channel first, then to the thread reply', async () => { + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + const messagesSpy = jasmine.createSpy(); + service.activeChannelMessages$.subscribe(messagesSpy); + messagesSpy.calls.reset(); + const activeParentMessageSpy = jasmine.createSpy(); + service.activeParentMessageId$.subscribe(activeParentMessageSpy); + activeParentMessageSpy.calls.reset(); + const threadMessagesSpy = jasmine.createSpy(); + service.activeThreadMessages$.subscribe(threadMessagesSpy); + threadMessagesSpy.calls.reset(); + spyOn(channelPaginator, 'jumpToMessage').and.callThrough(); + spyOn(thread.messagePaginator, 'jumpToMessage').and.callThrough(); + spyOn(thread, 'activate').and.callThrough(); + await service.jumpToMessage(messageId, parentMessageId); + + expect(channelPaginator.jumpToMessage).toHaveBeenCalledWith( + parentMessageId, + jasmine.objectContaining({ suppressFocusSignal: true }) + ); + + expect(thread.messagePaginator.jumpToMessage).toHaveBeenCalledWith( + messageId, + jasmine.objectContaining({ suppressFocusSignal: true }) + ); + + expect(channelPaginator.jumpToMessage).toHaveBeenCalledBefore( + thread.activate + ); + + expect(thread.activate).toHaveBeenCalledBefore( + thread.messagePaginator.jumpToMessage + ); - expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ - id: messageId, - parentId: parentMessageId, + expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ + id: messageId, + parentId: parentMessageId, + }); + + expect(messagesSpy).toHaveBeenCalledWith( + jasmine.arrayContaining([ + jasmine.objectContaining({ id: parentMessageId }), + ]) + ); + + expect(activeParentMessageSpy).toHaveBeenCalledWith(parentMessageId); + + expect(threadMessagesSpy).toHaveBeenCalledWith( + jasmine.arrayContaining([jasmine.objectContaining({ id: messageId })]) + ); }); - expect(messagesSpy).toHaveBeenCalledWith( - jasmine.arrayContaining([ - jasmine.objectContaining({ id: parentMessageId }), - ]) - ); + it(`shouldn't jump to parent message in channel, if it's already loaded`, async () => { + const parentMessage = channelPaginator.items![3]; + const replyThread = createThread(parentMessage.id); + spyOn(channelPaginator, 'jumpToMessage').and.callThrough(); + spyOn(replyThread.messagePaginator, 'jumpToMessage').and.callThrough(); + await service.jumpToMessage(messageId, parentMessage.id); + + expect(channelPaginator.jumpToMessage).not.toHaveBeenCalled(); + expect(replyThread.messagePaginator.jumpToMessage).toHaveBeenCalledWith( + messageId, + jasmine.any(Object) + ); - expect(activeParentMessageSpy).toHaveBeenCalledWith(parentMessageId); + expect( + getLatestValue(service.activeParentMessageId$) + ).toBe(parentMessage.id); + }); - expect(threadMessagesSpy).toHaveBeenCalledWith( - jasmine.arrayContaining([jasmine.objectContaining({ id: messageId })]) - ); + it(`shouldn't reopen thread, if it's already active`, async () => { + await service.jumpToMessage(messageId, parentMessageId); + spyOn(service, 'setAsActiveParentMessage').and.callThrough(); + spyOn(thread.messagePaginator, 'jumpToMessage').and.callThrough(); + await service.jumpToMessage('other-reply', parentMessageId); + + expect(service.setAsActiveParentMessage).not.toHaveBeenCalled(); + expect(thread.messagePaginator.jumpToMessage).toHaveBeenCalledWith( + 'other-reply', + jasmine.any(Object) + ); + }); + + it('should display notification if thread reply is not found', async () => { + spyOn(notificationService, 'addTemporaryNotification'); + const jumpToMessageIdSpy = jasmine.createSpy(); + service.jumpToMessage$.subscribe(jumpToMessageIdSpy); + jumpToMessageIdSpy.calls.reset(); + thread.messagePaginator.loadAround = () => undefined; + + await expectAsync( + service.jumpToMessage(messageId, parentMessageId) + ).toBeRejected(); + + expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( + 'streamChat.Message not found' + ); + + expect(jumpToMessageIdSpy).not.toHaveBeenCalled(); + }); + + it('should jump to the latest thread reply', async () => { + spyOn( + thread.messagePaginator, + 'jumpToTheLatestMessage' + ).and.callThrough(); + await service.jumpToMessage('latest', parentMessageId); + + expect( + thread.messagePaginator.jumpToTheLatestMessage + ).toHaveBeenCalledWith( + jasmine.objectContaining({ suppressFocusSignal: true }) + ); + }); }); it('should delete message - local', async () => { await init(); - const message = mockMessage(); + const parentMessage = mockMessage(); + parentMessage.id = 'parent'; + await service.setAsActiveParentMessage(parentMessage); + const paginator = getThread('parent').messagePaginator; + const message = toLocalMessage(mockMessage()); + message.id = 'local-message'; message.parent_id = 'parent'; - const channel = service.activeChannel; - spyOn(channel!.state, 'removeMessage'); - void service.deleteMessage(message, true); + paginator.ingestItem(message); + spyOn(paginator, 'removeItem').and.callThrough(); + await service.deleteMessage( + getLatestValue(service.activeThreadMessages$).find( + (m) => m.id === message.id + )!, + true + ); - expect(mockChatClient.deleteMessage).not.toHaveBeenCalledWith(); - expect(channel!.state.removeMessage).toHaveBeenCalledWith({ + expect(client.deleteMessage).not.toHaveBeenCalled(); + expect(paginator.removeItem).toHaveBeenCalledWith({ id: message.id, - parent_id: message.parent_id, }); + + expect( + getLatestValue(service.activeThreadMessages$).find( + (m) => m.id === message.id + ) + ).toBeUndefined(); }); }); diff --git a/projects/stream-chat-angular/src/lib/channel.service.ts b/projects/stream-chat-angular/src/lib/channel.service.ts index 9279cd65..b1a597af 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -13,17 +13,21 @@ import { ChannelFilters, ChannelOptions, ChannelResponse, - ChannelSort, CustomMessageData, CustomReactionData, Event, - EventTypes, - FormatMessageResponse, + EventPayload, + EventType, + formatMessage, + nsToDate, LocalMessage, MemberFilters, - Message, + MessageIntervalPaginator, + MessageRequest, MessageResponse, ReactionResponse, + SortParamRequest, + Thread, UserResponse, } from 'stream-chat'; import { ChatClientService, ClientEvent } from './chat-client.service'; @@ -40,9 +44,11 @@ import { MessageInput, MessageReactionType, NextPageConfiguration, + MessageListPaginationState, StreamMessage, } from './types'; import { ChannelQuery } from './channel-query'; +import { toLocalMessage } from './local-message'; /** * The `ChannelService` provides data and interaction for the channel list and message list. @@ -56,7 +62,7 @@ export class ChannelService { */ hasMoreChannels$: Observable; /** - * Emits the currently loaded and [watched](/chat/docs/javascript/watch_channel/) channel list. + * Emits the currently loaded and [watched](/chat/docs/javascript/creating-channels/) channel list. * * :::important * If you want to subscribe to channel events, you need to manually reenter Angular's change detection zone, our [Change detection guide](/chat/docs/sdk/angular/concepts/change-detection/) explains this in detail. @@ -81,6 +87,14 @@ export class ChannelService { * Emits the list of currently loaded messages of the active channel. */ activeChannelMessages$: Observable; + /** + * Emits the pagination state (loading, has more older/newer messages, last error) of the active channel's message list. + */ + activeChannelMessagesPaginationState$: Observable; + /** + * Emits the pagination state (loading, has more older/newer replies, last error) of the active thread's message list. + */ + activeThreadMessagesPaginationState$: Observable; /** * Emits the list of pinned messages of the active channel. */ @@ -118,7 +132,7 @@ export class ChannelService { */ latestMessageDateByUserByChannels$: Observable<{ [key: string]: Date }>; /** - * If you're using [semantic filters for moderation](/moderation/docs/) you can set up rules for bouncing messages. + * If you're using [semantic filters for moderation](/moderation/docs/node/) you can set up rules for bouncing messages. * * If a message is bounced, it will be emitted via this `Observable`. The built-in [`MessageBouncePrompt` component](/chat/docs/sdk/angular/components/MessageBouncePromptComponent/) will display the bounce option to the user if a bounced message is clicked. */ @@ -136,9 +150,9 @@ export class ChannelService { */ activeChannelUnreadCount?: number; /** - * Custom event handler to call if a new message received from a channel that is not being watched, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/) + * Custom event handler to call if a new message received from a channel that is not being watched, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels) * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customNewMessageNotificationHandler?: ( clientEvent: ClientEvent, @@ -148,9 +162,9 @@ export class ChannelService { ) => void ) => void; /** - * Custom event handler to call when the user is added to a channel, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when the user is added to a channel, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customAddedToChannelNotificationHandler?: ( clientEvent: ClientEvent, @@ -160,9 +174,9 @@ export class ChannelService { ) => void ) => void; /** - * Custom event handler to call when the user is removed from a channel, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when the user is removed from a channel, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customRemovedFromChannelNotificationHandler?: ( clientEvent: ClientEvent, @@ -172,9 +186,9 @@ export class ChannelService { ) => void ) => void; /** - * Custom event handler to call when a channel is deleted, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when a channel is deleted, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customChannelDeletedHandler?: ( event: Event, @@ -188,9 +202,9 @@ export class ChannelService { parentMessageSetter: (message: StreamMessage | undefined) => void ) => void; /** - * Custom event handler to call when a channel is updated, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when a channel is updated, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customChannelUpdatedHandler?: ( event: Event, @@ -204,9 +218,9 @@ export class ChannelService { parentMessageSetter: (message: StreamMessage | undefined) => void ) => void; /** - * Custom event handler to call when a channel is truncated, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when a channel is truncated, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customChannelTruncatedHandler?: ( event: Event, @@ -220,9 +234,9 @@ export class ChannelService { parentMessageSetter: (message: StreamMessage | undefined) => void ) => void; /** - * Custom event handler to call when a channel becomes hidden, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when a channel becomes hidden, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customChannelHiddenHandler?: ( event: Event, @@ -236,9 +250,9 @@ export class ChannelService { parentMessageSetter: (message: StreamMessage | undefined) => void ) => void; /** - * Custom event handler to call when a channel becomes visible, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call when a channel becomes visible, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customChannelVisibleHandler?: ( event: Event, @@ -252,9 +266,9 @@ export class ChannelService { parentMessageSetter: (message: StreamMessage | undefined) => void ) => void; /** - * Custom event handler to call if a new message received from a channel that is being watched, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels/). + * Custom event handler to call if a new message received from a channel that is being watched, provide an event handler if you want to override the [default channel list ordering](/chat/docs/sdk/angular/services/ChannelService/#channels). * - * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/watch_channel/) channel. + * If you're adding a new channel, make sure that it's a [watched](/chat/docs/javascript/creating-channels/) channel. */ customNewMessageHandler?: ( event: Event, @@ -317,6 +331,12 @@ export class ChannelService { * @internal */ static readonly MAX_MESSAGE_REACTIONS_TO_FETCH = 1200; + private static readonly initialPaginationState: MessageListPaginationState = { + hasMoreOlder: true, + hasMoreNewer: false, + isLoading: false, + error: undefined, + }; /** * @internal */ @@ -329,8 +349,27 @@ export class ChannelService { undefined ); private activeChannelMessagesSubject = new BehaviorSubject< - (StreamMessage | MessageResponse | FormatMessageResponse)[] + (StreamMessage | MessageResponse | LocalMessage)[] >([]); + private activeChannelMessagesPaginationStateSubject = + new BehaviorSubject( + ChannelService.initialPaginationState + ); + private activeThreadMessagesPaginationStateSubject = + new BehaviorSubject( + ChannelService.initialPaginationState + ); + private activeChannelPaginatorSubscriptions: (() => void)[] = []; + private activeThreadPaginatorSubscriptions: (() => void)[] = []; + private releaseActiveChannel?: () => void; + private releaseActiveThread?: () => void; + private activeThread?: Thread; + private errorStatusCodes = new Map(); + private latestStreamMessageById = new Map(); + private streamMessageCache = new WeakMap< + object, + { errorStatusCode?: number; message: StreamMessage } + >(); private activeChannelPinnedMessagesSubject = new BehaviorSubject< StreamMessage[] >([]); @@ -341,7 +380,7 @@ export class ChannelService { string | undefined >(undefined); private activeThreadMessagesSubject = new BehaviorSubject< - (StreamMessage | MessageResponse | FormatMessageResponse)[] + (StreamMessage | MessageResponse | LocalMessage)[] >([]); private jumpToMessageSubject = new BehaviorSubject<{ id?: string; @@ -393,14 +432,13 @@ export class ChannelService { this.channelSubscriptions[channel.cid](); delete this.channelSubscriptions.cid; } - void this.chatClientService.chatClient.activeChannels[channel.cid] + void this.chatClientService.chatClient.channelManager + .get(channel.cid) ?.stopWatching() - .catch((err) => - this.chatClientService.chatClient.logger( - 'warn', - 'Failed to unwatch channel', - err as Record - ) + .catch((error) => + this.chatClientService.logger.warn('Failed to unwatch channel', { + error: error as Error, + }) ); } } @@ -486,12 +524,22 @@ export class ChannelService { return undefined; } else { const message = messages.find((m) => m.id === parentMessageId); - if (!message) { - void this.setAsActiveParentMessage(undefined); - return undefined; - } else { + if (message) { return message; } + // The parent message might not be in the currently loaded message list window (for example after a jump) + const parentMessageFromThread = + this.activeThread?.id === parentMessageId + ? this.activeThread.state.getLatestValue().parentMessage + : undefined; + if (parentMessageFromThread) { + return this.transformToStreamMessage( + parentMessageFromThread, + this.activeChannelSubject.getValue() + ); + } + void this.setAsActiveParentMessage(undefined); + return undefined; } } ), @@ -517,6 +565,14 @@ export class ChannelService { this.activeChannelPinnedMessages$ = this.activeChannelPinnedMessagesSubject .asObservable() .pipe(shareReplay(1)); + this.activeChannelMessagesPaginationState$ = + this.activeChannelMessagesPaginationStateSubject + .asObservable() + .pipe(shareReplay(1)); + this.activeThreadMessagesPaginationState$ = + this.activeThreadMessagesPaginationStateSubject + .asObservable() + .pipe(shareReplay(1)); this.channelQueryState$ = this.channelQueryStateSubject .asObservable() .pipe(shareReplay(1)); @@ -539,7 +595,7 @@ export class ChannelService { if (!this._shouldMarkActiveChannelAsRead && shouldMarkActiveChannelAsRead) { const activeChannel = this.activeChannelSubject.getValue(); if (activeChannel && this.canSendReadEvents) { - void activeChannel.markRead(); + void this.sendMarkReadRequest(activeChannel); } } this._shouldMarkActiveChannelAsRead = shouldMarkActiveChannelAsRead; @@ -550,7 +606,7 @@ export class ChannelService { * * The method will be called with the result of the latest channel query. * - * You can return either an offset, or a filter using the [`$lte`/`$gte` operator](/chat/docs/javascript/query_syntax_operators/). If you return a filter, it will be merged with the filter provided for the `init` method. + * You can return either an offset, or a filter using the [`$lte`/`$gte` operator](/docs/platform/query-syntax-operators/). If you return a filter, it will be merged with the filter provided for the `init` method. */ set customPaginator( paginator: @@ -575,6 +631,7 @@ export class ChannelService { return; } this.stopWatchForActiveChannelEvents(prevActiveChannel); + this.releaseThread(); this.flushMarkReadQueue(); this.areReadEventsPaused = false; this.isMessageLoadingInProgress = false; @@ -582,23 +639,22 @@ export class ChannelService { channel.state.read[this.chatClientService.chatClient.user?.id || '']; this.activeChannelLastReadMessageId = readState?.last_read_message_id; if ( - channel.state.latestMessages[channel.state.latestMessages.length - 1] - ?.id === this.activeChannelLastReadMessageId + channel.messagePaginator.anchoredHeadItem?.id === + this.activeChannelLastReadMessageId ) { this.activeChannelLastReadMessageId = undefined; } this.activeChannelUnreadCount = readState?.unread_messages || 0; - this.watchForActiveChannelEvents(channel); this.addChannel(channel); this.activeChannelSubject.next(channel); - const channelStateLength = channel.state.latestMessages.length; - if (channelStateLength > 2 * this.messagePageSize) { - channel.state.latestMessages = channel.state.latestMessages.slice( - channelStateLength - 2 * this.messagePageSize - ); - } + this.watchForActiveChannelEvents(channel); + // Activated channels are reloaded by stream-chat after a reconnect + this.releaseActiveChannel = channel.activate(); this.setChannelState(channel); this.channelSwitchStateSubject.next('end'); + if (!channel.initialized || channel.messagePaginator.items === undefined) { + void this.loadInitialMessages(channel); + } } /** @@ -611,8 +667,12 @@ export class ChannelService { return; } this.stopWatchForActiveChannelEvents(activeChannel); + this.releaseThread(); this.flushMarkReadQueue(); this.activeChannelMessagesSubject.next([]); + this.activeChannelMessagesPaginationStateSubject.next( + ChannelService.initialPaginationState + ); this.activeChannelSubject.next(undefined); this.activeParentMessageIdSubject.next(undefined); this.activeThreadMessagesSubject.next([]); @@ -632,17 +692,18 @@ export class ChannelService { /** * Sets the given `message` as an active parent message. If `undefined` is provided, it will deleselect the current parent message. * @param message - * @param loadMessagesForm + * @param _loadMessagesForm deprecated, thread replies are always loaded through the thread's message paginator */ async setAsActiveParentMessage( message: StreamMessage | undefined, - loadMessagesForm: 'request' | 'state' = 'request' + _loadMessagesForm: 'request' | 'state' = 'request' ) { const messageToQuote = this.messageToQuoteSubject.getValue(); if (messageToQuote && !!messageToQuote.parent_id) { this.messageToQuoteSubject.next(undefined); } if (!message) { + this.releaseThread(); this.activeParentMessageIdSubject.next(undefined); this.activeThreadMessagesSubject.next([]); const messageToJumpTo = this.jumpToMessageSubject.getValue(); @@ -650,100 +711,133 @@ export class ChannelService { this.jumpToMessageSubject.next({ id: undefined, parentId: undefined }); } } else { - this.activeParentMessageIdSubject.next(message.id); const activeChannel = this.activeChannelSubject.getValue(); - if (loadMessagesForm === 'request') { - const result = await activeChannel?.getReplies(message.id, { - limit: this.messagePageSize, + if (!activeChannel) { + return; + } + if (this.activeThread?.id === message.id) { + return; + } + this.releaseThread(); + const client = this.chatClientService.chatClient; + const parentMessage = + activeChannel.messagePaginator.getItem(message.id) ?? + toLocalMessage(message); + const thread = + client.threads.get(message.id) ?? + client.threads.ensure({ channel: activeChannel, parentMessage }); + this.activeThread = thread; + this.activeParentMessageIdSubject.next(message.id); + this.activeThreadPaginatorSubscriptions = this.bindMessagePaginator( + thread.messagePaginator, + this.activeThreadMessagesSubject, + this.activeThreadMessagesPaginationStateSubject, + () => this.activeThread === thread + ); + if (thread.hasStaleState) { + // Load before activation: activating a stale thread also triggers a reload in stream-chat, but without error handling (that reload is skipped while this one is in progress) + this.activeThreadMessagesPaginationStateSubject.next({ + ...this.activeThreadMessagesPaginationStateSubject.getValue(), + isLoading: true, }); - this.activeThreadMessagesSubject.next(result?.messages || []); - } else { - this.activeThreadMessagesSubject.next( - activeChannel?.state.threads[message.id] || [] - ); + try { + await thread.reload(); + } catch (error) { + if (this.activeThread === thread) { + this.activeThreadMessagesPaginationStateSubject.next({ + ...this.activeThreadMessagesPaginationStateSubject.getValue(), + isLoading: false, + error: error as Error, + }); + this.notificationService.addTemporaryNotification( + 'streamChat.Error loading messages' + ); + } + } + } + if ( + !thread.hasStaleState && + thread.messagePaginator.items === undefined && + thread.state.getLatestValue().replyCount === 0 + ) { + // stream-chat doesn't load threads without replies, so the reply list keeps the paginator defaults (has more older and newer replies). Mark it complete, otherwise the first reply makes the message list think it shows an older reply window and jump to the latest replies. + // `items` stays `undefined`: stream-chat only places the replies of a reload (for example after a reconnect) as a first page when nothing is loaded, an empty list would make it drop them. + thread.messagePaginator.state.partialNext({ + hasMoreHead: false, + hasMoreTail: false, + }); + } + if (this.activeThread === thread && !this.releaseActiveThread) { + this.releaseActiveThread = thread.activate(); } } } /** - * Loads the next page of messages of the active channel. The page size can be set in the [query option](/chat/docs/javascript/query_channels/#query-options) object. + * Loads the next page of messages of the active channel. * @param direction + * @returns `false` if there are no more messages to load in the given direction, otherwise a `Promise` that resolves when the page is loaded */ loadMoreMessages(direction: 'older' | 'newer' = 'older') { - const activeChnannel = this.activeChannelSubject.getValue(); - const messages = this.activeChannelMessagesSubject.getValue(); - const lastMessageId = - messages[direction === 'older' ? 0 : messages.length - 1]?.id; - if ( - direction === 'newer' && - activeChnannel?.state?.latestMessages === activeChnannel?.state?.messages - ) { - // If we are on latest message set, activeChannelMessages$ will be refreshed by WS events, no need for a request + const activeChannel = this.activeChannelSubject.getValue(); + if (!activeChannel) { return false; } - return activeChnannel - ?.query({ - messages: { - limit: this.messagePageSize, - [direction === 'older' ? 'id_lt' : 'id_gt']: lastMessageId, - }, - members: { limit: 0 }, - watchers: { limit: 0 }, - }) - .then((res) => { - if ( - activeChnannel?.data?.id === - this.activeChannelSubject.getValue()?.data?.id - ) { - this.activeChannelMessagesSubject.next([ - ...activeChnannel.state.messages, - ]); - } - - return res; - }); + return this.loadMore(activeChannel.messagePaginator, direction); } /** - * Loads the next page of messages of the active thread. The page size can be set in the [query option](/chat/docs/javascript/query_channels/#query-options) object. + * Loads the next page of messages of the active thread. * @param direction + * @returns `false` if there are no more replies to load in the given direction, otherwise a `Promise` that resolves when the page is loaded */ loadMoreThreadReplies(direction: 'older' | 'newer' = 'older') { - if (direction === 'newer') { - // Thread replies aren't broke into different message sets, activeThreadMessages$ will be refreshed by WS events, no need for a request + if (!this.activeThread) { + return false; + } + return this.loadMore(this.activeThread.messagePaginator, direction); + } + + private loadMore( + paginator: MessageIntervalPaginator, + direction: 'older' | 'newer' + ) { + const hasMore = + direction === 'older' ? paginator.hasMoreTail : paginator.hasMoreHead; + if (!hasMore || paginator.isLoading) { return false; } - const activeChnannel = this.activeChannelSubject.getValue(); - const parentMessageId = this.activeParentMessageIdSubject.getValue(); - if (!parentMessageId || !activeChnannel) { + // There is no loaded message to paginate from (for example the channel/thread is empty), no need for a request + if (!paginator.items?.length) { return false; } - const threadMessages = this.activeThreadMessagesSubject.getValue(); - const lastMessageId = - threadMessages[direction === 'older' ? 0 : threadMessages.length - 1]?.id; - return activeChnannel - .getReplies(parentMessageId, { - limit: this.messagePageSize, - [direction === 'older' ? 'id_lt' : 'id_gt']: lastMessageId, - }) + return (direction === 'older' ? paginator.toTail() : paginator.toHead()) .then(() => { - this.activeThreadMessagesSubject.next( - activeChnannel?.state.threads[parentMessageId] || [] + const error = paginator.lastQueryError; + if (error) { + throw error; + } + }) + .catch((error: Error) => { + this.chatClientService.logger.warn( + `Failed to load ${direction} messages`, + { error } ); + throw error; }); } /** - * Queries the channels with the given filters, sorts and options. More info about [channel querying](/chat/docs/javascript/query_channels/) can be found in the platform documentation. By default the first channel in the list will be set as active channel and will be marked as read. + * Queries the channels with the given filters, sorts and options. More info about [channel querying](/chat/docs/javascript/query-channels/) can be found in the platform documentation. By default the first channel in the list will be set as active channel and will be marked as read. * @param filters - * @param sort + * @param sort The sort in the format expected by stream-chat, for example `[{ field: 'last_message_at', direction: -1 }]` * @param options * @param shouldSetActiveChannel Decides if the first channel in the result should be made as an active channel or not. * @returns the list of channels found by the query */ init( filters: ChannelFilters, - sort?: ChannelSort, + sort?: SortParamRequest[], options?: ChannelOptions, shouldSetActiveChannel: boolean = true ) { @@ -751,7 +845,7 @@ export class ChannelService { this.chatClientService, this, filters, - sort || { last_message_at: -1 }, + sort || [{ field: 'last_message_at', direction: -1 }], { limit: 25, state: true, @@ -770,7 +864,7 @@ export class ChannelService { } /** - * Queries the channels with the given query function. More info about [channel querying](/chat/docs/javascript/query_channels/) can be found in the platform documentation. + * Queries the channels with the given query function. More info about [channel querying](/chat/docs/javascript/query-channels/) can be found in the platform documentation. * @param query * @param options * @param options.shouldSetActiveChannel The `shouldSetActiveChannel` specifies if the first channel in the result should be selected as the active channel or not. Default is `true`. @@ -789,7 +883,7 @@ export class ChannelService { } /** - * Resets the `activeChannel$`, `channels$` and `activeChannelMessages$` Observables. Useful when disconnecting a chat user, use in combination with [`disconnectUser`](/chat/docs/sdk/angular/services/ChatClientService/#disconnectuser/). + * Resets the `activeChannel$`, `channels$` and `activeChannelMessages$` Observables. Useful when disconnecting a chat user, use in combination with [`disconnectUser`](/chat/docs/sdk/angular/services/ChatClientService/#disconnectuser). */ reset() { this.deselectActiveChannel(); @@ -805,7 +899,7 @@ export class ChannelService { } /** - * Loads the next page of channels. The page size can be set in the [query option](/chat/docs/javascript/query_channels/#query-options) object. + * Loads the next page of channels. The page size can be set in the [query option](/chat/docs/javascript/query-channels/#query-options) object. */ async loadMoreChannels() { await this.queryChannels(false, 'next-page'); @@ -822,10 +916,15 @@ export class ChannelService { reactionType: MessageReactionType, customData?: CustomReactionData ) { - await this.activeChannelSubject.getValue()?.sendReaction(messageId, { - type: reactionType, - ...customData, - }); + await this.activeChannelSubject.getValue()?.sendReaction( + { id: messageId }, + { + reaction: { + type: reactionType, + custom: customData, + }, + } + ); } /** @@ -836,7 +935,7 @@ export class ChannelService { async removeReaction(messageId: string, reactionType: MessageReactionType) { await this.activeChannelSubject .getValue() - ?.deleteReaction(messageId, reactionType); + ?.deleteReaction({ id: messageId, type: reactionType }); } /** @@ -871,7 +970,7 @@ export class ChannelService { input = await this.beforeSendMessage(input); } const preview = createMessagePreview( - this.chatClientService.chatClient.user!, + this.chatClientService.chatClient.user as UserResponse, input.text, input.attachments, input.mentionedUsers, @@ -880,8 +979,9 @@ export class ChannelService { input.customData, input.pollId ); - const channel = this.activeChannelSubject.getValue()!; - channel.state.addMessageSorted(preview, true); + // stream-chat's message paginator only accepts messages of its own channel (filtered by cid) + preview.cid = this.activeChannelSubject.getValue()!.cid; + this.getMessagePaginator(preview.parent_id)?.ingestItem(preview); const response = await this.sendMessageRequest(preview, input.customData); return response; } @@ -891,17 +991,14 @@ export class ChannelService { * @param message The message to resend */ async resendMessage(message: StreamMessage) { - const channel = this.activeChannelSubject.getValue()!; - channel.state.addMessageSorted( - { - ...(message as unknown as MessageResponse), - // @ts-expect-error stream-chat doesn't know about this property - errorStatusCode: undefined, - status: 'sending', - }, - true - ); - return this.sendMessageRequest(message, undefined, true); + this.errorStatusCodes.delete(message.id); + // Use the given message, not the stored one: editing a bounced message resends it with the new content + const localMessage = toLocalMessage(message); + this.getMessagePaginator(message.parent_id)?.ingestItem({ + ...localMessage, + status: 'sending', + }); + return this.sendMessageRequest(localMessage, undefined, true); } /** @@ -912,6 +1009,7 @@ export class ChannelService { let messageToUpdate: StreamMessage = { ...message, }; + const originalMessage = message; if (messageToUpdate.quoted_message) { messageToUpdate.quoted_message = { ...messageToUpdate.quoted_message, @@ -931,21 +1029,22 @@ export class ChannelService { if (messageToUpdate.quoted_message?.translation) { delete messageToUpdate.quoted_message.translation; } - if (message.moderation_details) { - return this.resendMessage(message); + if ( + originalMessage.type === 'error' && + originalMessage.moderation?.action === 'bounce' + ) { + return this.resendMessage(messageToUpdate); } const response = await this.chatClientService.chatClient.updateMessage( - messageToUpdate as unknown as LocalMessage + { id: messageToUpdate.id }, + { message: this.toMessageRequest(messageToUpdate) } ); const channel = this.channelsSubject .getValue() ?.find((c) => c.cid === message.cid); - if ( - response.message.type === 'error' && - response.message.moderation_details - ) { + if (response.message.type === 'error' && response.message.moderation) { this.notificationService.addTemporaryNotification( 'streamChat.This message did not meet our content guidelines' ); @@ -962,33 +1061,26 @@ export class ChannelService { */ async deleteMessage(message: StreamMessage, isLocalDelete = false) { if (isLocalDelete && this.activeChannel) { - const result = this.activeChannel.state.removeMessage({ + this.getMessagePaginator(message.parent_id)?.removeItem({ id: message.id, - parent_id: message.parent_id, }); - if (result) { - message.parent_id - ? this.activeThreadMessagesSubject.next( - this.activeChannel.state.threads[message.parent_id] - ) - : this.activeChannelMessagesSubject.next( - this.activeChannel.state.messages - ); - } + this.errorStatusCodes.delete(message.id); return; } if (this.messageDeleteConfirmationHandler) { const result = await this.messageDeleteConfirmationHandler(message); if (result) { - await this.chatClientService.chatClient.deleteMessage(message.id); + await this.chatClientService.chatClient.deleteMessage({ + id: message.id, + }); } } else { - await this.chatClientService.chatClient.deleteMessage(message.id); + await this.chatClientService.chatClient.deleteMessage({ id: message.id }); } } /** - * Uploads files to the channel. If you want to know more about [file uploads](/chat/docs/javascript/file_uploads/) check out the platform documentation. + * Uploads files to the channel. If you want to know more about [file uploads](/chat/docs/javascript/file-uploads/) check out the platform documentation. * @param uploads the attachments to upload (output of the [`AttachmentService`](/chat/docs/sdk/angular/services/AttachmentService/)) * @returns the result of file upload requests */ @@ -1002,10 +1094,10 @@ export class ChannelService { upload.type === 'image' ? this.customImageUploadRequest ? this.customImageUploadRequest(upload.file, channel) - : channel.sendImage(upload.file, upload.file.name, upload.file.type) + : channel.uploadImage({ file: upload.file }) : this.customFileUploadRequest ? this.customFileUploadRequest(upload.file, channel) - : channel.sendFile(upload.file, upload.file.name, upload.file.type) + : channel.uploadFile({ file: upload.file }) ) ); uploadResults.forEach((uploadResult, i) => { @@ -1016,7 +1108,7 @@ export class ChannelService { file, type, state: 'success', - url: uploadResult.value.file, + url: uploadResult.value.file!, /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ thumb_url: (uploadResult.value as any).thumb_url, }); @@ -1069,7 +1161,7 @@ export class ChannelService { } /** - * Deletes an uploaded file by URL. If you want to know more about [file uploads](/chat/docs/javascript/file_uploads/) check out the platform documentation + * Deletes an uploaded file by URL. If you want to know more about [file uploads](/chat/docs/javascript/file-uploads/) check out the platform documentation * @param attachmentUpload Attachment to be deleted (output of the [`AttachmentService`](/chat/docs/sdk/angular/services/AttachmentService/)) */ async deleteAttachment(attachmentUpload: AttachmentUpload) { @@ -1077,14 +1169,14 @@ export class ChannelService { await (attachmentUpload.type === 'image' ? this.customImageDeleteRequest ? this.customImageDeleteRequest(attachmentUpload.url!, channel) - : channel.deleteImage(attachmentUpload.url!) + : channel.deleteImage({ url: attachmentUpload.url! }) : this.customFileDeleteRequest ? this.customFileDeleteRequest(attachmentUpload.url!, channel) - : channel.deleteFile(attachmentUpload.url!)); + : channel.deleteFile({ url: attachmentUpload.url! })); } /** - * Returns the autocomplete options for current channel members. If member_count is equal to the number of members in the channel, it returns the channel members, otherwise sends a [search request](/chat/docs/javascript/query_members/#pagination-and-ordering) with the given search term. + * Returns the autocomplete options for current channel members. If member_count is equal to the number of members in the channel, it returns the channel members, otherwise sends a [search request](/chat/docs/javascript/query-members/#paginating-channel-members) with the given search term. * @param searchTerm Text to search for in the names of members * @returns The list of members matching the search filter */ @@ -1105,8 +1197,12 @@ export class ChannelService { return []; } const result = await activeChannel.queryMembers({ - name: { $autocomplete: searchTerm }, - } as MemberFilters); // TODO: find out why we need typecast here + payload: { + filter_conditions: { + name: { $autocomplete: searchTerm }, + } as MemberFilters, + }, + }); return result.members.filter( (m) => m.user_id !== this.chatClientService.chatClient?.user?.id @@ -1115,7 +1211,7 @@ export class ChannelService { } /** - * [Runs a message action](https://getstream.io/chat/docs/rest/#messages-runmessageaction) in the current channel. Updates the message list based on the action result (if no message is returned, the message will be removed from the message list). + * [Runs a message action](https://getstream.github.io/protocol/?urls.primaryName=Chat#/product%3Achat/RunMessageAction) in the current channel. Updates the message list based on the action result (if no message is returned, the message will be removed from the message list). * @param messageId * @param formData * @param parentMessageId @@ -1127,29 +1223,16 @@ export class ChannelService { ) { const channel = this.activeChannelSubject.getValue()!; const response = await channel.sendAction(messageId, formData); + const paginator = this.getMessagePaginator(parentMessageId); if (response?.message) { - channel.state.addMessageSorted({ - ...response.message, - status: 'received', - }); - const isThreadReply = !!response.message.parent_id; - isThreadReply - ? this.activeThreadMessagesSubject.next([ - ...channel.state.threads[response.message.parent_id!], - ]) - : this.activeChannelMessagesSubject.next([...channel.state.messages]); + paginator?.ingestItem( + formatMessage({ + ...response.message, + status: 'received', + } as LocalMessage) + ); } else { - channel.state.removeMessage({ - id: messageId, - parent_id: parentMessageId, - }); - if (parentMessageId) { - this.activeThreadMessagesSubject.next( - channel.state.threads[this.activeParentMessageIdSubject.getValue()!] - ); - } else { - this.activeChannelMessagesSubject.next([...channel.state.messages]); - } + paginator?.removeItem({ id: messageId }); } } @@ -1157,11 +1240,14 @@ export class ChannelService { * Selects or deselects the current message to quote reply to * @param message The message to select, if called with `undefined`, it deselects the message */ - selectMessageToQuote(message: StreamMessage | undefined | MessageResponse) { - if (message && !this.isStreamMessage(message)) { - message = this.transformToStreamMessage(message); - } - this.messageToQuoteSubject.next(message); + selectMessageToQuote( + message: StreamMessage | undefined | MessageResponse | LocalMessage + ) { + this.messageToQuoteSubject.next( + message + ? this.transformToStreamMessage(message, this.activeChannel) + : undefined + ); } /** @@ -1189,14 +1275,13 @@ export class ChannelService { this.channelSubscriptions[cid](); delete this.channelSubscriptions.cid; } - void this.chatClientService.chatClient.activeChannels[cid] + void this.chatClientService.chatClient.channelManager + .get(cid) ?.stopWatching() - .catch((err) => - this.chatClientService.chatClient.logger( - 'warn', - 'Failed to unwatch channel', - err as Record - ) + .catch((error) => + this.chatClientService.logger.warn('Failed to unwatch channel', { + error: error as Error, + }) ); } @@ -1213,46 +1298,32 @@ export class ChannelService { } private async sendMessageRequest( - preview: MessageResponse | StreamMessage, + preview: LocalMessage, customData?: CustomMessageData, isResend = false ) { const channel = this.activeChannelSubject.getValue()!; - const isThreadReply = !!preview.parent_id; - isThreadReply - ? this.activeThreadMessagesSubject.next([ - ...channel.state.threads[preview.parent_id!], - ]) - : this.activeChannelMessagesSubject.next([...channel.state.messages]); + const paginator = this.getMessagePaginator(preview.parent_id); try { const response = await channel.sendMessage({ - id: preview.id, - text: preview.text, - attachments: preview.attachments, - mentioned_users: preview.mentioned_users?.map((u) => u.id), - parent_id: preview.parent_id, - quoted_message_id: preview.quoted_message_id, - poll_id: preview.poll_id, - ...customData, - } as Message); // TODO: find out why we need typecast here - channel.state.addMessageSorted( - { + message: { + id: preview.id, + text: preview.text, + attachments: preview.attachments, + mentioned_users: preview.mentioned_users?.map((u) => u.id), + parent_id: preview.parent_id, + quoted_message_id: preview.quoted_message_id, + poll_id: preview.poll_id, + custom: customData ?? preview.custom, + }, + }); + paginator?.ingestItem( + formatMessage({ ...response.message, status: 'received', - }, - true + } as LocalMessage) ); - isThreadReply - ? this.activeThreadMessagesSubject.next([ - ...channel.state.threads[preview.parent_id!], - ]) - : this.activeChannelMessagesSubject.next([...channel.state.messages]); - let messages!: StreamMessage[]; - (isThreadReply ? this.activeThreadMessages$ : this.activeChannelMessages$) - .pipe(take(1)) - .subscribe((m) => (messages = m)); - const newMessage = messages[messages.length - 1]; - return newMessage; + return this.getSentMessage(preview); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { const stringError = JSON.stringify(error); @@ -1273,31 +1344,27 @@ export class ChannelService { } } - channel.state.addMessageSorted( - { - ...(preview as MessageResponse), - // @ts-expect-error stream-chat doesn't know about this property - errorStatusCode: isAlreadyExists - ? undefined - : parsedError.status || undefined, - status: isAlreadyExists ? 'received' : 'failed', - }, - true + this.errorStatusCodes.set( + preview.id, + isAlreadyExists ? undefined : parsedError.status || undefined ); - isThreadReply - ? this.activeThreadMessagesSubject.next([ - ...channel.state.threads[preview.parent_id!], - ]) - : this.activeChannelMessagesSubject.next([...channel.state.messages]); - let messages!: StreamMessage[]; - (isThreadReply ? this.activeThreadMessages$ : this.activeChannelMessages$) - .pipe(take(1)) - .subscribe((m) => (messages = m)); - const newMessage = messages[messages.length - 1]; - return newMessage; + paginator?.ingestItem({ + ...(paginator.getItem(preview.id) ?? preview), + status: isAlreadyExists ? 'received' : 'failed', + }); + return this.getSentMessage(preview); } } + private getSentMessage(preview: LocalMessage) { + const paginator = this.getMessagePaginator(preview.parent_id); + const message = paginator?.getItem(preview.id) ?? preview; + return this.transformToStreamMessage( + message, + this.activeChannelSubject.getValue() + ); + } + /** * Jumps to the selected message inside the message list, if the message is not yet loaded, it'll load the message (and it's surroundings) from the API. * @param messageId The ID of the message to be loaded, 'latest' means jump to the latest messages @@ -1307,19 +1374,51 @@ export class ChannelService { this.isMessageLoadingInProgress = true; const activeChannel = this.activeChannelSubject.getValue(); try { - await activeChannel?.state.loadMessageIntoState( - messageId, - parentMessageId - ); - const messages = activeChannel?.state.messages || []; - this.activeChannelMessagesSubject.next([...messages]); + if (!activeChannel) { + throw new Error('No active channel'); + } if (parentMessageId) { - const parentMessage = messages.find((m) => m.id === parentMessageId); - void this.setAsActiveParentMessage( - parentMessage as StreamMessage, - 'state' + // The parent message is displayed in the main message list as well (and activeParentMessage$ reads it from there) + const isParentLoaded = !!activeChannel.messagePaginator.items?.find( + (m) => m.id === parentMessageId ); + if (!isParentLoaded) { + await activeChannel.messagePaginator.jumpToMessage(parentMessageId, { + pageSize: this.messagePageSize, + suppressFocusSignal: true, + }); + activeChannel.messagePaginator.flushState(); + } + if (this.activeThread?.id !== parentMessageId) { + const parentMessage = await this.getMessage( + activeChannel, + parentMessageId + ); + await this.setAsActiveParentMessage( + this.transformToStreamMessage(parentMessage, activeChannel) + ); + } } + const paginator = parentMessageId + ? this.activeThread?.messagePaginator + : activeChannel.messagePaginator; + if (!paginator) { + throw new Error('Message paginator not found'); + } + const isSuccess = + messageId === 'latest' + ? await paginator.jumpToTheLatestMessage({ + suppressFocusSignal: true, + }) + : await paginator.jumpToMessage(messageId, { + pageSize: this.messagePageSize, + suppressFocusSignal: true, + }); + if (!isSuccess) { + throw new Error(`Unable to jump to message ${messageId}`); + } + // Make sure activeChannelMessages$ and activeThreadMessages$ are updated before the message list receives the jump instruction + paginator.flushState(); this.jumpToMessageSubject.next({ id: messageId, parentId: parentMessageId, @@ -1334,6 +1433,42 @@ export class ChannelService { } } + /** + * Jumps to the first unread message of the active channel, if the message is not yet loaded, it'll load the message (and it's surroundings) from the API. + * @returns `true` if the jump was successful + */ + async jumpToFirstUnreadMessage() { + const activeChannel = this.activeChannelSubject.getValue(); + if (!activeChannel) { + return false; + } + this.isMessageLoadingInProgress = true; + try { + const paginator = activeChannel.messagePaginator; + const isSuccess = await paginator.jumpToTheFirstUnreadMessage({ + pageSize: this.messagePageSize, + }); + if (!isSuccess) { + return false; + } + paginator.flushState(); + // The SDK has its own scroll/highlight logic, the focus signal is only used to know the jump target + const messageId = + paginator.messageFocusSignal.getLatestValue().signal?.messageId; + if (messageId) { + this.jumpToMessageSubject.next({ id: messageId }); + } + return true; + } catch (error) { + this.notificationService.addTemporaryNotification( + 'streamChat.Message not found' + ); + throw error; + } finally { + this.isMessageLoadingInProgress = false; + } + } + /** * Clears the currently selected message to jump */ @@ -1347,7 +1482,7 @@ export class ChannelService { */ async pinMessage(message: StreamMessage) { try { - await this.chatClientService.chatClient?.pinMessage(message); + await this.chatClientService.chatClient?.pinMessage({ id: message.id }); this.notificationService.addTemporaryNotification( 'streamChat.Message pinned', 'success' @@ -1366,7 +1501,7 @@ export class ChannelService { */ async unpinMessage(message: StreamMessage) { try { - await this.chatClientService.chatClient?.unpinMessage(message); + await this.chatClientService.chatClient?.unpinMessage({ id: message.id }); this.notificationService.addTemporaryNotification( 'streamChat.Message unpinned', 'success' @@ -1396,12 +1531,12 @@ export class ChannelService { shoulSetActiveChannel || false, 'recover-state' ); - if (this.activeChannelSubject.getValue()) { - // Thread messages are not refetched so active thread gets deselected to avoid displaying stale messages - void this.setAsActiveParentMessage(undefined); + const activeChannel = this.activeChannelSubject.getValue(); + if (activeChannel) { + // The active channel and thread are activated, so stream-chat reloads them after reconnect, the message lists are updated by the paginator subscriptions, the active thread can stay open + this.markRead(activeChannel); // Update and reselect message to quote const messageToQuote = this.messageToQuoteSubject.getValue(); - this.setChannelState(this.activeChannelSubject.getValue()!); let messages!: StreamMessage[]; this.activeChannelMessages$ .pipe(take(1)) @@ -1461,36 +1596,16 @@ export class ChannelService { } case 'user.updated': { this.ngZone.run(() => { - const updatedChannels = this.channelsSubject.getValue()?.map((c) => { - if (this.chatClientService.chatClient.activeChannels[c.cid]) { - return this.chatClientService.chatClient.activeChannels[c.cid]; - } else { - return c; - } - }); - this.channelsSubject.next(updatedChannels); + // stream-chat updates the user objects in the messages, message lists are updated via the paginators + const channels = this.channelsSubject.getValue(); + if (channels) { + this.channelsSubject.next([...channels]); + } const activeChannel = this.activeChannelSubject.getValue(); if (activeChannel) { - this.activeChannelSubject.next( - this.chatClientService.chatClient.activeChannels[ - activeChannel.cid - ] || activeChannel - ); - this.activeChannelMessagesSubject.next( - activeChannel.state.messages.map((m) => { - (m as StreamMessage).readBy = getReadBy(m, activeChannel); - return { ...m }; - }) - ); - const activeParentMessage = - this.activeParentMessageIdSubject.getValue(); - if (activeParentMessage) { - const messages = activeChannel.state.threads[activeParentMessage]; - this.activeThreadMessagesSubject.next([...messages]); - } - this.activeChannelPinnedMessagesSubject.next([ - ...(activeChannel.state.pinnedMessages as StreamMessage[]), - ]); + this.activeChannelSubject.next(activeChannel); + // Refresh the users of the readBy lists (same as before stream-chat v10) + this.refreshReadBy(activeChannel, 'all'); } }); break; @@ -1498,20 +1613,30 @@ export class ChannelService { } } + private getEventChannel(event: Event) { + return 'channel' in event + ? (event.channel as ChannelResponse | undefined) + : undefined; + } + private handleRemovedFromChannelNotification(clientEvent: ClientEvent) { - const channelIdToBeRemoved = clientEvent.event.channel!.cid; - this.removeChannel(channelIdToBeRemoved, true); + const channel = this.getEventChannel(clientEvent.event); + if (channel) { + this.removeChannel(channel.cid, true); + } } private handleNewMessageNotification(clientEvent: ClientEvent) { - if (clientEvent.event.channel) { - void this.addChannelFromNotification(clientEvent.event.channel); + const channel = this.getEventChannel(clientEvent.event); + if (channel) { + void this.addChannelFromNotification(channel); } } private handleAddedToChannelNotification(clientEvent: ClientEvent) { - if (clientEvent.event.channel) { - void this.addChannelFromNotification(clientEvent.event.channel); + const channel = this.getEventChannel(clientEvent.event); + if (channel) { + void this.addChannelFromNotification(channel); } } @@ -1524,13 +1649,14 @@ export class ChannelService { if (currentChannels.find((c) => c.cid === newChannel.cid)) { return; } - await newChannel.watch().catch((err) => { - this.chatClientService.chatClient.logger( - 'error', + try { + await newChannel.watch(); + } catch (error) { + this.chatClientService.logger.error( 'Failed to add channel to channel list because watch request failed', - err as Record + { error: error as Error } ); - }); + } currentChannels = this.channelsSubject.getValue() || []; if (currentChannels.find((c) => c.cid === newChannel.cid)) { return; @@ -1540,19 +1666,47 @@ export class ChannelService { } private watchForActiveChannelEvents(channel: Channel) { + this.activeChannelPaginatorSubscriptions = [ + ...this.bindMessagePaginator( + channel.messagePaginator, + this.activeChannelMessagesSubject, + this.activeChannelMessagesPaginationStateSubject, + () => this.activeChannelSubject.getValue() === channel + ), + channel.pinnedMessagesPaginator.state.subscribeWithSelector( + (state) => ({ items: state.items }), + ({ items }) => { + this.ngZone.run(() => { + if (this.activeChannelSubject.getValue() !== channel) { + return; + } + this.activeChannelPinnedMessagesSubject.next( + (items ?? []).map((m) => + this.transformToStreamMessage(m, channel) + ) + ); + }); + } + ), + // read state changes (for example message.read events): update the readBy property of the latest message (same as before stream-chat v10) + channel.state.subscribeWithSelector( + (state) => ({ read: state.read }), + (_, prev) => { + if (!prev) { + return; + } + this.ngZone.run(() => { + if (this.activeChannelSubject.getValue() !== channel) { + return; + } + this.refreshReadBy(channel, 'latest'); + }); + } + ), + ]; this.activeChannelSubscriptions.push( channel.on('message.new', (event) => { this.ngZone.run(() => { - event.message && event.message.parent_id - ? event.message.parent_id === - this.activeParentMessageIdSubject.getValue() - ? this.activeThreadMessagesSubject.next([ - ...channel.state.threads[event.message.parent_id], - ]) - : null - : this.activeChannelMessagesSubject.next([ - ...channel.state.messages, - ]); this.activeChannel$.pipe(first()).subscribe((c) => { if (c) { this.markRead(c); @@ -1562,58 +1716,19 @@ export class ChannelService { }); }) ); - this.activeChannelSubscriptions.push( - channel.on('message.updated', (event) => this.messageUpdated(event)) - ); - this.activeChannelSubscriptions.push( - channel.on('message.deleted', (event) => this.messageUpdated(event)) - ); - this.activeChannelSubscriptions.push( - channel.on('reaction.new', (e) => this.messageReactionEventReceived(e)) - ); - this.activeChannelSubscriptions.push( - channel.on('reaction.deleted', (e) => - this.messageReactionEventReceived(e) - ) - ); - this.activeChannelSubscriptions.push( - channel.on('reaction.updated', (e) => - this.messageReactionEventReceived(e) - ) - ); - this.activeChannelSubscriptions.push( - channel.on('message.read', (e) => { - this.ngZone.run(() => { - let latestMessage!: StreamMessage; - let messages!: StreamMessage[]; - this.activeChannelMessages$.pipe(first()).subscribe((m) => { - messages = m; - latestMessage = messages[messages.length - 1]; - }); - if (!latestMessage || !e.user) { - return; - } - if (latestMessage.readBy) { - latestMessage.readBy.splice(0, latestMessage.readBy.length); - } else { - latestMessage.readBy = []; - } - latestMessage.readBy.push(...getReadBy(latestMessage, channel)); - messages[messages.length - 1] = { ...latestMessage }; - - this.activeChannelMessagesSubject.next([...messages]); - }); - }) - ); this.activeChannelSubscriptions.push( this.chatClientService.events$ .pipe( filter( (e) => e.eventType === 'notification.mark_unread' && + e.event.type === 'notification.mark_unread' && e.event.channel_id === channel.id ), - map((e) => e.event) + map( + (e) => + e.event as Extract + ) ) .subscribe((e) => { this.ngZone.run(() => { @@ -1635,6 +1750,145 @@ export class ChannelService { ); } + /** + * Subscribes to the given message paginator, and emits the message list and pagination state via the provided subjects + * @param paginator + * @param messagesSubject + * @param paginationStateSubject + * @param isActive + */ + private bindMessagePaginator( + paginator: MessageIntervalPaginator, + messagesSubject: BehaviorSubject< + (StreamMessage | MessageResponse | LocalMessage)[] + >, + paginationStateSubject: BehaviorSubject, + isActive: () => boolean + ) { + return [ + paginator.state.subscribeWithSelector( + (state) => ({ + items: state.items, + hasMoreTail: state.hasMoreTail, + hasMoreHead: state.hasMoreHead, + isLoading: state.isLoading, + lastQueryError: state.lastQueryError, + }), + (state, prev) => { + this.ngZone.run(() => { + if (!isActive()) { + return; + } + // hasMoreHead is also used to decide if the latest message is in the list + if ( + !prev || + state.items !== prev.items || + state.hasMoreHead !== prev.hasMoreHead + ) { + messagesSubject.next(state.items ?? []); + } + paginationStateSubject.next({ + hasMoreOlder: state.hasMoreTail, + hasMoreNewer: state.hasMoreHead, + isLoading: state.isLoading, + error: state.lastQueryError, + }); + }); + } + ), + ]; + } + + private getMessagePaginator(parentId?: string) { + if (parentId) { + return this.activeThread?.id === parentId + ? this.activeThread.messagePaginator + : this.chatClientService.chatClient.threads.get(parentId) + ?.messagePaginator; + } + return this.activeChannelSubject.getValue()?.messagePaginator; + } + + private async getMessage(channel: Channel, messageId: string) { + const message = channel.messagePaginator.getItem(messageId); + if (message) { + return message; + } + const response = await channel.getMessagesById([messageId]); + const result = response.messages?.[0]; + if (!result) { + throw new Error(`Message ${messageId} not found`); + } + return formatMessage(result); + } + + private toMessageRequest(message: StreamMessage): MessageRequest { + const localMessage = toLocalMessage(message); + return { + id: localMessage.id, + text: localMessage.text, + attachments: localMessage.attachments, + mentioned_users: localMessage.mentioned_users?.map((u) => u.id), + parent_id: localMessage.parent_id, + show_in_channel: localMessage.show_in_channel, + quoted_message_id: localMessage.quoted_message_id, + poll_id: localMessage.poll_id, + pinned: localMessage.pinned, + silent: localMessage.silent, + custom: localMessage.custom, + }; + } + + private releaseThread() { + this.activeThreadPaginatorSubscriptions.forEach((unsubscribe) => + unsubscribe() + ); + this.activeThreadPaginatorSubscriptions = []; + this.releaseActiveThread?.(); + this.releaseActiveThread = undefined; + this.activeThread = undefined; + this.activeThreadMessagesPaginationStateSubject.next( + ChannelService.initialPaginationState + ); + } + + private async loadInitialMessages(channel: Channel) { + this.activeChannelMessagesPaginationStateSubject.next({ + ...this.activeChannelMessagesPaginationStateSubject.getValue(), + isLoading: true, + error: undefined, + }); + try { + await channel.watch(); + } catch (error) { + if (this.activeChannelSubject.getValue() === channel) { + this.activeChannelMessagesPaginationStateSubject.next({ + ...this.activeChannelMessagesPaginationStateSubject.getValue(), + isLoading: false, + error: error as Error, + }); + this.notificationService.addTemporaryNotification( + 'streamChat.Error loading messages' + ); + } + } + } + + /** + * Retries loading the messages of the active channel (for example after a failed request) + */ + async reloadActiveChannelMessages() { + const channel = this.activeChannelSubject.getValue(); + if (!channel) { + return; + } + if (!channel.initialized) { + await this.loadInitialMessages(channel); + } else { + await channel.messagePaginator.reload(); + } + } + /** * Call this method if user started typing in the active channel * @param parentId The id of the parent message, if user is typing in a thread @@ -1682,7 +1936,7 @@ export class ChannelService { } /** - * Get the last 1200 reactions of a message in the current active channel. If you need to fetch more reactions please use the [following endpoint](/chat/docs/javascript/send_reaction/#paginating-reactions). + * Get the last 1200 reactions of a message in the current active channel. If you need to fetch more reactions please use the [following endpoint](/chat/docs/javascript/send-reaction/#paginating-reactions). * @deprecated use [`messageReactionsService.queryReactions()`](/chat/docs/sdk/angular/services/MessageReactionsService/#queryreactions) instead * @param messageId * @returns all reactions of a message @@ -1696,10 +1950,13 @@ export class ChannelService { while (lastPageSize === limit && reactions.length < reactionsLimit) { try { - const response = await this.activeChannel?.getReactions(messageId, { - offset, - limit, - }); + const response = await this.chatClientService.chatClient.getReactions( + { id: messageId }, + { + offset, + limit, + } + ); lastPageSize = response?.reactions?.length || 0; if (lastPageSize > 0) { reactions.push(...response!.reactions); @@ -1769,94 +2026,20 @@ export class ChannelService { } } - private messageUpdated(event: Event) { - this.ngZone.run(() => { - const isThreadReply = event.message && event.message.parent_id; - const channel = this.activeChannelSubject.getValue(); - if (!channel) { - return; - } - // Get messages from state as message order could change, and message could've been deleted - const messages: FormatMessageResponse[] = isThreadReply - ? channel.state.threads[event?.message?.parent_id || ''] - : channel.state.messages; - if (!messages) { - return; - } - const messageIndex = messages.findIndex( - (m) => m.id === event?.message?.id - ); - if (messageIndex !== -1 || event.type === 'message.deleted') { - isThreadReply - ? this.activeThreadMessagesSubject.next([...messages]) - : this.activeChannelMessagesSubject.next([...messages]); - this.activeChannelPinnedMessagesSubject.next([ - ...(channel.state.pinnedMessages as StreamMessage[]), - ]); - } - }); - } - - private messageReactionEventReceived(e: Event) { - this.ngZone.run(() => { - const isThreadMessage = e.message && e.message.parent_id; - let messages!: StreamMessage[]; - (isThreadMessage - ? this.activeThreadMessages$ - : this.activeChannelMessages$ - ) - .pipe(first()) - .subscribe((m) => (messages = m)); - const messageIndex = messages.findIndex((m) => m.id === e?.message?.id); - if (messageIndex === -1) { - return; - } - const message = messages[messageIndex]; - message.reaction_counts = { ...e.message?.reaction_counts }; - message.reaction_scores = { ...e.message?.reaction_scores }; - message.latest_reactions = [...(e.message?.latest_reactions || [])]; - message.own_reactions = [...(e.message?.own_reactions || [])]; - message.reaction_groups = { ...e.message?.reaction_groups }; - - messages[messageIndex] = { ...message }; - isThreadMessage - ? this.activeThreadMessagesSubject.next([...messages]) - : this.activeChannelMessagesSubject.next([...messages]); - }); - } - - private formatMessage(message: MessageResponse) { - const m = message as unknown as FormatMessageResponse; - m.pinned_at = message.pinned_at ? new Date(message.pinned_at) : null; - m.created_at = message.created_at - ? new Date(message.created_at) - : new Date(); - m.updated_at = message.updated_at - ? new Date(message.updated_at) - : new Date(); - message.status = message.status || 'received'; - - return m; - } - - private isStreamMessage( - message: StreamMessage | FormatMessageResponse | MessageResponse - ): message is StreamMessage { - return 'readBy' in message; - } - - private isFormatMessageResponse( - message: StreamMessage | FormatMessageResponse | MessageResponse - ): message is FormatMessageResponse { - return message.created_at instanceof Date; - } - private stopWatchForActiveChannelEvents(channel: Channel | undefined) { if (!channel) { return; } this.activeChannelSubscriptions.forEach((s) => s.unsubscribe()); this.activeChannelSubscriptions = []; + this.activeChannelPaginatorSubscriptions.forEach((unsubscribe) => + unsubscribe() + ); + this.activeChannelPaginatorSubscriptions = []; + channel.messagePaginator.setViewingLive(false); + this.latestStreamMessageById.clear(); + this.releaseActiveChannel?.(); + this.releaseActiveChannel = undefined; } private async queryChannels( @@ -1917,8 +2100,7 @@ export class ChannelService { this.channelSubscriptions[channel.cid](); } const unsubscribe = channel.on((event: Event) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const type = event.type as EventTypes | 'capabilities.changed'; + const type: EventType = event.type; switch (type) { case 'message.new': { this.ngZone.run(() => { @@ -2024,7 +2206,7 @@ export class ChannelService { } case 'capabilities.changed': { this.ngZone.run(() => { - const cid = event.cid; + const cid = 'cid' in event ? event.cid : undefined; if (cid) { const currentChannels = this.channelsSubject.getValue(); const index = currentChannels?.findIndex((c) => c.cid === cid); @@ -2052,15 +2234,21 @@ export class ChannelService { } private handleChannelHidden(event: Event) { - this.removeChannel(event.channel!.cid, false); + const cid = 'cid' in event ? event.cid : undefined; + if (cid) { + this.removeChannel(cid, false); + } } private handleChannelDeleted(event: Event) { - this.removeChannel(event.channel!.cid, false); + const cid = 'cid' in event ? event.cid : undefined; + if (cid) { + this.removeChannel(cid, false); + } } - private handleChannelVisible(event: Event, channel: Channel) { - if (!this.channels.find((c) => c.cid === event.cid)) { + private handleChannelVisible(_: Event, channel: Channel) { + if (!this.channels.find((c) => c.cid === channel.cid)) { this.ngZone.run(() => this.channelsSubject.next([...this.channels, channel]) ); @@ -2068,40 +2256,28 @@ export class ChannelService { } private handleChannelUpdate(event: Event) { - const channelIndex = this.channels.findIndex( - (c) => c.cid === event.channel!.cid - ); + const cid = 'cid' in event ? event.cid : undefined; + const channelIndex = this.channels.findIndex((c) => c.cid === cid); if (channelIndex !== -1) { + // stream-chat updates channel.data (and keeps hidden, own_capabilities and member_count) const channel = this.channels[channelIndex]; - const notIncludedProperies = { - hidden: channel.data?.hidden || false, - own_capabilities: channel.data?.own_capabilities || [], - }; - channel.data = { - ...event.channel!, - ...notIncludedProperies, - }; this.channelsSubject.next([...this.channels]); - if (event.channel?.cid === this.activeChannelSubject.getValue()?.cid) { + if (cid === this.activeChannelSubject.getValue()?.cid) { this.activeChannelSubject.next(channel); } } } private handleChannelTruncate(event: Event) { - const channelIndex = this.channels.findIndex( - (c) => c.cid === event.channel!.cid - ); + const cid = 'cid' in event ? event.cid : undefined; + const channelIndex = this.channels.findIndex((c) => c.cid === cid); if (channelIndex !== -1) { - this.channels[channelIndex].state.messages = []; + // stream-chat truncates the message paginator, message list is updated via the paginator this.channelsSubject.next([...this.channels]); - if (event.channel?.cid === this.activeChannelSubject.getValue()?.cid) { + if (cid === this.activeChannelSubject.getValue()?.cid) { const channel = this.activeChannelSubject.getValue()!; - channel.state.messages = []; this.activeChannelSubject.next(channel); - this.activeChannelMessagesSubject.next([]); - this.activeParentMessageIdSubject.next(undefined); - this.activeThreadMessagesSubject.next([]); + void this.setAsActiveParentMessage(undefined); } } } @@ -2111,72 +2287,149 @@ export class ChannelService { if (!channel) { return false; } - const capabilites = channel.data?.own_capabilities as string[]; - return capabilites.indexOf('read-events') !== -1; + const capabilites = channel.data?.own_capabilities ?? []; + return ( + capabilites.indexOf('read-events') !== -1 && + channel.config.readEvents.enabled + ); } private transformToStreamMessage( - message: StreamMessage | MessageResponse | FormatMessageResponse, + message: StreamMessage | MessageResponse | LocalMessage, channel?: Channel - ) { + ): StreamMessage { const isThreadMessage = !!message.parent_id; + const user = this.chatClientService.chatClient.user; + if ('readBy' in message) { + // Already a StreamMessage (for example set via a custom handler) + const streamMessage = message as StreamMessage; + return { + ...streamMessage, + translation: getMessageTranslation(streamMessage, channel, user), + quoted_message: streamMessage.quoted_message + ? { + ...streamMessage.quoted_message, + translation: getMessageTranslation( + streamMessage.quoted_message, + channel, + user + ), + } + : undefined, + }; + } + const localMessage = message as LocalMessage | MessageResponse; + const errorStatusCode = this.errorStatusCodes.get(localMessage.id); + const cached = this.streamMessageCache.get(localMessage); + // readBy is calculated when the message object is created, after that it's only updated by refreshReadBy (same as before stream-chat v10) + if (cached && cached.errorStatusCode === errorStatusCode) { + return cached.message; + } + // Messages are stored by stream-chat, they shouldn't be mutated, we create a new object instead + const streamMessage: StreamMessage = { + ...localMessage, + status: (localMessage as LocalMessage).status || 'received', + readBy: isThreadMessage + ? [] + : channel + ? getReadBy(localMessage, channel) + : [], + translation: getMessageTranslation(localMessage, channel, user), + errorStatusCode, + quoted_message: localMessage.quoted_message + ? { + ...localMessage.quoted_message, + translation: getMessageTranslation( + localMessage.quoted_message, + channel, + user + ), + } + : undefined, + }; + // Every API response/WS event contains newly signed attachment URLs (only the query string changes), reusing the previous attachments avoids reloading images/videos (for example when a reaction is added) + const previousMessage = this.latestStreamMessageById.get(localMessage.id); if ( - this.isStreamMessage(message) && - this.isFormatMessageResponse(message) + previousMessage && + this.areAttachmentsEquivalent( + previousMessage.attachments, + streamMessage.attachments + ) ) { - if (message.quoted_message) { - message.quoted_message.translation = getMessageTranslation( - message.quoted_message, - channel, - this.chatClientService.chatClient.user - ); + streamMessage.attachments = previousMessage.attachments; + } + this.latestStreamMessageById.set(localMessage.id, streamMessage); + this.streamMessageCache.set(localMessage, { + errorStatusCode, + message: streamMessage, + }); + return streamMessage; + } + + /** + * Recalculates the readBy property of the latest (or all) messages of the active channel, and emits the message list if anything changed + * @param channel + * @param scope + */ + private refreshReadBy(channel: Channel, scope: 'latest' | 'all') { + const messages = this.activeChannelMessagesSubject.getValue(); + const messagesToRefresh = + scope === 'latest' ? messages.slice(-1) : messages; + let isChanged = false; + messagesToRefresh.forEach((message) => { + const cached = this.streamMessageCache.get(message); + if (!cached || cached.message.parent_id) { + return; } - message.translation = getMessageTranslation( - message, - channel, - this.chatClientService.chatClient.user - ); - return message; - } else { - if (message.quoted_message) { - (message as StreamMessage).quoted_message!.translation = - getMessageTranslation( - message.quoted_message, - channel, - this.chatClientService.chatClient.user - ); + const readBy = getReadBy(cached.message, channel); + if (!this.isSameUserList(readBy, cached.message.readBy)) { + cached.message = { ...cached.message, readBy }; + this.latestStreamMessageById.set(cached.message.id, cached.message); + isChanged = true; } - if (this.isFormatMessageResponse(message)) { - (message as StreamMessage).readBy = isThreadMessage - ? [] - : channel - ? getReadBy(message, channel) - : []; - (message as StreamMessage).translation = getMessageTranslation( - message, - channel, - this.chatClientService.chatClient.user - ); + }); + if (isChanged) { + this.activeChannelMessagesSubject.next([...messages]); + } + } - return message as StreamMessage; - } else { - message = this.formatMessage(message); - (message as StreamMessage).readBy = isThreadMessage - ? [] - : channel - ? getReadBy(message, channel) - : []; - (message as StreamMessage).translation = getMessageTranslation( - message, - channel, - this.chatClientService.chatClient.user - ); - return message as StreamMessage; - } + private isSameUserList(users1: UserResponse[], users2: UserResponse[]) { + return ( + users1.length === users2.length && + users1.every( + (u, i) => + u.id === users2[i]?.id && + u.name === users2[i]?.name && + u.image === users2[i]?.image + ) + ); + } + + private areAttachmentsEquivalent( + attachments1: Attachment[] | undefined, + attachments2: Attachment[] | undefined + ) { + if (attachments1 === attachments2) { + return true; + } + if ( + !attachments1 || + !attachments2 || + attachments1.length !== attachments2.length + ) { + return false; } + const ignoreUrlQueryString = (_: string, value: unknown) => + typeof value === 'string' && /^https?:\/\//.test(value) + ? value.split('?')[0] + : value; + return ( + JSON.stringify(attachments1, ignoreUrlQueryString) === + JSON.stringify(attachments2, ignoreUrlQueryString) + ); } - private handleTypingStartEvent(event: Event) { + private handleTypingStartEvent(event: EventPayload<'typing.start'>) { if (event.user?.id === this.chatClientService.chatClient.user?.id) { return; } @@ -2198,7 +2451,7 @@ export class ChannelService { } } - private handleTypingStopEvent(event: Event) { + private handleTypingStopEvent(event: EventPayload<'typing.stop'>) { const usersTypingInChannel = this.usersTypingInChannelSubject.getValue(); const usersTypingInThread = this.usersTypingInThreadSubject.getValue(); const user = event.user; @@ -2220,7 +2473,7 @@ export class ChannelService { } } - private updateLatestMessages(event: Event) { + private updateLatestMessages(event: EventPayload<'message.new'>) { if ( event.message?.user?.id !== this.chatClientService?.chatClient.user?.id ) { @@ -2235,7 +2488,7 @@ export class ChannelService { if (!channelId) { return; } - const messageDate = new Date(event.message.created_at); + const messageDate = nsToDate(event.message.created_at); if ( !latestMessages[channelId] || latestMessages[channelId]?.getTime() < messageDate.getTime() @@ -2248,27 +2501,8 @@ export class ChannelService { } private setChannelState(channel: Channel) { - channel.state.messages.forEach((m) => { - (m as StreamMessage).readBy = getReadBy(m, channel); - (m as StreamMessage).translation = getMessageTranslation( - m, - channel, - this.chatClientService.chatClient.user - ); - if (m.quoted_message) { - (m as StreamMessage).quoted_message!.translation = - getMessageTranslation( - m.quoted_message, - channel, - this.chatClientService.chatClient.user - ); - } - }); this.markRead(channel); - this.activeChannelMessagesSubject.next([...channel.state.messages]); - this.activeChannelPinnedMessagesSubject.next([ - ...(channel.state.pinnedMessages as StreamMessage[]), - ]); + // Messages and pinned messages are emitted by the paginator subscriptions (see watchForActiveChannelEvents) this.activeParentMessageIdSubject.next(undefined); this.activeThreadMessagesSubject.next([]); this.messageToQuoteSubject.next(undefined); @@ -2285,7 +2519,7 @@ export class ChannelService { if (isThrottled) { this.markReadThrottled(channel); } else { - void channel.markRead(); + void this.sendMarkReadRequest(channel); } } } @@ -2305,6 +2539,16 @@ export class ChannelService { } } + private async sendMarkReadRequest(channel: Channel) { + try { + await channel.markReadViaReporter(); + } catch (error) { + this.chatClientService.logger.warn('Failed to mark channel as read', { + error: error as Error, + }); + } + } + private flushMarkReadQueue() { this.scheduledMarkReadRequest?.(); this.scheduledMarkReadRequest = undefined; @@ -2318,9 +2562,20 @@ export class ChannelService { }) { this.shouldSetActiveChannel = settings.shouldSetActiveChannel; this.messagePageSize = settings.messagePageSize; + const client = this.chatClientService.chatClient; + // Use the same page size for message list pagination, as for the initial message list (queryChannels message_limit) + client.config.setConfig('channel', { + messagePaginator: { pageSize: this.messagePageSize }, + }); + client.config.setConfig('thread', { + messagePaginator: { pageSize: this.messagePageSize }, + }); this.clientEventsSubscription = this.chatClientService.events$.subscribe( (notification) => void this.handleNotification(notification) ); + // Thread events are only delivered to threads if thread manager subscriptions are active (this is a no-op if already registered) + // We don't unregister on reset: unregistering disposes the message paginators of threads, which are reused by stream-chat + client.threads.registerSubscriptions(); try { const result = await this.queryChannels( this.shouldSetActiveChannel, 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 162d12fb..7f6c981d 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 @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { Event, OwnUserResponse, StreamChat } from 'stream-chat'; import { ChatClientService } from './chat-client.service'; import { @@ -31,7 +31,7 @@ describe('ChatClientService', () => { mockChatClient.connectUser.calls.reset(); await service.init(apiKey, userId, userToken); - expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, undefined); + expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, {}); const spy = jasmine.createSpy(); service.appSettings$.subscribe(spy); const userSpy = jasmine.createSpy(); @@ -49,7 +49,7 @@ describe('ChatClientService', () => { mockChatClient.connectUser.calls.reset(); await service.init(apiKey, userId, 'guest'); - expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, undefined); + expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, {}); const spy = jasmine.createSpy(); service.appSettings$.subscribe(spy); const userSpy = jasmine.createSpy(); @@ -65,7 +65,7 @@ describe('ChatClientService', () => { mockChatClient.connectUser.calls.reset(); await service.init(apiKey, undefined, 'anonymous'); - expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, undefined); + expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, {}); const spy = jasmine.createSpy(); service.appSettings$.subscribe(spy); @@ -140,11 +140,17 @@ describe('ChatClientService', () => { id: userId, name: 'Test user', } as OwnUserResponse; - const options = { timeout: 5000 }; - await service.init(apiKey, user, userToken, options); + const options = { client: { axiosRequestConfig: { timeout: 5000 } } }; + await service.init(apiKey, user, userToken, { + ...options, + trackPendingChannelInvites: false, + }); - // @ts-expect-error don't know what's wrong here - expect(StreamChat.getInstance).toHaveBeenCalledWith(apiKey, options); + expect(StreamChat.getInstance).toHaveBeenCalledWith( + apiKey, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + options as any + ); }); it('should init with token provider', async () => { @@ -265,43 +271,69 @@ describe('ChatClientService', () => { }); }); - it('should notify if the user goes offline', () => { + it('should notify if the user goes offline', fakeAsync(() => { const spy = jasmine.createSpy(); service.connectionState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith('online'); + + spy.calls.reset(); const notificationService = TestBed.inject(NotificationService); spyOn(notificationService, 'addPermanentNotification'); - const event = { id: 'mockevent', online: false } as any as Event; - mockChatClient.handleEvent('connection.changed', event); + mockChatClient.wsConnection.state.partialNext({ isHealthy: false }); + + // offline state is debounced + expect(spy).not.toHaveBeenCalled(); + + tick(service.connectionFailureNotificationDelay); expect(spy).toHaveBeenCalledWith('offline'); expect(notificationService.addPermanentNotification).toHaveBeenCalledWith( 'streamChat.Connection failure, reconnecting now...' ); - }); + })); + + it(`shouldn't notify if the connection recovers within the failure notification delay`, fakeAsync(() => { + const spy = jasmine.createSpy(); + service.connectionState$.subscribe(spy); + spy.calls.reset(); + const notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'addPermanentNotification'); + mockChatClient.wsConnection.state.partialNext({ isHealthy: false }); + tick(service.connectionFailureNotificationDelay / 2); + mockChatClient.wsConnection.state.partialNext({ isHealthy: true }); + tick(service.connectionFailureNotificationDelay); + + expect(spy).not.toHaveBeenCalledWith('offline'); + expect(notificationService.addPermanentNotification).not.toHaveBeenCalled(); + })); - it('should notify if the user goes online', () => { + it('should notify if the user goes online', fakeAsync(() => { const removeNotificationSpy = jasmine.createSpy(); const notificationService = TestBed.inject(NotificationService); spyOn(notificationService, 'addPermanentNotification').and.returnValue( removeNotificationSpy ); - mockChatClient.handleEvent('connection.changed', { - id: 'event', - online: false, - } as any as Event); + service.connectionFailureNotificationDelay = 0; + mockChatClient.wsConnection.state.partialNext({ isHealthy: false }); + tick(); const stateSpy = jasmine.createSpy(); service.connectionState$.subscribe(stateSpy); - const event = { id: 'mockevent', online: true } as any as Event; - mockChatClient.handleEvent('connection.changed', event); + + expect(stateSpy).toHaveBeenCalledWith('offline'); + + mockChatClient.wsConnection.state.partialNext({ isHealthy: true }); expect(removeNotificationSpy).toHaveBeenCalledWith(); expect(stateSpy).toHaveBeenCalledWith('online'); - }); + })); it('should flag message', async () => { await service.flagMessage('messageId'); - expect(mockChatClient.flagMessage).toHaveBeenCalledWith('messageId'); + expect(mockChatClient.moderation.flagMessage).toHaveBeenCalledWith({ + message_id: 'messageId', + }); }); it('should query users', async () => { @@ -315,21 +347,25 @@ describe('ChatClientService', () => { }); const result = await service.autocompleteUsers('zi'); - expect(mockChatClient.queryUsers).toHaveBeenCalledWith( - jasmine.objectContaining({ - $or: [ - { id: { $autocomplete: 'zi' } }, - { name: { $autocomplete: 'zi' } }, - ], - }) - ); + expect(mockChatClient.queryUsers).toHaveBeenCalledWith({ + payload: jasmine.objectContaining({ + filter_conditions: { + $or: [ + { id: { $autocomplete: 'zi' } }, + { name: { $autocomplete: 'zi' } }, + ], + }, + }), + }); expect(result.length).toBe(1); }); it('should initialize pending invites', async () => { const channelsWithPendingInvites = [{ cid: 'cat-lovers' }]; - mockChatClient.queryChannels.and.resolveTo(channelsWithPendingInvites); + mockChatClient.queryChannelsAndHydrate.and.resolveTo( + channelsWithPendingInvites + ); const invitesSpy = jasmine.createSpy(); service.pendingInvites$.subscribe(invitesSpy); invitesSpy.calls.reset(); @@ -337,9 +373,11 @@ describe('ChatClientService', () => { trackPendingChannelInvites: true, }); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith({ - invite: 'pending', - members: { $in: [mockChatClient.user.id] }, + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith({ + filter_conditions: { + invite: 'pending', + members: { $in: [mockChatClient.user.id] }, + }, }); expect(invitesSpy).toHaveBeenCalledWith(channelsWithPendingInvites); 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 9e31b173..993f7583 100644 --- a/projects/stream-chat-angular/src/lib/chat-client.service.ts +++ b/projects/stream-chat-angular/src/lib/chat-client.service.ts @@ -1,19 +1,28 @@ import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; import { - AppSettingsAPIResponse, Channel, ChannelFilters, + ClientUser, ConnectAPIResponse, + DeepPartial, + GetApplicationResponse, + InstanceConfigTree, OwnUserResponse, - StreamChatOptions, UserFilters, UserResponse, } from 'stream-chat'; -import { AppSettings, Event, StreamChat, TokenOrProvider } from 'stream-chat'; +import { + chatLoggerSystem, + Event, + ScopedLogger, + StreamChat, + TokenOrProvider, +} from 'stream-chat'; import { version } from '../assets/version'; import { NotificationService } from './notification.service'; import { take } from 'rxjs/operators'; +import { AppSettings } from './types'; export type ClientEvent = { eventType: string; @@ -32,14 +41,14 @@ export class ChatClientService { */ chatClient!: StreamChat; /** - * Emits [`ClientEvent`](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angular/src/lib/chat-client.service.ts) events. The platform documentation covers [the list of client, user presence and notification events](/chat/docs/javascript/event_object/). + * Emits [`ClientEvent`](https://github.com/GetStream/stream-chat-angular/blob/master/projects/stream-chat-angular/src/lib/chat-client.service.ts) events. The platform documentation covers [the list of client, user presence and notification events](/chat/docs/javascript/event-object/). * :::important * For performance reasons this Observable operates outside of the Angular change detection zone. If you subscribe to it, you need to manually reenter Angular's change detection zone, our [Change detection guide](/chat/docs/sdk/angular/concepts/change-detection/) explains this in detail. * ::: */ events$: Observable; /** - * Emits the current [application settings](/chat/docs/javascript/app_setting_overview/). Since getting the application settings is an expensive API call and we don't always need the result, this is not initialized by default, you need to call `getApplicationSettings` to load them. + * Emits the current [application settings](/chat/docs/node/app-setting-overview/). Since getting the application settings is an expensive API call and we don't always need the result, this is not initialized by default, you need to call `getApplicationSettings` to load them. */ appSettings$: Observable; /** @@ -54,6 +63,12 @@ export class ChatClientService { * Emits the current chat user */ user$: Observable; + /** + * @internal + */ + readonly logger: ScopedLogger = chatLoggerSystem + .getLogger('client') + .withExtraTags('stream-chat-angular'); private notificationSubject = new ReplaySubject(1); private connectionStateSubject = new ReplaySubject<'offline' | 'online'>(1); private appSettingsSubject = new BehaviorSubject( @@ -65,7 +80,12 @@ export class ChatClientService { >(1); private subscriptions: { unsubscribe: () => void }[] = []; private trackPendingChannelInvites = true; - private appSettingsPromise?: Promise; + /** + * @internal + */ + connectionFailureNotificationDelay = 5000; + private appSettingsPromise?: Promise; + private connectionStateCleanup?: () => void; constructor( private ngZone: NgZone, @@ -79,22 +99,22 @@ export class ChatClientService { } /** - * Creates a [`StreamChat`](https://github.com/GetStream/stream-chat-js/blob/668b3e5521339f4e14fc657834531b4c8bf8176b/src/client.ts#L124) instance using the provided `apiKey`, and connects a user with the given meta data and token. More info about [connecting users](/chat/docs/javascript/init_and_users/) can be found in the platform documentation. + * Creates a [`StreamChat`](https://github.com/GetStream/stream-chat-js/blob/668b3e5521339f4e14fc657834531b4c8bf8176b/src/client.ts#L124) instance using the provided `apiKey`, and connects a user with the given meta data and token. More info about [connecting users](/chat/docs/javascript/init-and-users/) can be found in the platform documentation. * @param apiKey - * @param userOrId you can emit this for anonymous logins + * @param userOrId you can omit this for anonymous logins * @param userTokenOrProvider You can provide:
    *
  • a token,
  • *
  • a token provider, a method that returns `Promise`, which can be called when the previous token expires (recommended setup for production applications)
  • - *
  • the keyword 'guest' to connect as [guest user](/chat/docs/javascript/authless_users/#guest-users)
  • - *
  • the keyword 'anonymous' to connect as [anonymous user](/chat/docs/javascript/authless_users/#anonymous-users)
  • + *
  • the keyword 'guest' to connect as [guest user](/chat/docs/javascript/authless-users/#guest-users)
  • + *
  • the keyword 'anonymous' to connect as [anonymous user](/chat/docs/javascript/authless-users/#anonymous-users)
  • *
* @param clientOptions Setting to provide to the Stream client instance */ async init( apiKey: string, - userOrId: string | OwnUserResponse | UserResponse | undefined, + userOrId: string | ClientUser | undefined, userTokenOrProvider: TokenOrProvider, - clientOptions?: StreamChatOptions & { + clientOptions?: DeepPartial & { trackPendingChannelInvites?: boolean; } ): ConnectAPIResponse { @@ -104,24 +124,30 @@ export class ChatClientService { } this.trackPendingChannelInvites = clientOptions?.trackPendingChannelInvites === true; - this.chatClient = StreamChat.getInstance(apiKey, clientOptions); + const { trackPendingChannelInvites: _, ...config } = clientOptions ?? {}; + this.chatClient = StreamChat.getInstance(apiKey, config); this.chatClient.sdkIdentifier = { name: 'angular', version, }; - this.chatClient.recoverStateOnReconnect = false; - this.chatClient.devToken; let result; await this.ngZone.runOutsideAngular(async () => { const user = typeof userOrId === 'string' ? { id: userOrId } : userOrId; try { result = await ( { - guest: () => this.chatClient.setGuestUser(user!), + guest: () => + this.chatClient.setGuestUser( + user as ClientUser + ) as ConnectAPIResponse, anonymous: () => this.chatClient.connectAnonymousUser(), // eslint-disable-next-line @typescript-eslint/restrict-template-expressions }[`${userTokenOrProvider}`] ?? - (() => this.chatClient.connectUser(user!, userTokenOrProvider)) + (() => + this.chatClient.connectUser( + user as ClientUser, + userTokenOrProvider + )) )(); } catch (error) { this.notificationService.addPermanentNotification( @@ -131,16 +157,18 @@ export class ChatClientService { throw error; } this.userSubject.next( - this.chatClient.user ? { ...this.chatClient.user } : undefined + this.chatClient.user + ? ({ ...this.chatClient.user } as OwnUserResponse) + : undefined ); }); if (this.chatClient.user?.id && this.trackPendingChannelInvites) { - const channels = await this.chatClient.queryChannels( - { + const channels = await this.chatClient.queryChannelsAndHydrate({ + filter_conditions: { invite: 'pending', members: { $in: [this.chatClient.user?.id] }, - } as unknown as ChannelFilters // TODO: find out why we need this typecast - ); + } as ChannelFilters, + }); this.pendingInvitesSubject.next(channels); } this.subscriptions.push( @@ -154,39 +182,64 @@ export class ChatClientService { }) ); let removeNotification: undefined | (() => void); - this.subscriptions.push( - this.chatClient.on('connection.changed', (e) => { - this.ngZone.run(() => { - const isOnline = e.online; - if (isOnline) { - if (removeNotification) { - removeNotification(); - } - } else { - removeNotification = - this.notificationService.addPermanentNotification( - 'streamChat.Connection failure, reconnecting now...' - ); + let offlineTimeout: ReturnType | undefined; + let isOnline: boolean | undefined; + this.connectionStateCleanup?.(); + // `connection.changed` was removed in stream-chat v10, the socket health is published via `wsConnection.state` (without debounce) + this.connectionStateCleanup = + this.chatClient.wsConnection.state.subscribeWithSelector( + ({ isHealthy }) => ({ isHealthy }), + ({ isHealthy }) => { + // Cancel the pending offline state if the connection recovered within the delay + clearTimeout(offlineTimeout); + if (isHealthy === isOnline) { + return; } - this.connectionStateSubject.next(isOnline ? 'online' : 'offline'); - }); - }) - ); + if (isHealthy) { + isOnline = true; + this.ngZone.run(() => { + removeNotification?.(); + removeNotification = undefined; + this.connectionStateSubject.next('online'); + }); + } else if (isOnline !== undefined) { + offlineTimeout = setTimeout(() => { + isOnline = false; + this.ngZone.run(() => { + removeNotification = + this.notificationService.addPermanentNotification( + 'streamChat.Connection failure, reconnecting now...' + ); + this.connectionStateSubject.next('offline'); + }); + }, this.connectionFailureNotificationDelay); + } + } + ); + this.subscriptions.push({ + unsubscribe: () => { + clearTimeout(offlineTimeout); + removeNotification?.(); + this.connectionStateCleanup?.(); + this.connectionStateCleanup = undefined; + }, + }); return result; } /** - * Disconnects the current user, and closes the WebSocket connection. Useful when disconnecting a chat user, use in combination with [`reset`](/chat/docs/sdk/angular/services/ChannelService/#reset/). + * Disconnects the current user, and closes the WebSocket connection. Useful when disconnecting a chat user, use in combination with [`reset`](/chat/docs/sdk/angular/services/ChannelService/#reset). */ async disconnectUser() { this.pendingInvitesSubject.next([]); await this.chatClient.disconnectUser(); this.userSubject.next(undefined); this.subscriptions.forEach((s) => s.unsubscribe()); + this.subscriptions = []; } /** - * Loads the current [application settings](/chat/docs/javascript/app_setting_overview/), if the application settings have already been loaded, it does nothing. + * Loads the current [application settings](/chat/docs/node/app-setting-overview/), if the application settings have already been loaded, it does nothing. */ async getAppSettings() { if (this.appSettingsPromise) { @@ -200,7 +253,7 @@ export class ChatClientService { this.appSettingsPromise = undefined; }); const settings = await this.appSettingsPromise; - this.appSettingsSubject.next((settings.app as AppSettings) || {}); + this.appSettingsSubject.next(settings.app || {}); } /** @@ -208,7 +261,7 @@ export class ChatClientService { * @param messageId */ async flagMessage(messageId: string) { - await this.chatClient.flagMessage(messageId); + await this.chatClient.moderation.flagMessage({ message_id: messageId }); } /** @@ -221,11 +274,15 @@ export class ChatClientService { return []; } const result = await this.chatClient.queryUsers({ - $or: [ - { id: { $autocomplete: searchTerm } }, - { name: { $autocomplete: searchTerm } }, - ], - } as UserFilters); // TODO: find out why we need this typecast + payload: { + filter_conditions: { + $or: [ + { id: { $autocomplete: searchTerm } }, + { name: { $autocomplete: searchTerm } }, + ], + } as UserFilters, + }, + }); return result.users.filter((u) => u.id !== this.chatClient?.user?.id); } @@ -233,15 +290,19 @@ export class ChatClientService { if (!this.trackPendingChannelInvites) { return; } + if ( + e.type !== 'notification.invited' && + e.type !== 'notification.invite_accepted' && + e.type !== 'notification.invite_rejected' + ) { + return; + } if (e.member?.user?.id === this.chatClient.user?.id && e.channel) { const pendingInvites = this.pendingInvitesSubject.getValue(); if (e.type === 'notification.invited') { const channel = this.chatClient.channel(e.channel?.type, e.channel?.id); this.pendingInvitesSubject.next([...pendingInvites, channel]); - } else if ( - e.type === 'notification.invite_accepted' || - e.type === 'notification.invite_rejected' - ) { + } else { const index = pendingInvites.findIndex( (i) => i?.cid === e.channel?.cid ); @@ -254,60 +315,38 @@ export class ChatClientService { } private updateUser(e: Event) { - if (typeof e.total_unread_count !== 'undefined') { - let user: OwnUserResponse | UserResponse | undefined; - this.userSubject.pipe(take(1)).subscribe((u) => { - user = u; - }); - if ( - user && - 'total_unread_count' in user && - user.total_unread_count !== e.total_unread_count - ) { - this.userSubject.next({ - ...user, - total_unread_count: e.total_unread_count, - }); - } - } - if (typeof e.unread_channels !== 'undefined') { - let user: OwnUserResponse | UserResponse | undefined; - this.userSubject.pipe(take(1)).subscribe((u) => { - user = u; - }); - if ( - user && - 'unread_channels' in user && - user.unread_channels !== e.unread_channels - ) { - this.userSubject.next({ - ...user, - unread_channels: e.unread_channels, - }); + const unreadCounts = e as { + total_unread_count?: number; + unread_channels?: number; + unread_count?: number; + }; + ( + ['total_unread_count', 'unread_channels', 'unread_count'] as const + ).forEach((key) => { + if (typeof unreadCounts[key] === 'undefined') { + return; } - } - if (typeof e.unread_count !== 'undefined') { let user: OwnUserResponse | UserResponse | undefined; this.userSubject.pipe(take(1)).subscribe((u) => { user = u; }); if ( user && - 'unread_count' in user && - user.unread_count !== e.unread_count + key in user && + user[key as keyof typeof user] !== unreadCounts[key] ) { this.userSubject.next({ ...user, - unread_count: e.unread_count, + [key]: unreadCounts[key], }); } - } + }); if ( e.type === 'user.updated' && this.chatClient.user && e.user?.id === this.chatClient.user.id ) { - this.userSubject.next({ ...this.chatClient.user }); + this.userSubject.next({ ...this.chatClient.user } as OwnUserResponse); } } } diff --git a/projects/stream-chat-angular/src/lib/custom-templates.service.ts b/projects/stream-chat-angular/src/lib/custom-templates.service.ts index f1637807..e430a0ab 100644 --- a/projects/stream-chat-angular/src/lib/custom-templates.service.ts +++ b/projects/stream-chat-angular/src/lib/custom-templates.service.ts @@ -64,7 +64,7 @@ export class CustomTemplatesService { TemplateRef | undefined >(undefined); /** - * Template used to display an item in the [channel list](/chat/docs/sdk/angular/components/ChannelListComponent/) (instead of the default [channal list item](/chat/docs/sdk/angular/components/ChannelPreviewComponent/)) + * Template used to display an item in the [channel list](/chat/docs/sdk/angular/components/ChannelListComponent/) (instead of the default [channel list item](/chat/docs/sdk/angular/components/ChannelPreviewComponent/)) * */ channelPreviewTemplate$ = new BehaviorSubject< @@ -78,7 +78,7 @@ export class CustomTemplatesService { TemplateRef | undefined >(undefined); /** - * The template for [emoji picker](/chat/docs/sdk/angular/code-examples/emoji-picker) + * The template for [emoji picker](/chat/docs/sdk/angular/guides/emoji-picker) * */ emojiPickerTemplate$ = new BehaviorSubject< @@ -280,7 +280,7 @@ export class CustomTemplatesService { TemplateRef | undefined >(undefined); /** - * The template used to display [system messages](/chat/docs/javascript/silent_messages/) indise the [message component](/chat/docs/sdk/angular/components/MessageComponent/) + * The template used to display [system messages](/chat/docs/javascript/silent-messages/) indise the [message component](/chat/docs/sdk/angular/components/MessageComponent/) */ systemMessageTemplate$ = new BehaviorSubject< TemplateRef | undefined diff --git a/projects/stream-chat-angular/src/lib/get-channel-display-text.spec.ts b/projects/stream-chat-angular/src/lib/get-channel-display-text.spec.ts index cf006c43..b3914dbc 100644 --- a/projects/stream-chat-angular/src/lib/get-channel-display-text.spec.ts +++ b/projects/stream-chat-angular/src/lib/get-channel-display-text.spec.ts @@ -8,7 +8,7 @@ describe('getChannelDisplayText', () => { const channel = { data: { id: 'hobby-chefs', - name: 'Hobby Chefs🧁🥩🥗🥑🥘', + custom: { name: 'Hobby Chefs🧁🥩🥗🥑🥘' }, members: [{ id: 'hobby-chef1' }, { id: 'hobby-chef2' }], }, } as any as Channel; @@ -27,7 +27,7 @@ describe('getChannelDisplayText', () => { const channel = { data: { id: 'hobby-chefs', - name: undefined, + custom: { name: undefined }, }, state: { members: { @@ -36,7 +36,7 @@ describe('getChannelDisplayText', () => { [currentUser.id]: { user: currentUser }, }, }, - } as Channel; + } as unknown as Channel; expect(getChannelDisplayText(channel, currentUser)).toBe( listUsers(members) @@ -52,7 +52,7 @@ describe('getChannelDisplayText', () => { const channel = { data: { id: 'hobby-chefs', - name: undefined, + custom: { name: undefined }, }, state: { members: { @@ -61,7 +61,7 @@ describe('getChannelDisplayText', () => { [currentUser.id]: { user: currentUser }, }, }, - } as Channel; + } as unknown as Channel; expect(getChannelDisplayText(channel, currentUser)).not.toContain( currentUser.id @@ -74,12 +74,12 @@ describe('getChannelDisplayText', () => { id: 'hobby-chefs', data: { id: 'hobby-chefs', - name: undefined, + custom: { name: undefined }, }, state: { members: {}, }, - } as Channel; + } as unknown as Channel; expect(getChannelDisplayText(channel, currentUser)).toBe('hobby-chefs'); diff --git a/projects/stream-chat-angular/src/lib/get-channel-display-text.ts b/projects/stream-chat-angular/src/lib/get-channel-display-text.ts index 3bdb82ee..d4b0873d 100644 --- a/projects/stream-chat-angular/src/lib/get-channel-display-text.ts +++ b/projects/stream-chat-angular/src/lib/get-channel-display-text.ts @@ -1,12 +1,12 @@ -import { Channel, UserResponse } from 'stream-chat'; +import { Channel } from 'stream-chat'; import { listUsers } from './list-users'; export const getChannelDisplayText = ( channel: Channel, - currentUser: UserResponse + currentUser?: { id?: string } ) => { - if (channel.data?.name) { - return channel.data.name; + if (channel.data?.custom?.name) { + return channel.data.custom.name; } if (channel.state.members && Object.keys(channel.state.members).length > 0) { const members = Object.values(channel.state.members) diff --git a/projects/stream-chat-angular/src/lib/get-message-translation.ts b/projects/stream-chat-angular/src/lib/get-message-translation.ts index ec791a59..c6257914 100644 --- a/projects/stream-chat-angular/src/lib/get-message-translation.ts +++ b/projects/stream-chat-angular/src/lib/get-message-translation.ts @@ -1,20 +1,12 @@ -import { - Channel, - FormatMessageResponse, - MessageResponse, - TranslationLanguages, - UserResponse, -} from 'stream-chat'; +import { Channel, LocalMessage, MessageResponse } from 'stream-chat'; import { StreamMessage } from './types'; export const getMessageTranslation = ( - message?: StreamMessage | MessageResponse | FormatMessageResponse, + message?: StreamMessage | MessageResponse | LocalMessage, channel?: Channel, - user?: UserResponse + user?: { id?: string; language?: string } ) => { - const language = - user?.language || - (channel?.data?.auto_translation_language as TranslationLanguages); + const language = user?.language || channel?.data?.auto_translation_language; if (language && message?.i18n && message?.user?.id !== user?.id) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-base-to-string return message.i18n[`${language}_text`]; diff --git a/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts b/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts index ada73f00..9d27eebb 100644 --- a/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts +++ b/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { CustomTemplatesService } from '../../custom-templates.service'; /** - * The `LoadingInficatorPlaceholder` component displays the [default loading indicator](/chat/docs/sdk/angular/components/LoadingIndicatorComponent/) unless a [custom template](/chat/docs/sdk/angular/services/CustomTemplatesService/) is provided. This component is used by the SDK internally, you likely won't need to use it. + * The `LoadingIndicatorPlaceholder` component displays the [default loading indicator](/chat/docs/sdk/angular/components/LoadingIndicatorComponent/) unless a [custom template](/chat/docs/sdk/angular/services/CustomTemplatesService/) is provided. This component is used by the SDK internally, you likely won't need to use it. */ @Component({ selector: 'stream-loading-indicator-placeholder', diff --git a/projects/stream-chat-angular/src/lib/list-users.ts b/projects/stream-chat-angular/src/lib/list-users.ts index 3700ec80..28bbabc2 100644 --- a/projects/stream-chat-angular/src/lib/list-users.ts +++ b/projects/stream-chat-angular/src/lib/list-users.ts @@ -1,7 +1,5 @@ -import { UserResponse } from 'stream-chat'; - export const listUsers = ( - users: UserResponse[], + users: { id: string; name?: string }[], displayRestCount = true, othersText = '' ) => { diff --git a/projects/stream-chat-angular/src/lib/local-message.ts b/projects/stream-chat-angular/src/lib/local-message.ts new file mode 100644 index 00000000..8bbb8a1c --- /dev/null +++ b/projects/stream-chat-angular/src/lib/local-message.ts @@ -0,0 +1,21 @@ +import { LocalMessage } from 'stream-chat'; +import { StreamMessage } from './types'; + +/** + * Removes the SDK specific fields from a `StreamMessage`, so it can be handed back to stream-chat + * @param message + * @returns the message in the `LocalMessage` format stream-chat uses + */ +export const toLocalMessage = ( + message: StreamMessage | LocalMessage +): LocalMessage => { + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + readBy: _readBy, + translation: _translation, + errorStatusCode: _errorStatusCode, + ...rest + } = message as Partial & LocalMessage; + /* eslint-enable @typescript-eslint/no-unused-vars */ + return rest; +}; diff --git a/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.spec.ts b/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.spec.ts index f27161a3..d6470f99 100644 --- a/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.spec.ts @@ -43,6 +43,7 @@ describe('MessageActionsBoxComponent', () => { let message: StreamMessage; let nativeElement: HTMLElement; let mockChatClient: MockStreamChatClient; + let flagMessageSpy: jasmine.Spy; let channelService: { updateMessage: jasmine.Spy; activeChannel$: Observable; @@ -57,6 +58,7 @@ describe('MessageActionsBoxComponent', () => { beforeEach(async () => { mockChatClient = mockStreamChatClient(); + flagMessageSpy = jasmine.createSpy('flagMessage'); channelService = { updateMessage: jasmine.createSpy(), activeChannel$: new BehaviorSubject(generateMockChannels(1)[0]), @@ -75,7 +77,10 @@ describe('MessageActionsBoxComponent', () => { MessageReactionsSelectorComponent, ], providers: [ - { provide: ChatClientService, useValue: mockChatClient }, + { + provide: ChatClientService, + useValue: { ...mockChatClient, flagMessage: flagMessageSpy }, + }, { provide: ChannelService, useValue: channelService }, { provide: textareaInjectionToken, @@ -248,7 +253,7 @@ describe('MessageActionsBoxComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(mockChatClient.flagMessage).toHaveBeenCalledWith(message.id); + expect(flagMessageSpy).toHaveBeenCalledWith(message.id); expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( 'streamChat.Message has been successfully flagged', 'success' @@ -258,7 +263,7 @@ describe('MessageActionsBoxComponent', () => { it(`should display error message, if flag action wasn't successful`, async () => { const notificationService = TestBed.inject(NotificationService); spyOn(notificationService, 'addTemporaryNotification'); - mockChatClient.flagMessage.and.rejectWith(); + flagMessageSpy.and.rejectWith(); component.enabledActions = ['flag-message']; component.ngOnChanges({ enabledActions: {} as SimpleChange }); fixture.detectChanges(); @@ -267,7 +272,7 @@ describe('MessageActionsBoxComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(mockChatClient.flagMessage).toHaveBeenCalledWith(message.id); + expect(flagMessageSpy).toHaveBeenCalledWith(message.id); expect(notificationService.addTemporaryNotification).toHaveBeenCalledWith( 'streamChat.Error adding flag' ); diff --git a/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.ts b/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.ts index 3006b79a..415bbe58 100644 --- a/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.ts +++ b/projects/stream-chat-angular/src/lib/message-actions-box/message-actions-box.component.ts @@ -44,7 +44,7 @@ export class MessageActionsBoxComponent */ @Input() messageTextHtmlElement: HTMLElement | undefined; /** - * The list of [channel capabilities](/chat/docs/javascript/channel_capabilities/) that are enabled for the current user, the list of [supported interactions](/chat/docs/sdk/angular/concepts/message-interactions) can be found in our message interaction guide. Unathorized actions won't be displayed on the UI. + * The list of [channel capabilities](/chat/docs/node/chat-permission-policies/) that are enabled for the current user, the list of [supported interactions](/chat/docs/sdk/angular/concepts/message-interactions) can be found in our message interaction guide. Unathorized actions won't be displayed on the UI. */ @Input() enabledActions: string[] = []; messageActionItemTemplate: diff --git a/projects/stream-chat-angular/src/lib/message-bounce-prompt/message-bounce-prompt.component.ts b/projects/stream-chat-angular/src/lib/message-bounce-prompt/message-bounce-prompt.component.ts index 0f73e40d..ca34c45c 100644 --- a/projects/stream-chat-angular/src/lib/message-bounce-prompt/message-bounce-prompt.component.ts +++ b/projects/stream-chat-angular/src/lib/message-bounce-prompt/message-bounce-prompt.component.ts @@ -8,7 +8,7 @@ import { StreamMessage } from '../types'; /** * The component watches for the [`channelService.bouncedMessage$` stream](/chat/docs/sdk/angular/services/ChannelService/#bouncedmessage) and opens the bounce modal if a message is emitted. * - * To bounce messages, you need to set up [semantic filters for moderation](https://getstream.io/automated-moderation/docs/automod_configuration/?q=semantic%20filters). + * To bounce messages, you need to set up [semantic filters for moderation](/moderation/docs/node/configuration/policies/). */ @Component({ selector: 'stream-message-bounce-prompt', diff --git a/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.spec.ts index 9af2f0f6..326101ca 100644 --- a/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.spec.ts @@ -7,7 +7,7 @@ import { } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { MentionModule } from '@stream-io/angular-mentions'; -import { Channel } from 'stream-chat'; +import { Channel, ChannelMemberResponse, UserResponse } from 'stream-chat'; import { ChatClientService } from '../../chat-client.service'; import { ChannelService } from '../../channel.service'; import { mockChannelService, MockChannelService } from '../../mocks'; @@ -195,16 +195,20 @@ describe('AutocompleteTextareaComponent', () => { it('should add channel members to autocomplete config', () => { expect(component.autocompleteConfig.mentions![0].items).toEqual([ { - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, autocompleteLabel: 'Jack', type: 'mention', }, { - user: { id: 'sara', name: 'Sara' }, + user: { id: 'sara', name: 'Sara' } as UserResponse, autocompleteLabel: 'Sara', type: 'mention', }, - { user: { id: 'eddie' }, autocompleteLabel: 'eddie', type: 'mention' }, + { + user: { id: 'eddie' } as UserResponse, + autocompleteLabel: 'eddie', + type: 'mention', + }, ]); }); @@ -212,12 +216,10 @@ describe('AutocompleteTextareaComponent', () => { const userMentionSpy = jasmine.createSpy(); component.userMentions.subscribe(userMentionSpy); spyOn(channelServiceMock, 'autocompleteMembers').and.returnValue([ - { user: { id: 'sophie' } }, + { user: { id: 'sophie' } as UserResponse } as ChannelMemberResponse, ]); channelServiceMock.activeChannel$.next({ - getConfig: () => ({ - commands: [], - }), + config: { availableCommands: [] }, } as any as Channel); fixture.detectChanges(); await fixture.whenStable(); @@ -231,7 +233,7 @@ describe('AutocompleteTextareaComponent', () => { component.userMentions.subscribe(userMentionSpy); component.autocompleteConfig.mentions![0].mentionSelect!( { - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, }, '@' ); @@ -252,7 +254,7 @@ describe('AutocompleteTextareaComponent', () => { expect( component.autocompleteConfig.mentions![0].mentionSelect!( { - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, autocompleteLabel: 'Jack', }, '@' @@ -265,7 +267,7 @@ describe('AutocompleteTextareaComponent', () => { component.userMentions.subscribe(userMentionSpy); component.autocompleteConfig.mentions![0].mentionSelect!( { - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, }, '@' ); @@ -289,7 +291,7 @@ describe('AutocompleteTextareaComponent', () => { component.userMentions.subscribe(userMentionSpy); component.autocompleteConfig.mentions![0].mentionSelect!( { - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, }, '@' ); @@ -351,8 +353,12 @@ describe('AutocompleteTextareaComponent', () => { it('should filter autocomplete options', fakeAsync(() => { spyOn(channelServiceMock, 'autocompleteMembers').and.returnValue([ - { user: { id: 'dom13re4ty', name: 'Jeff' } }, - { user: { id: '3sfwer232', name: 'Dominique' } }, + { + user: { id: 'dom13re4ty', name: 'Jeff' } as UserResponse, + } as ChannelMemberResponse, + { + user: { id: '3sfwer232', name: 'Dominique' } as UserResponse, + } as ChannelMemberResponse, ]); component.autcompleteSearchTermChanged('@dom'); tick(300); @@ -362,7 +368,9 @@ describe('AutocompleteTextareaComponent', () => { it('should transliterate - option contains non-Latin characters', fakeAsync(() => { spyOn(channelServiceMock, 'autocompleteMembers').and.returnValue([ - { user: { id: '12', name: 'Ádám' } }, + { + user: { id: '12', name: 'Ádám' } as UserResponse, + } as ChannelMemberResponse, ]); component.autcompleteSearchTermChanged('@Adam'); tick(300); @@ -372,7 +380,9 @@ describe('AutocompleteTextareaComponent', () => { it('should transliterate - search term contains non-Latin characters', fakeAsync(() => { spyOn(channelServiceMock, 'autocompleteMembers').and.returnValue([ - { user: { id: '12', name: 'Adam' } }, + { + user: { id: '12', name: 'Adam' } as UserResponse, + } as ChannelMemberResponse, ]); component.autcompleteSearchTermChanged('@Ádám'); tick(300); diff --git a/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.ts b/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.ts index 1f4324c2..b21e6497 100644 --- a/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.ts +++ b/projects/stream-chat-angular/src/lib/message-input/autocomplete-textarea/autocomplete-textarea.component.ts @@ -52,7 +52,7 @@ export class AutocompleteTextareaComponent */ @Input() placeholder = ''; /** - * If true, users can mention other users in messages. You can also set this input on the [`MessageInput`](/chat/docs/sdk/angular/components/MessageInputComponent/#inputs-and-outputs/) component. + * If true, users can mention other users in messages. You can also set this input on the [`MessageInput`](/chat/docs/sdk/angular/components/MessageInputComponent/#inputs-and-outputs) component. */ @Input() areMentionsEnabled: boolean | undefined = true; /** @@ -155,7 +155,7 @@ export class AutocompleteTextareaComponent }); this.subscriptions.push( this.channelService.activeChannel$.subscribe((channel) => { - const commands = channel?.getConfig()?.commands || []; + const commands = channel?.config.availableCommands || []; this.slashCommandConfig.items = commands.map((c) => ({ ...c, [this.autocompleteKey]: c.name, @@ -253,8 +253,7 @@ export class AutocompleteTextareaComponent } if (changes.value && !this.value && this.messageInput) { this.messageInput.nativeElement.style.height = 'auto'; - this.chatClientService?.chatClient?.logger?.( - 'info', + this.chatClientService?.logger?.info( '[Autocomplete textarea] Value reset, adjusting textarea height to auto' ); this.updateMentionedUsersFromText(); @@ -264,8 +263,7 @@ export class AutocompleteTextareaComponent this.messageInput && this.isViewInited ) { - this.chatClientService?.chatClient?.logger?.( - 'info', + this.chatClientService?.logger?.info( '[Autocomplete textarea] Value changed' ); setTimeout(() => { @@ -278,10 +276,7 @@ export class AutocompleteTextareaComponent ngAfterViewInit(): void { this.isViewInited = true; - this.chatClientService?.chatClient?.logger?.( - 'info', - '[Autocomplete textarea] View inited' - ); + this.chatClientService?.logger?.info('[Autocomplete textarea] View inited'); if (this.messageInput.nativeElement.scrollHeight > 0 && this.value) { this.adjustTextareaHeight(); } @@ -321,8 +316,7 @@ export class AutocompleteTextareaComponent } inputChanged() { - this.chatClientService?.chatClient?.logger?.( - 'info', + this.chatClientService?.logger?.info( '[Autocomplete textarea] Input changed' ); this.valueChange.emit(this.messageInput.nativeElement.value); @@ -342,8 +336,7 @@ export class AutocompleteTextareaComponent } private adjustTextareaHeight() { - this.chatClientService?.chatClient?.logger?.( - 'info', + this.chatClientService?.logger?.info( `[Autocomplete textarea] Textarea height set from ${this.messageInput.nativeElement.style.height} to ${this.messageInput.nativeElement.scrollHeight}px` ); this.messageInput.nativeElement.style.height = ''; diff --git a/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts b/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts index dd3ac0be..b72479a1 100644 --- a/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts +++ b/projects/stream-chat-angular/src/lib/message-input/emoji-input.service.ts @@ -2,14 +2,14 @@ import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; /** - * If you have an emoji picker in your application, you can propagate the selected emoji to the textarea using this service, more info can be found in [custom emoji picker guide](/chat/docs/sdk/angular/code-examples/emoji-picker/) + * If you have an emoji picker in your application, you can propagate the selected emoji to the textarea using this service, more info can be found in [custom emoji picker guide](/chat/docs/sdk/angular/guides/emoji-picker/) */ @Injectable({ providedIn: 'root', }) export class EmojiInputService { /** - * If you have an emoji picker in your application, you can propagate the selected emoji to the textarea using this Subject, more info can be found in [custom emoji picker guide](/chat/docs/sdk/angular/code-examples/emoji-picker/) + * If you have an emoji picker in your application, you can propagate the selected emoji to the textarea using this Subject, more info can be found in [custom emoji picker guide](/chat/docs/sdk/angular/guides/emoji-picker/) */ emojiInput$ = new Subject(); diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts b/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts index 1d1734d6..a919d87f 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts @@ -11,7 +11,7 @@ import { CustomAutocomplete } from '../types'; }) export class MessageInputConfigService { /** - * If file upload is enabled, the user can open a file selector from the input. Please note that the user also needs to have the necessary [channel capability](/chat/docs/javascript/channel_capabilities/). + * If file upload is enabled, the user can open a file selector from the input. Please note that the user also needs to have the necessary [channel capability](/chat/docs/node/chat-permission-policies/). */ isFileUploadEnabled: boolean | undefined = true; /** diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index 9bb75abe..f67e8153 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -13,6 +13,7 @@ import { BehaviorSubject, Subject, of } from 'rxjs'; import { Attachment, Channel, + dateToNs, DraftMessage, MessageResponse, UserResponse, @@ -306,7 +307,7 @@ describe('MessageInputComponent', () => { const message = 'This is my message'; component.textareaValue = message; attachmentService.mapToAttachments.and.returnValue([]); - const mentionedUsers = [{ id: 'john', name: 'John' }]; + const mentionedUsers = [{ id: 'john', name: 'John' }] as UserResponse[]; component.mentionedUsers = mentionedUsers; await component.messageSent(); fixture.detectChanges(); @@ -513,7 +514,7 @@ describe('MessageInputComponent', () => { ]); component.textareaValue = 'text'; mockActiveChannel$.next({ - getConfig: () => ({ commands: [] }), + config: { availableCommands: [] }, } as any as Channel); fixture.detectChanges(); @@ -532,7 +533,7 @@ describe('MessageInputComponent', () => { component.textareaValue = 'text'; mockActiveChannel$.next({ ...mockActiveChannel$.getValue(), - getConfig: () => ({ commands: [] }), + config: { availableCommands: [] }, } as any as Channel); fixture.detectChanges(); @@ -549,13 +550,18 @@ describe('MessageInputComponent', () => { }); it('should display attachments of #message', () => { - const attachments = [ - { fallback: 'flower.png', image_url: 'http://url/to/img', type: 'image' }, + const attachments: Attachment[] = [ + { + fallback: 'flower.png', + image_url: 'http://url/to/img', + type: 'image', + custom: {}, + }, { title: 'note.txt', - file_size: 3272969, asset_url: 'http://url/to/data', type: 'file', + custom: { file_size: 3272969 }, }, ]; component.message = { ...mockMessage(), attachments }; @@ -613,7 +619,7 @@ describe('MessageInputComponent', () => { mockActiveChannel$.next({ data: { own_capabilities: [] }, - getConfig: () => ({ commands: [] }), + config: { availableCommands: [] }, } as any as Channel); fixture.detectChanges(); await fixture.whenStable(); @@ -680,14 +686,14 @@ describe('MessageInputComponent', () => { mockActiveChannel$.next({ data: { own_capabilities: [] }, - getConfig: () => ({ commands: [] }), + config: { availableCommands: [] }, } as any as Channel); expect(component.canSendMessages).toBeFalse(); mockActiveChannel$.next({ data: { own_capabilities: [] }, - getConfig: () => ({ commands: [] }), + config: { availableCommands: [] }, } as any as Channel); expect(component.canSendMessages).toBeFalse(); @@ -744,7 +750,7 @@ describe('MessageInputComponent', () => { mockActiveChannel$.next({ data: { own_capabilities: ['send-message'] }, - getConfig: () => ({ commands: [] }), + config: { availableCommands: [] }, } as any as Channel); expect(component.canSendMessages).toBeFalse(); @@ -778,7 +784,10 @@ describe('MessageInputComponent', () => { const quotedMessageContainerSelector = '[data-testid="quoted-message-container"]'; const message = mockMessage(); - message.attachments = [{ image_url: '1' }, { image_url: '2' }]; + message.attachments = [ + { image_url: '1', custom: {} }, + { image_url: '2', custom: {} }, + ]; mockMessageToQuote$.next(message); fixture.detectChanges(); @@ -794,12 +803,12 @@ describe('MessageInputComponent', () => { .query(By.directive(AttachmentListComponent)) .componentInstance as AttachmentListComponent; - expect(avatar.name).toBe(message.user!.name); - expect(avatar.imageUrl).toBe(message.user!.image); + expect(avatar.name).toBe(message.user.name); + expect(avatar.imageUrl).toBe(message.user.image); expect(avatar.type).toBe('user'); expect(avatar.location).toBe('quoted-message-sender'); - expect(avatar.user).toBe(message.user!); - expect(attachments.attachments).toEqual([{ image_url: '1' }]); + expect(avatar.user).toBe(message.user); + expect(attachments.attachments).toEqual([{ image_url: '1', custom: {} }]); const textComponent = fixture.debugElement .query(By.css(quotedMessageContainerSelector)) @@ -1084,7 +1093,7 @@ describe('MessageInputComponent', () => { expect(component['messageToUpdateChanged']).toHaveBeenCalledWith(); expect(component.message).toBe(message); - expect(component.textareaValue).toBe(message.text!); + expect(component.textareaValue).toBe(message.text); service.messageToEdit$.next(undefined); @@ -1195,7 +1204,9 @@ describe('MessageInputComponent', () => { const messageDraftSpy = jasmine.createSpy(); component.messageDraftChange.subscribe(messageDraftSpy); messageDraftSpy.calls.reset(); - queryTextarea()?.userMentions.next([{ id: 'user1', name: 'User 1' }]); + queryTextarea()?.userMentions.next([ + { id: 'user1', name: 'User 1' } as UserResponse, + ]); fixture.detectChanges(); expect(messageDraftSpy).toHaveBeenCalledWith( @@ -1261,9 +1272,10 @@ describe('MessageInputComponent', () => { const messageDraftSpy = jasmine.createSpy(); component.messageDraftChange.subscribe(messageDraftSpy); messageDraftSpy.calls.reset(); - const customAttachment = { + const customAttachment: Attachment = { type: 'image', image_url: 'url', + custom: {}, }; attachmentService.mapToAttachments.and.returnValue([customAttachment]); attachmentService.customAttachments$.next([customAttachment]); @@ -1367,7 +1379,7 @@ describe('MessageInputComponent', () => { text: 'Hello, world!', } as any as DraftMessage, channel_cid: `not${channel.cid}`, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), }); fixture.detectChanges(); @@ -1385,7 +1397,7 @@ describe('MessageInputComponent', () => { parent_id: 'not' + parentMessageId, } as any as DraftMessage, channel_cid: `not${channel.cid}`, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), }); fixture.detectChanges(); @@ -1404,7 +1416,7 @@ describe('MessageInputComponent', () => { text: 'Hello, world!', } as any as DraftMessage, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), }); fixture.detectChanges(); @@ -1422,7 +1434,7 @@ describe('MessageInputComponent', () => { text: 'Hello, world!', } as any as DraftMessage, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), quoted_message: mockQuotedMessage as any as MessageResponse, }); fixture.detectChanges(); @@ -1441,7 +1453,7 @@ describe('MessageInputComponent', () => { text: 'Hello, world!', } as any as DraftMessage, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), quoted_message: undefined, }); fixture.detectChanges(); @@ -1454,12 +1466,12 @@ describe('MessageInputComponent', () => { const draft = { message: { text: 'Hello, world!', - mentioned_users: ['user1', 'user2'], + mentioned_users: [{ id: 'user1' }, { id: 'user2' }], poll_id: 'poll1', attachments: [{ type: 'file', url: 'url' }], } as any as DraftMessage, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), }; attachmentService.createFromAttachments.calls.reset(); @@ -1470,7 +1482,7 @@ describe('MessageInputComponent', () => { expect(component.mentionedUsers).toEqual([ { id: 'user1' }, { id: 'user2' }, - ]); + ] as UserResponse[]); expect(component['pollId']).toBe('poll1'); expect(attachmentService.createFromAttachments).toHaveBeenCalledOnceWith([ { type: 'file', url: 'url' }, @@ -1492,12 +1504,12 @@ describe('MessageInputComponent', () => { const draft = { message: { text: 'Hello, world!', - mentioned_users: ['user1', 'user2'], + mentioned_users: [{ id: 'user1' }, { id: 'user2' }], poll_id: 'poll1', attachments: [{ type: 'file', url: 'url' }], } as any as DraftMessage, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: dateToNs(new Date()), }; const spy = jasmine.createSpy(); component.messageDraftChange.subscribe(spy); diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts index 0edcea2b..4e9c8364 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts @@ -24,8 +24,8 @@ import { distinctUntilChanged, first, map, take, tap } from 'rxjs/operators'; import { Attachment, Channel, - DraftMessagePayload, DraftResponse, + MessageRequest, UserResponse, } from 'stream-chat'; import { AttachmentService } from '../attachment.service'; @@ -52,7 +52,7 @@ import { VoiceRecorderService } from './voice-recorder.service'; import { AudioRecorderService } from '../voice-recorder/audio-recorder.service'; /** - * The `MessageInput` component displays an input where users can type their messages and upload files, and sends the message to the active channel. The component can be used to compose new messages or update existing ones. To send messages, the chat user needs to have the necessary [channel capability](/chat/docs/javascript/channel_capabilities/). + * The `MessageInput` component displays an input where users can type their messages and upload files, and sends the message to the active channel. The component can be used to compose new messages or update existing ones. To send messages, the chat user needs to have the necessary [channel capability](/chat/docs/node/chat-permission-policies/). */ @Component({ selector: 'stream-message-input', @@ -64,7 +64,7 @@ export class MessageInputComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { /** - * If file upload is enabled, the user can open a file selector from the input. Please note that the user also needs to have the necessary [channel capability](/chat/docs/javascript/channel_capabilities/). If no value is provided, it is set from the [`MessageInputConfigService`](/chat/docs/sdk/angular/services/MessageInputConfigService/). + * If file upload is enabled, the user can open a file selector from the input. Please note that the user also needs to have the necessary [channel capability](/chat/docs/node/chat-permission-policies/). If no value is provided, it is set from the [`MessageInputConfigService`](/chat/docs/sdk/angular/services/MessageInputConfigService/). */ @Input() isFileUploadEnabled: boolean | undefined; /** @@ -128,7 +128,7 @@ export class MessageInputComponent * - If the user clears the message input, or sends the message, undefined is emitted. * - If active channel changes, nothing is emitted. * - * To save and fetch message drafts, you can use the [Stream message drafts API](https://getstream.io/chat/docs/javascript/drafts/). + * To save and fetch message drafts, you can use the [Stream message drafts API](/chat/docs/javascript/drafts/). * * Message draft only works for new messages, nothing is emitted when input is in edit mode (if `message` input is set). * @@ -137,7 +137,7 @@ export class MessageInputComponent * - If a thread message is loaded, and the input isn't in thread mode or parent ids don't match, the draft is ignored. */ @Output() readonly messageDraftChange = new EventEmitter< - DraftMessagePayload | undefined + MessageRequest | undefined >(); @ContentChild(TemplateRef) voiceRecorderRef: | TemplateRef<{ service: VoiceRecorderService }> @@ -666,8 +666,7 @@ export class MessageInputComponent this.isLoadingDraft = true; this.textareaValue = draft.message?.text || ''; - this.mentionedUsers = - draft?.message?.mentioned_users?.map((id) => ({ id })) || []; + this.mentionedUsers = draft?.message?.mentioned_users || []; this.pollId = draft?.message?.poll_id; this.attachmentService.resetAttachmentUploads(); this.attachmentService.createFromAttachments( diff --git a/projects/stream-chat-angular/src/lib/message-list/group-styles.spec.ts b/projects/stream-chat-angular/src/lib/message-list/group-styles.spec.ts index cdf6af90..998bf14d 100644 --- a/projects/stream-chat-angular/src/lib/message-list/group-styles.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-list/group-styles.spec.ts @@ -1,3 +1,4 @@ +import { dateToNs, nsToDate } from 'stream-chat'; import { mockMessage } from '../mocks'; import { StreamMessage } from '../types'; import { getGroupStyles } from './group-styles'; @@ -44,19 +45,23 @@ describe('getGroupStyles', () => { }); it('should mark messages with attachements as "single"', () => { - messages[0].attachments = [{}, {}]; + messages[0].attachments = [{ custom: {} }, { custom: {} }]; expect(getGroupStyles(messages[0], undefined, messages[1])).toBe('single'); }); it('should mark first message of the day as "top"', () => { - messages[0].created_at.setDate(messages[0].created_at.getDate() - 1); + const createdAt = nsToDate(messages[0].created_at); + createdAt.setDate(createdAt.getDate() - 1); + messages[0].created_at = dateToNs(createdAt); expect(getGroupStyles(messages[1], messages[0], messages[2])).toBe('top'); }); it('should mark last message of the day as "bottom"', () => { - messages[2].created_at.setDate(messages[0].created_at.getDate() + 1); + const createdAt = nsToDate(messages[2].created_at); + createdAt.setDate(nsToDate(messages[0].created_at).getDate() + 1); + messages[2].created_at = dateToNs(createdAt); expect(getGroupStyles(messages[1], messages[0], messages[2])).toBe( 'bottom' @@ -78,7 +83,7 @@ describe('getGroupStyles', () => { }); it('should handle deleted messages', () => { - messages[2].deleted_at = new Date(); + messages[2].deleted_at = dateToNs(new Date()); expect(getGroupStyles(messages[1], messages[0], messages[2])).toBe( 'bottom' @@ -92,8 +97,8 @@ describe('getGroupStyles', () => { }); it(`shouldn't group together messages with different authors `, () => { - messages[0].user!.id = 'not' + messages[0].user!.id; - messages[4].user!.id = 'not' + messages[4].user!.id; + messages[0].user.id = 'not' + messages[0].user.id; + messages[4].user.id = 'not' + messages[4].user.id; expect(getGroupStyles(messages[1], messages[0], messages[2])).toBe('top'); @@ -149,7 +154,7 @@ describe('getGroupStyles', () => { }); it('should end group if the message id edited', () => { - messages[1].message_text_updated_at = new Date().toISOString(); + messages[1].message_text_updated_at = dateToNs(new Date()); expect(getGroupStyles(messages[1], messages[0], messages[2])).toBe( 'bottom' diff --git a/projects/stream-chat-angular/src/lib/message-list/group-styles.ts b/projects/stream-chat-angular/src/lib/message-list/group-styles.ts index 49d85e37..e4a853c4 100644 --- a/projects/stream-chat-angular/src/lib/message-list/group-styles.ts +++ b/projects/stream-chat-angular/src/lib/message-list/group-styles.ts @@ -1,3 +1,4 @@ +import { nsToDate, TimestampNS } from 'stream-chat'; import { GroupStyleOptions, StreamMessage } from '../types'; export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single'; @@ -62,7 +63,9 @@ export const getGroupStyles = ( return ''; }; -const isOnSameDay = (date1: Date, date2: Date) => { +const isOnSameDay = (timestamp1: TimestampNS, timestamp2: TimestampNS) => { + const date1 = nsToDate(timestamp1); + const date2 = nsToDate(timestamp2); return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.html b/projects/stream-chat-angular/src/lib/message-list/message-list.component.html index 0e7c66bf..a4fe89ab 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.html +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.html @@ -108,7 +108,7 @@ *ngTemplateOutlet=" dateSeparator; context: { - date: message.created_at, + date: toDate(message.created_at), parsedDate: parseDate(message.created_at) } " @@ -155,7 +155,7 @@ *ngTemplateOutlet=" dateSeparator; context: { - date: messages[i + 1].created_at, + date: toDate(messages[i + 1].created_at), parsedDate: parseDate(messages[i + 1].created_at) } " diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts b/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts index a1b98769..a3cc7e88 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.spec.ts @@ -8,7 +8,7 @@ import { import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, of } from 'rxjs'; -import { Channel } from 'stream-chat'; +import { Channel, dateToNs, nsToDate, UserResponse } from 'stream-chat'; import { AvatarPlaceholderComponent } from '../avatar-placeholder/avatar-placeholder.component'; import { AvatarComponent } from '../avatar/avatar.component'; import { ChannelService } from '../channel.service'; @@ -21,6 +21,7 @@ import { MockChannelService, mockChannelService, mockCurrentUser, + MockMessagePaginator, mockMessage, } from '../mocks'; import { StreamI18nService } from '../stream-i18n.service'; @@ -28,6 +29,9 @@ import { MessageListComponent } from './message-list.component'; import { take } from 'rxjs/operators'; import { NgxFloatUiModule } from 'ngx-float-ui'; +const getPaginator = (channel: Channel | undefined) => + channel!.messagePaginator as unknown as MockMessagePaginator; + describe('MessageListComponent', () => { let component: MessageListComponent; let fixture: ComponentFixture; @@ -48,6 +52,13 @@ describe('MessageListComponent', () => { beforeEach(() => { channelServiceMock = mockChannelService(); + // In ChannelService these getters return the latest value of the message streams + Object.defineProperty(channelServiceMock, 'activeChannelMessages', { + get: () => channelServiceMock.activeChannelMessages$.getValue(), + }); + Object.defineProperty(channelServiceMock, 'activeChannelThreadReplies', { + get: () => channelServiceMock.activeThreadMessages$.getValue(), + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NgxFloatUiModule], declarations: [ @@ -117,7 +128,7 @@ describe('MessageListComponent', () => { it('should display messages', () => { const messages = channelServiceMock.activeChannelMessages$.getValue(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; component.highlightedMessageId = messages[0].id; channelServiceMock.activeChannelMessages$.next([...messages]); component.ngOnChanges({ @@ -232,14 +243,13 @@ describe('MessageListComponent', () => { ); const newMessage = mockMessage(); - newMessage.created_at = new Date(); + newMessage.created_at = dateToNs(new Date()); (channelServiceMock.activeChannel as MockChannel).handleEvent( 'message.new', { message: newMessage, } ); - channelServiceMock.activeChannel?.state.messages.push(newMessage); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -263,7 +273,7 @@ describe('MessageListComponent', () => { component.ngOnChanges({ direction: {} as SimpleChange }); fixture.detectChanges(); const newMessage = mockMessage(); - newMessage.created_at = new Date(); + newMessage.created_at = dateToNs(new Date()); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -354,7 +364,6 @@ describe('MessageListComponent', () => { id: 'nextchannel', on: () => {}, } as any as Channel); - channelServiceMock.activeChannel!.state.latestMessages = []; channelServiceMock.activeChannelMessages$.next([]); fixture.detectChanges(); @@ -414,30 +423,36 @@ describe('MessageListComponent', () => { expect(component.highlightedMessageId).toBeUndefined(); })); - it('should wait with scrolling until message has been rendered', () => { + it('should render the new message window before scrolling to the message', () => { const newMessages = generateMockMessages(); const message = newMessages[Math.round(newMessages.length / 2)]; message.id = 'new-message-id'; + const scrollIntoViewSpy = spyOn( + HTMLElement.prototype, + 'scrollIntoView' + ).and.callThrough(); // Simulates the scenario when jumping requires a new message set to be loaded channelServiceMock.activeChannelMessages$.next(newMessages); channelServiceMock.jumpToMessage$.next({ id: message.id, parentId: undefined, }); - // fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); - expect(nativeElement.querySelector(`#${message.id}`)).toBeNull(); + const messageElement = nativeElement.querySelector(`#${message.id}`); - fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); - - expect(nativeElement.querySelector(`#${message.id}`)).not.toBeNull(); + expect(messageElement).not.toBeNull(); + expect(scrollIntoViewSpy.calls.mostRecent()?.object).toBe(messageElement); }); it('should set #isUserScrolled to true when not the latest message set is displayed', () => { const newMessages = generateMockMessages(25, true); // Replace the current messages with a compelitely new set newMessages.forEach((m) => { - m.cid = channelServiceMock.activeChannel?.cid; + m.cid = channelServiceMock.activeChannel!.cid; + }); + // There are newer messages to load + getPaginator(channelServiceMock.activeChannel).state.partialNext({ + hasMoreHead: true, }); channelServiceMock.activeChannelMessages$.next(newMessages); fixture.detectChanges(); @@ -515,7 +530,9 @@ describe('MessageListComponent', () => { .pipe(take(1)) .subscribe((c) => (channel = c!)); // Simulate message set change - channel.state.latestMessages = []; + getPaginator(channel).state.partialNext({ + hasMoreHead: true, + }); channelServiceMock.activeChannelMessages$.next( generateMockMessages(25, true).map((m) => ({ ...m, cid: channel.cid })) ); @@ -528,7 +545,7 @@ describe('MessageListComponent', () => { fixture.detectChanges(); const newMessageFromOtherUser = mockMessage(); - newMessageFromOtherUser.user!.id += 'not'; + newMessageFromOtherUser.user.id += 'not'; (channel as MockChannel).handleEvent('message.new', { message: newMessageFromOtherUser, }); @@ -537,7 +554,7 @@ describe('MessageListComponent', () => { spyOn(channelServiceMock, 'jumpToMessage').and.callThrough(); const newMessageFromCurrentUser = mockMessage(); - newMessageFromCurrentUser.created_at = new Date(); + newMessageFromCurrentUser.created_at = dateToNs(new Date()); (channel as MockChannel).handleEvent('message.new', { message: newMessageFromCurrentUser, }); @@ -558,8 +575,8 @@ describe('MessageListComponent', () => { scrollContainer.dispatchEvent(new Event('scroll')); fixture.detectChanges(); const newMessage = mockMessage(); - newMessage.created_at = new Date(); - newMessage.user!.id = 'not' + mockCurrentUser().id; + newMessage.created_at = dateToNs(new Date()); + newMessage.user.id = 'not' + mockCurrentUser().id; channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -581,13 +598,12 @@ describe('MessageListComponent', () => { scrollContainer.dispatchEvent(new Event('scroll')); fixture.detectChanges(); const newMessage = mockMessage(); - newMessage.created_at = new Date(); + newMessage.created_at = dateToNs(new Date()); newMessage.user_id = 'not' + mockCurrentUser().id; (channelServiceMock.activeChannel as MockChannel).handleEvent( 'message.new', { message: newMessage } ); - channelServiceMock.activeChannel?.state.messages.push(newMessage); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -607,14 +623,13 @@ describe('MessageListComponent', () => { ); const newMessage = mockMessage(); - newMessage.created_at = new Date(); + newMessage.created_at = dateToNs(new Date()); (channelServiceMock.activeChannel as MockChannel).handleEvent( 'message.new', { message: newMessage, } ); - channelServiceMock.activeChannel?.state.messages.push(newMessage); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -667,8 +682,8 @@ describe('MessageListComponent', () => { it(`shouldn't scroll up for new messages`, () => { const scrollContainer = queryScrollContainer()!; const newMessage = mockMessage(); - newMessage.created_at = new Date(); - newMessage.user!.id = 'not' + mockCurrentUser().id; + newMessage.created_at = dateToNs(new Date()); + newMessage.user.id = 'not' + mockCurrentUser().id; channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -680,7 +695,7 @@ describe('MessageListComponent', () => { it('should display unread message count if direction is top-to-bottom', () => { const newMessage = mockMessage(); - newMessage.created_at = new Date(); + newMessage.created_at = dateToNs(new Date()); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -708,7 +723,7 @@ describe('MessageListComponent', () => { it('should scroll up if user sends new message', () => { const scrollContainer = queryScrollContainer()!; const newMessage = mockMessage(); - newMessage.created_at = new Date(); + newMessage.created_at = dateToNs(new Date()); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -745,7 +760,7 @@ describe('MessageListComponent', () => { channelServiceMock.usersTypingInChannel$.next([ { id: 'jack' }, { id: 'john', name: 'John', image: 'http://url/to/img' }, - ]); + ] as UserResponse[]); fixture.detectChanges(); expect(queryTypingIndicator()).not.toBeNull(); @@ -889,14 +904,16 @@ describe('MessageListComponent', () => { }); it('should display typing indicator in thread', () => { - channelServiceMock.usersTypingInChannel$.next([{ id: 'sara' }]); + channelServiceMock.usersTypingInChannel$.next([ + { id: 'sara' } as UserResponse, + ]); expect(queryTypingIndicator()).toBeNull(); channelServiceMock.usersTypingInThread$.next([ { id: 'jack' }, { id: 'john', name: 'John' }, - ]); + ] as UserResponse[]); fixture.detectChanges(); expect(queryTypingIndicator()).not.toBeNull(); @@ -1005,26 +1022,26 @@ describe('MessageListComponent', () => { const messages = generateMockMessages(); const message = messages[0]; const nextMessage = messages[1]; - message.created_at = new Date(); + message.created_at = dateToNs(new Date()); expect(component['checkIfOnSeparateDates'](message, undefined)).toBe(false); - message.created_at = new Date(2023, 6, 26); - nextMessage.created_at = new Date(2023, 6, 27); + message.created_at = dateToNs(new Date(2023, 6, 26)); + nextMessage.created_at = dateToNs(new Date(2023, 6, 27)); expect(component['checkIfOnSeparateDates'](message, nextMessage)).toBe( true ); - message.created_at = new Date(); - nextMessage.created_at = new Date(); + message.created_at = dateToNs(new Date()); + nextMessage.created_at = dateToNs(new Date()); expect(component['checkIfOnSeparateDates'](message, nextMessage)).toBe( false ); - message.created_at = new Date(2023, 6, 26); - nextMessage.created_at = new Date(2023, 7, 26); + message.created_at = dateToNs(new Date(2023, 6, 26)); + nextMessage.created_at = dateToNs(new Date(2023, 7, 26)); expect(component['checkIfOnSeparateDates'](message, nextMessage)).toBe( true @@ -1041,8 +1058,8 @@ describe('MessageListComponent', () => { expect(queryDateSeparators().length).toBe(1); - message.created_at = new Date(2013, 6, 26); - nextMessage.created_at = new Date(2013, 6, 27); + message.created_at = dateToNs(new Date(2013, 6, 26)); + nextMessage.created_at = dateToNs(new Date(2013, 6, 27)); channelServiceMock.activeChannelMessages$.next([message, nextMessage]); fixture.detectChanges(); @@ -1060,8 +1077,8 @@ describe('MessageListComponent', () => { const message = messages[0]; const nextMessage = messages[1]; - message.created_at = new Date(2023, 6, 26); - nextMessage.created_at = new Date(2023, 6, 27); + message.created_at = dateToNs(new Date(2023, 6, 26)); + nextMessage.created_at = dateToNs(new Date(2023, 6, 27)); channelServiceMock.activeChannelMessages$.next([message, nextMessage]); fixture.detectChanges(); @@ -1112,10 +1129,10 @@ describe('MessageListComponent', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 2].created_at.setDate( - messages[messages.length - 2].created_at.getDate() + 1 - ); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + const createdAt = nsToDate(messages[messages.length - 2].created_at); + createdAt.setDate(createdAt.getDate() + 1); + messages[messages.length - 2].created_at = dateToNs(createdAt); + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1131,10 +1148,10 @@ describe('MessageListComponent', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 2].created_at.setDate( - messages[messages.length - 2].created_at.getDate() + 1 - ); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + const createdAt = nsToDate(messages[messages.length - 2].created_at); + createdAt.setDate(createdAt.getDate() + 1); + messages[messages.length - 2].created_at = dateToNs(createdAt); + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1150,7 +1167,7 @@ describe('MessageListComponent', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1166,7 +1183,7 @@ describe('MessageListComponent', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1183,7 +1200,7 @@ describe('MessageListComponent', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1202,10 +1219,10 @@ describe('MessageListComponent', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 2].created_at.setDate( - messages[messages.length - 2].created_at.getDate() + 1 - ); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + const createdAt = nsToDate(messages[messages.length - 2].created_at); + createdAt.setDate(createdAt.getDate() + 1); + messages[messages.length - 2].created_at = dateToNs(createdAt); + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1219,7 +1236,7 @@ describe('MessageListComponent', () => { it('should display unread banner with correct text', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1245,7 +1262,7 @@ describe('MessageListComponent', () => { component.displayUnreadSeparator = false; const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1291,7 +1308,7 @@ describe('MessageListComponent', () => { component.openMessageListAt = 'last-read-message'; const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1334,7 +1351,7 @@ describe('MessageListComponent', () => { it('should dismiss unread message notification', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - messages[messages.length - 1].user!.id = 'not' + mockCurrentUser().id; + messages[messages.length - 1].user.id = 'not' + mockCurrentUser().id; channel.id = 'test-channel'; channelServiceMock.activeChannelLastReadMessageId = messages[messages.length - 2].id; @@ -1357,7 +1374,8 @@ describe('MessageListComponent', () => { it('should handle if last message is hard deleted', () => { const channel = generateMockChannels()[0]; const messages = generateMockMessages(); - channel.state.latestMessages = messages; + messages.forEach((m) => (m.cid = channel.cid)); + getPaginator(channel).state.partialNext({ hasMoreHead: false }); channelServiceMock.activeChannel$.next(channel); channelServiceMock.activeChannel = channel; channelServiceMock.activeChannelMessages$.next(messages); @@ -1366,10 +1384,70 @@ describe('MessageListComponent', () => { expect(component['isLatestMessageInList']).toBeTrue(); messages.pop(); - channel.state.latestMessages = messages; channelServiceMock.activeChannelMessages$.next(messages); fixture.detectChanges(); expect(component['isLatestMessageInList']).toBeTrue(); }); + + it('should tell stream-chat if user sees the latest messages', () => { + const paginator = getPaginator(channelServiceMock.activeChannel); + const spy = spyOn(paginator, 'setViewingLive').and.callThrough(); + const messages = generateMockMessages(); + messages.forEach((m) => (m.cid = channelServiceMock.activeChannel!.cid)); + channelServiceMock.activeChannelMessages$.next(messages); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(true); + + spy.calls.reset(); + const scrollContainer = queryScrollContainer()!; + scrollContainer.scrollTo({ + top: (scrollContainer.scrollHeight - scrollContainer.clientHeight) / 2, + }); + scrollContainer.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(false); + + spy.calls.reset(); + component.scrollToBottom(); + scrollContainer.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(true); + + // Not the latest message set is displayed + spy.calls.reset(); + paginator.state.partialNext({ hasMoreHead: true }); + channelServiceMock.activeChannelMessages$.next([...messages]); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith(false); + expect(spy).not.toHaveBeenCalledWith(true); + }); + + it(`shouldn't call setViewingLive in thread mode`, () => { + const paginator = getPaginator(channelServiceMock.activeChannel); + const spy = spyOn(paginator, 'setViewingLive'); + component.mode = 'thread'; + component.ngOnChanges({ mode: {} as SimpleChange }); + spy.calls.reset(); + const parent = mockMessage(); + channelServiceMock.activeParentMessage$.next(parent); + channelServiceMock.activeThreadMessages$.next( + generateMockMessages().map((m) => ({ ...m, parent_id: parent.id })) + ); + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should stop viewing live on destroy', () => { + const paginator = getPaginator(channelServiceMock.activeChannel); + const spy = spyOn(paginator, 'setViewingLive'); + fixture.destroy(); + + expect(spy).toHaveBeenCalledWith(false); + }); }); diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts b/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts index 9945c143..e1656df0 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts @@ -36,7 +36,12 @@ import { } from '../types'; import { ChatClientService } from '../chat-client.service'; import { getGroupStyles, GroupStyle } from './group-styles'; -import { MessageResponse, UserResponse } from 'stream-chat'; +import { + MessageResponse, + nsToDate, + TimestampNS, + UserResponse, +} from 'stream-chat'; import { CustomTemplatesService } from '../custom-templates.service'; import { listUsers } from '../list-users'; import { DateParserService } from '../date-parser.service'; @@ -138,7 +143,7 @@ export class MessageListComponent private usersTypingInThread$!: Observable; private isLatestMessageInList = true; private channelId?: string; - private parsedDates = new Map(); + private parsedDates = new Map(); private isViewInited = false; private checkIfUnreadNotificationIsVisibleTimeout?: ReturnType< typeof setTimeout @@ -414,6 +419,7 @@ export class MessageListComponent ngAfterViewInit(): void { this.isViewInited = true; + document.addEventListener('visibilitychange', this.updateViewingLive); this.ngZone.runOutsideAngular(() => { this.scrollContainer?.nativeElement?.addEventListener('scroll', () => this.scrolled() @@ -444,6 +450,12 @@ export class MessageListComponent } ngOnDestroy(): void { + document.removeEventListener('visibilitychange', this.updateViewingLive); + if (this.mode === 'main') { + this.channelService.activeChannel?.messagePaginator?.setViewingLive( + false + ); + } this.subscriptions.forEach((s) => s.unsubscribe()); this.newMessageSubscription?.unsubscribe(); if (this.scrollEndTimeout) { @@ -562,15 +574,19 @@ export class MessageListComponent return message.user?.id === this.chatClientService.chatClient.user?.id; } - parseDate(date: Date) { - if (this.parsedDates.has(date)) { - return this.parsedDates.get(date); + parseDate(timestamp: TimestampNS) { + if (this.parsedDates.has(timestamp)) { + return this.parsedDates.get(timestamp); } - const parsedDate = this.dateParser.parseDate(date); - this.parsedDates.set(date, parsedDate); + const parsedDate = this.dateParser.parseDate(nsToDate(timestamp)); + this.parsedDates.set(timestamp, parsedDate); return parsedDate; } + toDate(timestamp: TimestampNS) { + return nsToDate(timestamp); + } + get replyCountParam() { return { replyCount: this.parentMessage?.reply_count }; } @@ -591,6 +607,7 @@ export class MessageListComponent if (this.isUserScrolled !== isUserScrolled) { this.ngZone.run(() => { this.isUserScrolled = isUserScrolled; + this.updateViewingLive(); if (!this.isUserScrolled) { this.newMessageCountWhileBeingScrolled = 0; } @@ -628,6 +645,26 @@ export class MessageListComponent } } + private getMessagePaginator() { + return this.mode === 'main' + ? this.channelService.activeChannel?.messagePaginator + : this.chatClientService.chatClient?.threads?.get( + this.parentMessage?.id || '' + )?.messagePaginator; + } + + /** + * Tells stream-chat if the user sees the latest messages (in that case new messages don't increase the unread count) + */ + private updateViewingLive = () => { + if (this.mode !== 'main') { + return; + } + this.channelService.activeChannel?.messagePaginator?.setViewingLive( + !document.hidden && !this.isUserScrolled && this.isLatestMessageInList + ); + }; + private preserveScrollbarPosition() { if (!this.messageIdToAnchorTo) { return; @@ -724,20 +761,23 @@ export class MessageListComponent tap((messages) => { const latestMessageInList = messages[messages.length - 1]; const channel = this.channelService.activeChannel; - const messagesFromState = - (this.mode === 'main' - ? channel?.state.latestMessages - : channel?.state.threads[this.parentMessage?.id || '']) || []; + const paginator = this.getMessagePaginator(); + const allMessages = + this.mode === 'main' + ? this.channelService.activeChannelMessages + : this.channelService.activeChannelThreadReplies; this.isLatestMessageInList = !latestMessageInList || latestMessageInList.cid !== channel?.cid || (this.mode === 'thread' && latestMessageInList?.parent_id !== this.parentMessage?.id) || - latestMessageInList?.id === - messagesFromState[messagesFromState.length - 1]?.id; + (!paginator?.hasMoreHead && + latestMessageInList?.id === + allMessages[allMessages.length - 1]?.id); if (!this.isLatestMessageInList) { this.isUserScrolled = true; } + this.updateViewingLive(); }), map((messages) => { return this.direction === 'bottom-to-top' @@ -771,6 +811,8 @@ export class MessageListComponent jumpToMessage.position = 'top'; } this.isJumpingToMessage = true; + // Render the new message window first, otherwise we might scroll to the message in the previous window + this.cdRef.detectChanges(); this.scrollMessageIntoView({ messageId: this.firstUnreadMessageId || messageId, position: jumpToMessage.position || 'middle', @@ -836,9 +878,8 @@ export class MessageListComponent this.jumpToMessageTimeouts.push( setTimeout(() => { this.isJumpingToMessage = false; - if (!this.isUserScrolled) { - this.checkIfUserScrolled(); - } + // Scroll position isn't tracked during jumps, update it, so the virtualized list knows if it needs to load more messages after the jump + this.checkIfUserScrolled(); }, 200) ); this.jumpToMessageTimeouts.push( @@ -896,6 +937,9 @@ export class MessageListComponent if (!message || !nextMessage) { return false; } - return isOnSeparateDate(message.created_at, nextMessage.created_at); + return isOnSeparateDate( + nsToDate(message.created_at), + nsToDate(nextMessage.created_at) + ); } } diff --git a/projects/stream-chat-angular/src/lib/message-preview.spec.ts b/projects/stream-chat-angular/src/lib/message-preview.spec.ts index a3d97352..6d48c1ac 100644 --- a/projects/stream-chat-angular/src/lib/message-preview.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-preview.spec.ts @@ -1,3 +1,4 @@ +import { Attachment, UserResponse } from 'stream-chat'; import { mockCurrentUser } from './mocks'; import { createMessagePreview } from './message-preview'; declare module 'stream-chat' { @@ -14,8 +15,8 @@ describe('createMessagePreview', () => { const attachments = [ { fallback: 'image.png', image_url: 'http://url/to/image' }, { fallback: 'christmas.jpg', image_url: 'http://url/to/image' }, - ]; - const users = [{ id: 'jack', name: 'Jack' }]; + ] as Attachment[]; + const users = [{ id: 'jack', name: 'Jack' }] as UserResponse[]; const parentId = 'parentId'; const quotedMessageId = 'quotedMessageId'; const preview = createMessagePreview( @@ -32,16 +33,17 @@ describe('createMessagePreview', () => { ); expect(preview.created_at).not.toBeUndefined(); + expect(typeof preview.created_at).toBe('number'); + expect(preview.updated_at).toBe(preview.created_at); + expect(preview.status).toBe('sending'); expect(preview.html).toBe(text); - // @ts-expect-error internal property - expect(preview.__html).toBe(text); expect(preview.user).toBe(user); expect(preview.id).toContain(user.id); expect(preview.attachments).toBe(attachments); expect(preview.mentioned_users).toBe(users); expect(preview.parent_id).toBe(parentId); expect(preview.quoted_message_id).toBe(quotedMessageId); - expect(preview.isVote).toBe(true); - expect(preview.options).toEqual(['Monday', 'Tuesday', 'Friday']); + expect(preview.custom.isVote).toBe(true); + expect(preview.custom.options).toEqual(['Monday', 'Tuesday', 'Friday']); }); }); diff --git a/projects/stream-chat-angular/src/lib/message-preview.ts b/projects/stream-chat-angular/src/lib/message-preview.ts index 5fb8a419..2a711f5e 100644 --- a/projects/stream-chat-angular/src/lib/message-preview.ts +++ b/projects/stream-chat-angular/src/lib/message-preview.ts @@ -1,7 +1,8 @@ import { Attachment, CustomMessageData, - MessageResponse, + LocalMessage, + nowNs, UserResponse, } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; @@ -18,12 +19,16 @@ export const createMessagePreview = ( ) => { const clientSideId = `${user.id}-${uuidv4()}`; + const now = nowNs(); return { - __html: text, - created_at: new Date(), + created_at: now, + updated_at: now, html: text, id: clientSideId, - reactions: [], + latest_reactions: [], + own_reactions: [], + reaction_counts: {}, + reaction_scores: {}, status: 'sending', text, type: 'regular', @@ -33,6 +38,6 @@ export const createMessagePreview = ( parent_id: parentId, quoted_message_id: quotedMessageId, poll_id: pollId, - ...customData, - } as unknown as MessageResponse; + custom: customData ?? {}, + } as unknown as LocalMessage; }; diff --git a/projects/stream-chat-angular/src/lib/message-reactions-selector/message-reactions-selector.component.ts b/projects/stream-chat-angular/src/lib/message-reactions-selector/message-reactions-selector.component.ts index f223994a..fc962b1e 100644 --- a/projects/stream-chat-angular/src/lib/message-reactions-selector/message-reactions-selector.component.ts +++ b/projects/stream-chat-angular/src/lib/message-reactions-selector/message-reactions-selector.component.ts @@ -13,7 +13,7 @@ import { MessageReactionsService } from '../message-reactions.service'; import { Subscription } from 'rxjs'; /** - * The `MessageReactionsSelectorComponent` makes it possible for users to react to a message, the reaction options can be set using the [`MessageReactionsService`](/chat/docs/sdk/angular/services/MessageReactionsService/). You can read more about [message reactions](/chat/docs/javascript/send_reaction/) in the platform documentation. + * The `MessageReactionsSelectorComponent` makes it possible for users to react to a message, the reaction options can be set using the [`MessageReactionsService`](/chat/docs/sdk/angular/services/MessageReactionsService/). You can read more about [message reactions](/chat/docs/javascript/send-reaction/) in the platform documentation. */ @Component({ selector: 'stream-message-reactions-selector', diff --git a/projects/stream-chat-angular/src/lib/message-reactions.service.ts b/projects/stream-chat-angular/src/lib/message-reactions.service.ts index 5fd4edf0..8adb3ef7 100644 --- a/projects/stream-chat-angular/src/lib/message-reactions.service.ts +++ b/projects/stream-chat-angular/src/lib/message-reactions.service.ts @@ -5,7 +5,7 @@ import { ChatClientService } from './chat-client.service'; import { NotificationService } from './notification.service'; /** - * The `MessageReactionsService` provides customization options to message [reactions](/chat/docs/javascript/send_reaction/). + * The `MessageReactionsService` provides customization options to message [reactions](/chat/docs/javascript/send-reaction/). * */ @Injectable({ @@ -13,9 +13,9 @@ import { NotificationService } from './notification.service'; }) export class MessageReactionsService { /** - * The enabled [reactions](/chat/docs/javascript/send_reaction/) and the associated emoji + * The enabled [reactions](/chat/docs/javascript/send-reaction/) and the associated emoji * - * You can provide any string as a reaction. The emoji can be provided as a string, if you want to use custom images for reactions you have to provide a [custom reactions UI](/chat/docs/sdk/angular/services/CustomTemplatesService/#messagereactionstemplate/) + * You can provide any string as a reaction. The emoji can be provided as a string, if you want to use custom images for reactions you have to provide a [custom reactions UI](/chat/docs/sdk/angular/services/CustomTemplatesService/#messagereactionstemplate) */ reactions$ = new BehaviorSubject<{ [key in MessageReactionType]: string }>({ haha: '😂', @@ -51,7 +51,7 @@ export class MessageReactionsService { } /** - * Query reactions of a specific message, more info in the [API documentation](/chat/docs/javascript/send_reaction/#query-reactions) + * Query reactions of a specific message, more info in the [API documentation](/chat/docs/javascript/send-reaction/#querying-reactions) * @param messageId * @param type * @param next @@ -65,10 +65,12 @@ export class MessageReactionsService { } else { try { const response = await this.chatClientService.chatClient.queryReactions( - messageId, - { type }, - { created_at: -1 }, - { next } + { id: messageId }, + { + filter: { type: { $eq: type } }, + sort: [{ field: 'created_at', direction: -1 }], + next, + } ); return response; 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 7cb40e37..05df843d 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,7 +4,13 @@ import { TestBed, tick, } from '@angular/core/testing'; -import { QueryReactionsAPIResponse, ReactionResponse } from 'stream-chat'; +import { + dateToNs, + QueryReactionsResponse, + ReactionGroupResponse, + ReactionResponse, + UserResponse, +} from 'stream-chat'; import { AvatarComponent } from '../avatar/avatar.component'; import { MessageReactionsComponent } from './message-reactions.component'; @@ -32,7 +38,7 @@ describe('MessageReactionsComponent', () => { Promise.resolve({ reactions: [], next: undefined, - } as unknown as QueryReactionsAPIResponse), + } as unknown as QueryReactionsResponse), }; beforeEach(async () => { @@ -123,7 +129,7 @@ describe('MessageReactionsComponent', () => { const spy = spyOn(reactionsServiceMock, 'queryReactions').and.resolveTo({ reactions, next: undefined, - } as unknown as QueryReactionsAPIResponse); + } as unknown as QueryReactionsResponse); const wowEmoji = queryEmojis()[0]; wowEmoji.click(); @@ -153,7 +159,7 @@ describe('MessageReactionsComponent', () => { { type: 'sad', user: { id: 'ben', name: 'Ben' } }, ], next: undefined, - } as unknown as QueryReactionsAPIResponse); + } as unknown as QueryReactionsResponse); nativeElement .querySelector( '[data-testid="reaction-details-selector-sad"]' @@ -173,7 +179,7 @@ describe('MessageReactionsComponent', () => { const spy = spyOn(reactionsServiceMock, 'queryReactions').and.resolveTo({ reactions: [], next: 'next-page', - } as unknown as QueryReactionsAPIResponse); + } as unknown as QueryReactionsResponse); await component.reactionSelected('wow'); @@ -192,7 +198,7 @@ describe('MessageReactionsComponent', () => { wow: { count: 4, sum_scores: 4, - }, + } as ReactionGroupResponse, }; component.ngOnChanges({ messageReactionGroups: {} as SimpleChange }); fixture.detectChanges(); @@ -206,7 +212,7 @@ describe('MessageReactionsComponent', () => { spyOn(reactionsServiceMock, 'queryReactions').and.resolveTo({ reactions: reactions, next: 'next-page', - } as unknown as QueryReactionsAPIResponse); + } as unknown as QueryReactionsResponse); void component.reactionSelected('wow'); fixture.detectChanges(); @@ -228,7 +234,7 @@ describe('MessageReactionsComponent', () => { { id: 'saraid', name: 'Sara' }, { id: 'benid', name: 'Ben' }, { id: 'jackid' }, - ]); + ] as UserResponse[]); })); it(`should call custom reaction details handler if that's provided`, () => { @@ -320,32 +326,37 @@ describe('MessageReactionsComponent', () => { love: { count: 12, sum_scores: 12, - first_reaction_at: '2024-09-05T13:17:05.138248Z', - last_reaction_at: '2024-09-05T13:17:11.454912Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.138248Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:11.454912Z')), + latest_reactions_by: [], }, sad: { count: 10, sum_scores: 10, - first_reaction_at: '2024-09-05T13:17:05.673605Z', - last_reaction_at: '2024-09-05T13:17:13.211086Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.673605Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:13.211086Z')), + latest_reactions_by: [], }, wow: { count: 20, sum_scores: 20, - first_reaction_at: '2024-09-05T13:17:05.059252Z', - last_reaction_at: '2024-09-05T13:17:13.066055Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.059252Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:13.066055Z')), + latest_reactions_by: [], }, haha: { count: 9, sum_scores: 9, - first_reaction_at: '2024-09-05T13:17:05.522053Z', - last_reaction_at: '2024-09-05T13:17:10.87445Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.522053Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:10.87445Z')), + latest_reactions_by: [], }, like: { count: 7, sum_scores: 7, - first_reaction_at: '2024-09-05T13:17:04.977203Z', - last_reaction_at: '2024-09-05T13:17:14.856949Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:04.977203Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:14.856949Z')), + latest_reactions_by: [], }, }; component.ngOnChanges({ @@ -374,8 +385,9 @@ describe('MessageReactionsComponent', () => { sad: { count: 10, sum_scores: 10, - first_reaction_at: '2024-09-05T12:17:05.673605Z', - last_reaction_at: '2024-09-05T13:17:13.211086Z', + first_reaction_at: dateToNs(new Date('2024-09-05T12:17:05.673605Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:13.211086Z')), + latest_reactions_by: [], }, }; component.ngOnChanges({ messageReactionGroups: {} as SimpleChange }); @@ -394,32 +406,37 @@ describe('MessageReactionsComponent', () => { love: { count: 12, sum_scores: 12, - first_reaction_at: '2024-09-05T13:17:05.138248Z', - last_reaction_at: '2024-09-05T13:17:11.454912Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.138248Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:11.454912Z')), + latest_reactions_by: [], }, sad: { count: 10, sum_scores: 10, - first_reaction_at: '2024-09-05T13:17:05.673605Z', - last_reaction_at: '2024-09-05T13:17:13.211086Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.673605Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:13.211086Z')), + latest_reactions_by: [], }, wow: { count: 20, sum_scores: 20, - first_reaction_at: '2024-09-05T13:17:05.059252Z', - last_reaction_at: '2024-09-05T13:17:13.066055Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.059252Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:13.066055Z')), + latest_reactions_by: [], }, haha: { count: 9, sum_scores: 9, - first_reaction_at: '2024-09-05T13:17:05.522053Z', - last_reaction_at: '2024-09-05T13:17:10.87445Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:05.522053Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:10.87445Z')), + latest_reactions_by: [], }, like: { count: 7, sum_scores: 7, - first_reaction_at: '2024-09-05T13:17:04.977203Z', - last_reaction_at: '2024-09-05T13:17:14.856949Z', + first_reaction_at: dateToNs(new Date('2024-09-05T13:17:04.977203Z')), + last_reaction_at: dateToNs(new Date('2024-09-05T13:17:14.856949Z')), + latest_reactions_by: [], }, }; component.messageReactionCounts = { 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 559b8b4f..d6c9629e 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 @@ -11,8 +11,10 @@ import { ViewChild, } from '@angular/core'; import { + nsToMs, ReactionGroupResponse, ReactionResponse, + TimestampNS, UserResponse, } from 'stream-chat'; import { MessageReactionType } from '../types'; @@ -21,7 +23,7 @@ import { CustomTemplatesService } from '../custom-templates.service'; import { Subscription } from 'rxjs'; /** - * The `MessageReactions` component displays the reactions of a message. You can read more about [message reactions](/chat/docs/javascript/send_reaction/) in the platform documentation. + * The `MessageReactions` component displays the reactions of a message. You can read more about [message reactions](/chat/docs/javascript/send-reaction/) in the platform documentation. */ @Component({ selector: 'stream-message-reactions', @@ -96,9 +98,10 @@ export class MessageReactionsComponent this.messageReactionGroups![k] = { count: this.messageReactionCounts?.[k] ?? 0, sum_scores: this.messageReactionCounts?.[k] ?? 0, - first_reaction_at: undefined, - last_reaction_at: undefined, - }; + // Not known when only counts are provided, sorting falls back to the current time + first_reaction_at: undefined as unknown as TimestampNS, + last_reaction_at: undefined as unknown as TimestampNS, + } as ReactionGroupResponse; }); } this.setExistingReactions(); @@ -186,14 +189,15 @@ export class MessageReactionsComponent .filter((k) => this.reactionOptions.indexOf(k) !== -1) .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(); + const firstReactionAt1 = + this.messageReactionGroups![r1].first_reaction_at; + const firstReactionAt2 = + this.messageReactionGroups![r2].first_reaction_at; + const now = Date.now(); + const time1 = firstReactionAt1 ? nsToMs(firstReactionAt1) : now; + const time2 = firstReactionAt2 ? nsToMs(firstReactionAt2) : now; - return date1.getTime() - date2.getTime(); + return time1 - time2; }); } } diff --git a/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts b/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts index 5f0ee538..e68e6f9a 100644 --- a/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-text/message-text.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MessageTextComponent } from './message-text.component'; import { StreamMessage } from '../types'; +import { UserResponse } from 'stream-chat'; import { SimpleChange } from '@angular/core'; import { MessageService } from '../message.service'; import { generateMockMessages } from '../mocks'; @@ -148,7 +149,7 @@ describe('MessageTextComponent', () => { { content: '@Jack', type: 'mention', - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, }, ]); @@ -163,7 +164,7 @@ describe('MessageTextComponent', () => { { content: '@Jack', type: 'mention', - user: { id: 'jack', name: 'Jack' }, + user: { id: 'jack', name: 'Jack' } as UserResponse, }, { content: ', how are you?', type: 'text' }, ]); @@ -182,13 +183,13 @@ describe('MessageTextComponent', () => { { content: '@Jack', type: 'mention', - user: { id: 'id2334', name: 'Jack' }, + user: { id: 'id2334', name: 'Jack' } as UserResponse, }, { content: ' and ', type: 'text' }, { content: '@Lucie', type: 'mention', - user: { id: 'id3444', name: 'Lucie' }, + user: { id: 'id3444', name: 'Lucie' } as UserResponse, }, { content: ', how are you?', type: 'text' }, ]); @@ -205,7 +206,11 @@ describe('MessageTextComponent', () => { 'https://getstream.io/ this is the link ', type: 'text', }, - { content: '@sara', type: 'mention', user: { id: 'sara' } }, + { + content: '@sara', + type: 'mention', + user: { id: 'sara' } as UserResponse, + }, ]); component.message = { @@ -272,7 +277,10 @@ describe('MessageTextComponent', () => { ' https://getstream.io/' ); - component.message.html = undefined; + component.message = { + ...component.message, + html: undefined, + } as unknown as StreamMessage; component.ngOnChanges({ message: {} as SimpleChange }); expect(component.messageTextParts![0].content).toContain( diff --git a/projects/stream-chat-angular/src/lib/message.service.spec.ts b/projects/stream-chat-angular/src/lib/message.service.spec.ts index 2392dc5f..5d8e1124 100644 --- a/projects/stream-chat-angular/src/lib/message.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/message.service.spec.ts @@ -17,6 +17,7 @@ describe('MessageService', () => { it('should filter custom attachments', () => { const attachment: Attachment = { + custom: {}, type: 'image', }; 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 2e5bfd5e..64ec3503 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.html +++ b/projects/stream-chat-angular/src/lib/message/message.component.html @@ -1,9 +1,6 @@ @@ -38,7 +35,7 @@ [class.str-chat__message-with-thread-link]="shouldDisplayThreadLink" [class.str-chat__message-send-can-be-retried]=" (message?.status === 'failed' && message?.errorStatusCode !== 403) || - (message?.type === 'error' && message?.moderation_details) + isBounced " [class.str-chat__message-with-touch-support]="hasTouchSupport" [class.str-chat__message-without-touch-support]="!hasTouchSupport" @@ -213,8 +210,7 @@ [class.str-chat__message-text--pointer-cursor]=" (message?.status === 'failed' && message?.errorStatusCode !== 403) || - (this.message?.type === 'error' && - this.message?.moderation_details) || + isBounced || message?.message_text_updated_at " (click)="messageClicked()" @@ -236,7 +232,7 @@ data-testid="client-error-message" class="str-chat__simple-message--error-message str-chat__message--error-message" > - {{ + {{ "streamChat.Error · Unsent" | translate }} @@ -492,10 +488,7 @@ }" > {{ "streamChat.Edited" | translate }} -