From e21f0ccad093df013e4913cadfa71bf706478f1a Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 26 Oct 2026 14:33:43 -0500 Subject: [PATCH 01/11] feat!: support for v10 Co-Authored-By: Claude Opus 5.5 BREAKING CHANGE: check the migration-guide.md --- package-lock.json | 585 +--- package.json | 6 +- .../src/app/app.component.html | 2 +- .../src/app/app.component.ts | 3 +- .../src/app/icon/icon.component.ts | 1 + projects/sample-app/src/app/app.component.ts | 2 +- .../custom-message.component.ts | 5 +- .../stream-chat-angular/migration-guide.md | 347 +++ projects/stream-chat-angular/package.json | 2 +- .../stream-chat-angular/src/assets/i18n/en.ts | 1 + .../attachment-configuration.service.spec.ts | 50 +- .../lib/attachment-configuration.service.ts | 5 +- .../attachment-list.component.spec.ts | 185 +- .../attachment-list.component.ts | 10 +- .../attachment-preview-list.component.spec.ts | 29 +- .../src/lib/attachment.service.spec.ts | 82 +- .../src/lib/attachment.service.ts | 48 +- .../src/lib/avatar/avatar.component.spec.ts | 130 +- .../src/lib/avatar/avatar.component.ts | 12 +- .../channel-header.component.html | 2 +- .../channel-header.component.spec.ts | 9 +- .../channel-header.component.ts | 4 +- .../channel-preview.component.spec.ts | 149 +- .../channel-preview.component.ts | 80 +- .../src/lib/channel-query.ts | 47 +- .../src/lib/channel.service.spec.ts | 2390 ++++++++++++----- .../src/lib/channel.service.thread.spec.ts | 1024 ++++--- .../src/lib/channel.service.ts | 1293 +++++---- .../src/lib/chat-client.service.spec.ts | 104 +- .../src/lib/chat-client.service.ts | 201 +- .../src/lib/get-channel-display-text.spec.ts | 14 +- .../src/lib/get-channel-display-text.ts | 8 +- .../src/lib/get-message-translation.ts | 18 +- .../stream-chat-angular/src/lib/list-users.ts | 4 +- .../message-actions-box.component.spec.ts | 13 +- .../src/lib/message-dates.ts | 84 + .../autocomplete-textarea.component.spec.ts | 42 +- .../autocomplete-textarea.component.ts | 19 +- .../message-input.component.spec.ts | 68 +- .../message-input/message-input.component.ts | 7 +- .../src/lib/message-list/group-styles.spec.ts | 8 +- .../message-list.component.spec.ts | 142 +- .../message-list/message-list.component.ts | 50 +- .../src/lib/message-preview.spec.ts | 14 +- .../src/lib/message-preview.ts | 17 +- .../src/lib/message-reactions.service.ts | 10 +- .../message-reactions.component.spec.ts | 77 +- .../message-reactions.component.ts | 24 +- .../message-text.component.spec.ts | 20 +- .../src/lib/message.service.spec.ts | 1 + .../src/lib/message/message.component.html | 12 +- .../src/lib/message/message.component.spec.ts | 90 +- .../src/lib/message/message.component.ts | 12 +- .../src/lib/mocks/index.ts | 605 ++++- .../add-option/add-option.component.ts | 4 +- .../poll-actions/poll-actions.component.ts | 10 +- .../poll-answers-list.component.ts | 10 +- .../poll-results-list.component.ts | 16 +- .../poll-vote-results-list.component.ts | 14 +- .../poll-vote/poll-vote.component.ts | 8 +- .../upsert-answer/upsert-answer.component.ts | 4 +- .../poll-composer/poll-composer.component.ts | 5 +- .../poll-option-selector.component.ts | 12 +- .../poll-options-list.component.ts | 6 +- .../src/lib/read-by.spec.ts | 21 +- .../stream-chat-angular/src/lib/read-by.ts | 20 +- .../src/lib/thread/thread.component.spec.ts | 2 +- .../src/lib/types-custom.ts | 8 + projects/stream-chat-angular/src/lib/types.ts | 71 +- .../lib/user-list/user-list.component.spec.ts | 8 +- .../amplitude-recorder.service.ts | 2 +- .../src/lib/voice-recorder/media-recorder.ts | 2 +- .../voice-recording.component.html | 12 +- .../voice-recording.component.ts | 10 +- 74 files changed, 5518 insertions(+), 2894 deletions(-) create mode 100644 projects/stream-chat-angular/migration-guide.md create mode 100644 projects/stream-chat-angular/src/lib/message-dates.ts diff --git a/package-lock.json b/package-lock.json index aa82624a..2f9040a8 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": "file:../stream-chat-js-v10", "ts-node": "^10.9.2", "tslib": "^2.3.0", "uuid": "^9.0.1", @@ -77,12 +77,55 @@ "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" } }, + "../stream-chat-js-v10": { + "name": "stream-chat", + "version": "0.0.0-development", + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@stream-io/logger": "^2.0.0", + "@stream-io/state-store": "^1.1.6", + "axios": "^1.19.0", + "linkifyjs": "^4.3.3" + }, + "devDependencies": { + "@commitlint/cli": "^21.0.1", + "@commitlint/config-conventional": "^21.0.1", + "@eslint/js": "^9.39.4", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "@stream-io/node-sdk": "^0.8.10", + "@types/node": "^22.19.19", + "@types/sinon": "^10.0.6", + "@vitest/coverage-v8": "^4.1.10", + "concurrently": "^9.2.1", + "conventional-changelog-conventionalcommits": "^9.3.1", + "esbuild": "^0.28.2", + "eslint": "^9.39.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsdoc": "^63.0.7", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.6.0", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "prettier": "^3.8.3", + "semantic-release": "^25.0.9", + "sinon": "^12.0.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vitest": "^4.1.10", + "yaml": "^2.8.4" + }, + "engines": { + "node": ">=22.18.0" + } + }, "../transliterate": { "name": "@stream-io/transliterate", "version": "1.5.0", @@ -7385,16 +7428,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 +7435,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 +7560,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": "*" @@ -9588,11 +9616,6 @@ "dev": true, "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, "node_modules/autoprefixer": { "version": "10.4.18", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz", @@ -9779,6 +9802,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 +10054,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", @@ -10164,6 +10182,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10517,17 +10536,6 @@ "node": ">=0.1.90" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -11309,14 +11317,6 @@ "node": ">=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=", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -11539,6 +11539,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -11600,15 +11601,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", @@ -12020,6 +12012,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12029,6 +12022,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -12044,6 +12038,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -12052,21 +12047,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.1.tgz", @@ -13137,6 +13117,7 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "dev": true, "funding": [ { "type": "individual", @@ -13180,22 +13161,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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==", - "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" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -13332,6 +13297,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -13398,6 +13364,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -13437,6 +13404,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -13582,6 +13550,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13701,6 +13670,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13709,24 +13679,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -14630,15 +14587,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 +14896,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", @@ -15295,12 +15200,6 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, - "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", - "license": "MIT" - }, "node_modules/lint-staged": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-11.1.2.tgz", @@ -15473,39 +15372,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 +15390,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", @@ -15848,6 +15719,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -16057,6 +15929,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -16065,6 +15938,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -20889,11 +20763,6 @@ "node": ">= 0.10" } }, - "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==" - }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -23323,55 +23192,8 @@ } }, "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==", - "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" - }, - "engines": { - "node": ">=18" - } - }, - "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==", - "dependencies": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.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==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } + "resolved": "../stream-chat-js-v10", + "link": true }, "node_modules/stream-combiner2": { "version": "1.1.1", @@ -24393,9 +24215,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 +25176,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" }, @@ -30095,26 +29918,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 +30040,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": "*" } @@ -31568,11 +31378,6 @@ "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", "dev": true }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, "autoprefixer": { "version": "10.4.18", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz", @@ -31701,7 +31506,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 +31678,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", @@ -31971,6 +31772,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "requires": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -32210,14 +32012,6 @@ "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", "dev": true }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, "commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -32796,11 +32590,6 @@ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "dev": true }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -32956,6 +32745,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -33015,14 +32805,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", @@ -33315,12 +33097,14 @@ "es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true }, "es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true }, "es-module-lexer": { "version": "1.7.0", @@ -33332,21 +33116,11 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "requires": { "es-errors": "^1.3.0" } }, - "es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "requires": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - } - }, "esbuild": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.1.tgz", @@ -34107,7 +33881,8 @@ "follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "dev": true }, "foreground-child": { "version": "3.1.1", @@ -34127,18 +33902,6 @@ } } }, - "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==", - "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" - } - }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -34240,7 +34003,8 @@ "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true }, "function-timeout": { "version": "1.0.2", @@ -34281,6 +34045,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -34310,6 +34075,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "requires": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -34431,7 +34197,8 @@ "gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true }, "graceful-fs": { "version": "4.2.11", @@ -34516,20 +34283,14 @@ "has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "requires": { - "has-symbols": "^1.0.3" - } + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true }, "hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "requires": { "function-bind": "^1.1.2" } @@ -35131,12 +34892,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 +35129,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", @@ -35639,11 +35358,6 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, - "linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==" - }, "lint-staged": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-11.1.2.tgz", @@ -35783,35 +35497,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 +35515,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", @@ -36062,7 +35753,8 @@ "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true }, "media-typer": { "version": "0.3.0", @@ -36202,12 +35894,14 @@ "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "requires": { "mime-db": "1.52.0" } @@ -39484,11 +39178,6 @@ } } }, - "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==" - }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -41206,37 +40895,38 @@ "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==", - "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" - }, - "dependencies": { - "axios": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", - "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", - "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.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": {} - } + "version": "file:../stream-chat-js-v10", + "requires": { + "@commitlint/cli": "^21.0.1", + "@commitlint/config-conventional": "^21.0.1", + "@eslint/js": "^9.39.4", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/git": "^10.0.1", + "@stream-io/logger": "^2.0.0", + "@stream-io/node-sdk": "^0.8.10", + "@stream-io/state-store": "^1.1.6", + "@types/node": "^22.19.19", + "@types/sinon": "^10.0.6", + "@vitest/coverage-v8": "^4.1.10", + "axios": "^1.19.0", + "concurrently": "^9.2.1", + "conventional-changelog-conventionalcommits": "^9.3.1", + "esbuild": "^0.28.2", + "eslint": "^9.39.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsdoc": "^63.0.7", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.6.0", + "husky": "^9.1.7", + "linkifyjs": "^4.3.3", + "lint-staged": "^17.0.5", + "prettier": "^3.8.3", + "semantic-release": "^25.0.9", + "sinon": "^12.0.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vitest": "^4.1.10", + "yaml": "^2.8.4" } }, "stream-combiner2": { @@ -41958,9 +41648,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 +42283,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..c11fb45d 100644 --- a/package.json +++ b/package.json @@ -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": "file:../stream-chat-js-v10", "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.ts b/projects/sample-app/src/app/custom-message/custom-message.component.ts index b972f7a2..e72e3f9b 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,5 @@ import { Component, Input } from '@angular/core'; -import { FormatMessageResponse } from 'stream-chat'; -import { ChatClientService } from 'stream-chat-angular'; +import { ChatClientService, StreamMessage } from 'stream-chat-angular'; @Component({ selector: 'app-custom-message', @@ -8,7 +7,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) { diff --git a/projects/stream-chat-angular/migration-guide.md b/projects/stream-chat-angular/migration-guide.md new file mode 100644 index 00000000..efc84d22 --- /dev/null +++ b/projects/stream-chat-angular/migration-guide.md @@ -0,0 +1,347 @@ +# 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 uses the SDK's own `ChannelSort` type, exported by `stream-chat-angular` (it's no longer exported by `stream-chat`). It accepts the v10 format and the previous object format, so existing code keeps working: + +```ts +import { ChannelSort } from "stream-chat-angular"; + +const sort: ChannelSort = [{ field: "last_message_at", direction: -1 }]; // v10 format +const legacySort: ChannelSort = { last_message_at: -1 }; // still accepted +``` + +### 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). Date fields are still `Date` objects in `StreamMessage`: the SDK converts them from the v10 format (`TimestampNS`, unix nanoseconds). + +| Change | Before | After | +| ------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------- | +| Base type | `FormatMessageResponse` | `MessageWithDates` (exported by `stream-chat-angular`) | +| Date fields (`created_at`, `updated_at`, `deleted_at`, `pinned_at`, `pin_expires`, `message_text_updated_at`) | `Date` (or `null`) | `Date` or `undefined` (never `null`) | +| `quoted_message` | `MessageResponseBase & { translation }` | `MessageWithDates & { translation }` (dates are `Date` objects) | +| 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 | + +New exported types: `MessageDateFields` (names of the converted date fields) and `MessageWithDates`. + +If you need the v10 message format (for example to call `stream-chat` APIs), the raw messages are available on the channel's paginator: `channel.messagePaginator.getItem(message.id)`. If you work with v10 objects directly, use the `nsToDate` and `dateToNs` helpers exported by `stream-chat` to convert dates. + +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`'s type changed from `FormatMessageResponse` to `MessageWithDates`. 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`. + +- `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.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 \| MessageWithDates & { translation } \| undefined` | +| `ChannelSort` | from `stream-chat` | from `stream-chat-angular` | +| `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). +- `ChannelSort`, `MessageListPaginationState`, `MessageDateFields`, `MessageWithDates`, `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..d3d432b0 100644 --- a/projects/stream-chat-angular/package.json +++ b/projects/stream-chat-angular/package.json @@ -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/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..7afb66f3 100644 --- a/projects/stream-chat-angular/src/lib/attachment.service.ts +++ b/projects/stream-chat-angular/src/lib/attachment.service.ts @@ -8,11 +8,11 @@ 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'; @@ -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, @@ -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/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..72516c66 100644 --- a/projects/stream-chat-angular/src/lib/avatar/avatar.component.ts +++ b/projects/stream-chat-angular/src/lib/avatar/avatar.component.ts @@ -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..33e84801 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 @@ -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..93f6cc5f 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 { convertStreamMessageToLocalMessage } from '../message-dates'; 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,10 @@ 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( + convertStreamMessageToLocalMessage(newMessage) + ); fixture.detectChanges(); expect(container?.classList.contains(unreadClass)).toBeTrue(); @@ -185,6 +209,7 @@ describe('ChannelPreviewComponent', () => { chatClientServiceMock.events$.next({ eventType: 'notification.mark_unread', event: { + type: 'notification.mark_unread', channel_id: channel.id, } as Event, }); @@ -211,8 +236,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 +245,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 +261,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 +276,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 +300,11 @@ 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( + convertStreamMessageToLocalMessage(newMessage) + ); fixture.detectChanges(); expect(queryLatestMessage()?.textContent).toContain(newMessage.text); @@ -290,11 +315,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 +330,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 +346,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 +363,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 +372,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 +422,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(Date)); expect(component.latestMessageStatus).toBe('delivered'); @@ -408,24 +435,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 +461,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..5b8243f8 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 } from 'stream-chat'; import { ChannelService } from '../channel.service'; import { getChannelDisplayText } from '../get-channel-display-text'; import { ChatClientService } from '../chat-client.service'; @@ -11,6 +11,8 @@ import { CustomTemplatesService } from '../custom-templates.service'; import { getReadBy } from '../read-by'; import { isOnSeparateDate } from '../is-on-separate-date'; import { DateParserService } from '../date-parser.service'; +import { convertMessageDatesToDate } from '../message-dates'; +import { MessageWithDates } from '../types'; /** * The `ChannelPreview` component displays a channel preview in the channel list, it consists of the image, name and latest message of the channel. @@ -32,7 +34,7 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { latestMessageText: string = 'streamChat.Nothing yet...'; latestMessageStatus?: 'delivered' | 'read'; latestMessageTime?: string; - latestMessage?: FormatMessageResponse; + latestMessage?: MessageWithDates; displayAs: 'text' | 'html'; userId?: string; private subscriptions: (Subscription | { unsubscribe: () => void })[] = []; @@ -63,26 +65,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 +92,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 +110,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 +123,7 @@ export class ChannelPreviewComponent implements OnInit, OnDestroy { } return getChannelDisplayText( this.channel, - this.chatClientService.chatClient.user! + this.chatClientService.chatClient.user ); } @@ -135,35 +131,19 @@ 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 + ? convertMessageDatesToDate(localMessage) + : undefined; 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; diff --git a/projects/stream-chat-angular/src/lib/channel-query.ts b/projects/stream-chat-angular/src/lib/channel-query.ts index 8966770a..dc312524 100644 --- a/projects/stream-chat-angular/src/lib/channel-query.ts +++ b/projects/stream-chat-angular/src/lib/channel-query.ts @@ -2,11 +2,12 @@ import { Channel, ChannelFilters, ChannelOptions, - ChannelSort, + SortParamRequest, } from 'stream-chat'; import { ChannelService } from './channel.service'; import { ChannelQueryResult, + ChannelSort, ChannelQueryType, NextPageConfiguration, } from './types'; @@ -30,7 +31,7 @@ export class ChannelQuery { private chatService: ChatClientService, private channelService: ChannelService, private filters: ChannelFilters, - private sort: ChannelSort = { last_message_at: -1 }, + private sort: ChannelSort = [{ field: 'last_message_at', direction: -1 }], private options: ChannelOptions = { limit: 25, state: true, @@ -65,11 +66,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: toSortParams(this.sort), + }); this.setNextPageConfiguration(channels); const currentActiveChannel = this.channelService.activeChannel; @@ -78,16 +79,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 { @@ -110,3 +103,23 @@ export class ChannelQuery { } } } + +/** + * Converts the sort parameter to the format expected by stream-chat (`[{ field, direction }]`) + * @param sort + * @returns the sort in `SortParamRequest[]` format + */ +export const toSortParams = (sort?: ChannelSort): SortParamRequest[] => { + if (!sort) { + return []; + } + const items = Array.isArray(sort) ? sort : [sort]; + return items.flatMap((item) => { + if ('field' in item && typeof item.field === 'string') { + return [item as SortParamRequest]; + } + return Object.entries(item as Record).map( + ([field, direction]) => ({ field, direction }) + ); + }); +}; 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..d5803db6 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.spec.ts @@ -1,45 +1,56 @@ -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, 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 { AttachmentUpload, MessageInput, StreamMessage } from './types'; +import { convertStreamMessageToLocalMessage } from './message-dates'; +import { + AttachmentUpload, + ChannelSort, + 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, @@ -50,30 +61,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) => convertStreamMessageToLocalMessage(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, @@ -103,10 +134,30 @@ describe('ChannelService', () => { const sort: ChannelSort = { last_message_at: -1 }; await init(undefined, sort); - expect(mockChatClient.queryChannels).toHaveBeenCalledWith( - jasmine.any(Object), - sort, - jasmine.any(Object) + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ + sort: [{ field: 'last_message_at', direction: -1 }], + }) + ); + + mockChatClient.queryChannelsAndHydrate.calls.reset(); + service.reset(); + await init(undefined, [{ field: 'updated_at', direction: 1 }]); + + expect(mockChatClient.queryChannelsAndHydrate).toHaveBeenCalledWith( + jasmine.objectContaining({ + sort: [{ 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 +165,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 +173,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 +238,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 +259,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 +375,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 +409,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 +420,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 +439,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 +501,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 +537,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 +548,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 + ).toEqual(jasmine.any(Date)); expect(typingUsersSpy).toHaveBeenCalledWith([]); expect(typingUsersInThreadSpy).toHaveBeenCalledWith([]); expect(channelSwitchStateSpy.calls.count()).toBe(2); @@ -482,15 +571,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, 'ensureWatched').and.callThrough(); + service.setAsActiveChannel(channel); + + expect(channel.ensureWatched).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, 'ensureWatched').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).toEqual(jasmine.any(Date)); + }); + }); + + 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 +721,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 +975,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: nsToDate(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 +1110,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 +1129,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 +1146,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 +1173,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 +1195,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 +1211,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 +1251,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 +1294,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 +1315,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 +1362,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,85 +1375,119 @@ 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, 'ensureWatched').and.callThrough(); spyOn(newChannel, 'on').and.callThrough(); const spy = jasmine.createSpy(); 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.ensureWatched).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, 'ensureWatched').and.callThrough(); spyOn(channel, 'on').and.callThrough(); const spy = jasmine.createSpy(); 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]; expect(firstChannel.cid).toBe(channel.cid); - expect(channel.watch).toHaveBeenCalledWith(); + expect(channel.ensureWatched).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'; - channel.type = 'messaging'; mockChatClient.channel.and.returnValue(channel); - spyOn(channel, 'watch').and.callThrough(); + const error = new Error('watch failed'); + spyOn(channel, 'ensureWatched').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'; + mockChatClient.channel.and.returnValue(channel); + spyOn(channel, 'ensureWatched').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 +1503,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 +1527,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 +1555,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 +1576,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 +1608,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 +1636,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 +1657,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 +1674,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 +1690,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 +1824,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 +1851,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: convertStreamMessageToLocalMessage(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: { + ...convertStreamMessageToLocalMessage(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: convertStreamMessageToLocalMessage(message), + }); const spy = jasmine.createSpy(); service.beforeUpdateMessage = spy; spy.and.callFake((m: StreamMessage) => { @@ -1413,45 +1982,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: convertStreamMessageToLocalMessage(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 +2049,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 +2128,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 +2152,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 +2187,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 +2250,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 +2319,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 +2386,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 +2396,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 +2406,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 +2421,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 +2435,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 +2552,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 +2560,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 +2576,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 +2603,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 + const localMessage = getActiveChannel().messagePaginator.items![0]; + service.selectMessageToQuote(localMessage); + + expect(spy).toHaveBeenCalledWith( + jasmine.objectContaining({ + id: localMessage.id, + created_at: jasmine.any(Date), + }) + ); }); it('should deselect message to be quoted', async () => { @@ -1918,8 +2630,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 +2639,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 +2654,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 +2700,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 +2775,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 +2819,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 +2831,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 +2849,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 +2870,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 +2881,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 +2940,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 +2949,11 @@ describe('ChannelService', () => { await response; + expect(paginator.jumpToMessage).toHaveBeenCalledWith(messageId, { + pageSize: 25, + suppressFocusSignal: true, + }); + expect(jumpToMessageIdSpy).toHaveBeenCalledWith({ id: messageId, parentId: undefined, @@ -2194,6 +2965,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 +2993,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 +3009,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 +3314,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 +3336,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 +3368,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 +3414,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 +3441,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 +3462,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 +3472,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 +3518,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 +3526,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 +3542,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 +3554,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 +3569,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 +3588,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 +3601,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 +3647,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 +3660,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 +3673,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 +3695,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 +3712,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 +3734,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 +3760,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..bad43988 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,99 @@ 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, 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 { convertStreamMessageToLocalMessage } from './message-dates'; + +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 = convertStreamMessageToLocalMessage(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 +111,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 +126,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 +276,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(service.setAsActiveParentMessage).toHaveBeenCalledWith(undefined); + expect(parentMessageSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ id: parentMessage.id }) + ); + + expect( + getLatestValue(service.activeParentMessage$) + ?.id + ).toBe(parentMessage.id); }); it(`shouldn't deselect message to quote, if not a thread reply`, async () => { @@ -225,10 +365,38 @@ 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(); + expect(thread.messagePaginator.items).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 +405,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 +533,93 @@ 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( + convertStreamMessageToLocalMessage(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( + convertStreamMessageToLocalMessage(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]; + paginator.ingestItem({ + ...messageToDelete, + type: 'deleted', + deleted_at: dateToNs(new Date()), }); - 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).toEqual(jasmine.any(Date)); }); 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 +627,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 +652,7 @@ describe('ChannelService - threads', () => { service.customChannelTruncatedHandler = spy; const event = { type: 'channel.truncated', + cid: channel.cid, channel: { cid: channel.cid, name: 'New name', @@ -467,7 +667,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 +679,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 +740,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: new Date().toISOString(), + updated_at: new Date().toISOString(), + 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: new Date().toISOString(), + updated_at: new Date().toISOString(), + 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: new Date().toISOString(), + updated_at: new Date().toISOString(), + 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(Date)); }); 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 +958,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 +973,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 +1025,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 = convertStreamMessageToLocalMessage(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..a891e8a2 100644 --- a/projects/stream-chat-angular/src/lib/channel.service.ts +++ b/projects/stream-chat-angular/src/lib/channel.service.ts @@ -13,17 +13,20 @@ import { ChannelFilters, ChannelOptions, ChannelResponse, - ChannelSort, CustomMessageData, CustomReactionData, Event, - EventTypes, - FormatMessageResponse, + EventPayload, + EventType, + formatMessage, + nsToDate, LocalMessage, MemberFilters, - Message, + MessageIntervalPaginator, + MessageRequest, MessageResponse, ReactionResponse, + Thread, UserResponse, } from 'stream-chat'; import { ChatClientService, ClientEvent } from './chat-client.service'; @@ -40,9 +43,15 @@ import { MessageInput, MessageReactionType, NextPageConfiguration, + ChannelSort, + MessageListPaginationState, StreamMessage, } from './types'; import { ChannelQuery } from './channel-query'; +import { + convertMessageDatesToDate, + convertStreamMessageToLocalMessage, +} from './message-dates'; /** * The `ChannelService` provides data and interaction for the channel list and message list. @@ -81,6 +90,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. */ @@ -317,6 +334,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 +352,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 +383,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 +435,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 +527,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 +568,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 +598,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; @@ -575,6 +634,7 @@ export class ChannelService { return; } this.stopWatchForActiveChannelEvents(prevActiveChannel); + this.releaseThread(); this.flushMarkReadQueue(); this.areReadEventsPaused = false; this.isMessageLoadingInProgress = false; @@ -582,23 +642,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 +670,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 +695,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,86 +714,119 @@ 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) ?? + convertStreamMessageToLocalMessage(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. + thread.messagePaginator.state.partialNext({ + items: [], + 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; }); } @@ -751,7 +848,7 @@ export class ChannelService { this.chatClientService, this, filters, - sort || { last_message_at: -1 }, + sort || [{ field: 'last_message_at', direction: -1 }], { limit: 25, state: true, @@ -822,10 +919,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 +938,7 @@ export class ChannelService { async removeReaction(messageId: string, reactionType: MessageReactionType) { await this.activeChannelSubject .getValue() - ?.deleteReaction(messageId, reactionType); + ?.deleteReaction({ id: messageId, type: reactionType }); } /** @@ -871,7 +973,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 +982,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 +994,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 = convertStreamMessageToLocalMessage(message); + this.getMessagePaginator(message.parent_id)?.ingestItem({ + ...localMessage, + status: 'sending', + }); + return this.sendMessageRequest(localMessage, undefined, true); } /** @@ -912,6 +1012,7 @@ export class ChannelService { let messageToUpdate: StreamMessage = { ...message, }; + const originalMessage = message; if (messageToUpdate.quoted_message) { messageToUpdate.quoted_message = { ...messageToUpdate.quoted_message, @@ -931,21 +1032,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,28 +1064,21 @@ 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 }); } } @@ -1002,10 +1097,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 +1111,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, }); @@ -1077,10 +1172,10 @@ 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! })); } /** @@ -1105,8 +1200,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 @@ -1127,29 +1226,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 +1243,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 +1278,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 +1301,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 +1347,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 +1377,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 +1436,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 +1485,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 +1504,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 +1534,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 +1599,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 +1616,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 +1652,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.ensureWatched(); + } 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 +1669,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 +1719,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 +1753,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 = convertStreamMessageToLocalMessage(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.ensureWatched(); + } 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 @@ -1696,10 +1953,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 +2029,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 +2103,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 +2209,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 +2237,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 +2259,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 +2290,150 @@ 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 (message.created_at instanceof Date) { + // 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 converted = convertMessageDatesToDate(localMessage); + const streamMessage: StreamMessage = { + ...converted, + status: (localMessage as LocalMessage).status || 'received', + readBy: isThreadMessage + ? [] + : channel + ? getReadBy(converted, channel) + : [], + translation: getMessageTranslation(localMessage, channel, user), + errorStatusCode, + quoted_message: converted.quoted_message + ? { + ...converted.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 +2455,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 +2477,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 +2492,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 +2505,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 +2523,7 @@ export class ChannelService { if (isThrottled) { this.markReadThrottled(channel); } else { - void channel.markRead(); + void this.sendMarkReadRequest(channel); } } } @@ -2305,6 +2543,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 +2566,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..f6534aee 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; @@ -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, @@ -92,9 +112,9 @@ export class ChatClientService { */ 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,24 +182,48 @@ 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; } @@ -183,6 +235,7 @@ export class ChatClientService { await this.chatClient.disconnectUser(); this.userSubject.next(undefined); this.subscriptions.forEach((s) => s.unsubscribe()); + this.subscriptions = []; } /** @@ -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/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..7462564f 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 { StreamMessage } from './types'; +import { Channel, LocalMessage, MessageResponse } from 'stream-chat'; +import { MessageWithDates, StreamMessage } from './types'; export const getMessageTranslation = ( - message?: StreamMessage | MessageResponse | FormatMessageResponse, + message?: StreamMessage | MessageResponse | LocalMessage | MessageWithDates, 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/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/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-dates.ts b/projects/stream-chat-angular/src/lib/message-dates.ts new file mode 100644 index 00000000..b9203c10 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/message-dates.ts @@ -0,0 +1,84 @@ +import { + dateToNs, + LocalMessage, + MessageResponse, + nsToDate, + TimestampNS, +} from 'stream-chat'; +import { MessageDateFields, MessageWithDates, StreamMessage } from './types'; + +const toDate = (value?: TimestampNS | Date | null) => { + if (value === undefined || value === null) { + return undefined; + } + return value instanceof Date ? value : nsToDate(value); +}; + +const toNs = (value?: TimestampNS | Date | null) => { + if (value === undefined || value === null) { + return undefined; + } + return value instanceof Date ? dateToNs(value) : value; +}; + +/** + * stream-chat stores message dates as `TimestampNS` (unix nanoseconds), the SDK's `StreamMessage` type uses `Date` objects + * @param message + * @returns the message with `Date` date fields + */ +export const convertMessageDatesToDate = ( + message: T +): MessageWithDates & { + quoted_message?: MessageWithDates; +} => { + const { quoted_message, ...rest } = message; + return { + ...rest, + created_at: toDate(message.created_at) ?? new Date(), + updated_at: toDate(message.updated_at) ?? new Date(), + deleted_at: toDate(message.deleted_at), + pinned_at: toDate(message.pinned_at), + pin_expires: toDate(message.pin_expires), + message_text_updated_at: toDate(message.message_text_updated_at), + quoted_message: quoted_message + ? convertMessageDatesToDate(quoted_message) + : undefined, + } as MessageWithDates & { quoted_message?: MessageWithDates }; +}; + +/** + * Converts a `StreamMessage` back to the `LocalMessage` format stream-chat uses + * @param message + * @returns the message with `TimestampNS` date fields, and without SDK specific fields + */ +export const convertStreamMessageToLocalMessage = ( + message: StreamMessage | LocalMessage +): LocalMessage => { + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + readBy: _readBy, + translation: _translation, + errorStatusCode: _errorStatusCode, + quoted_message, + ...rest + } = message as Partial & Record; + /* eslint-enable @typescript-eslint/no-unused-vars */ + const dates = rest as Record< + MessageDateFields, + TimestampNS | Date | undefined + >; + return { + ...rest, + created_at: toNs(dates.created_at), + updated_at: toNs(dates.updated_at), + deleted_at: toNs(dates.deleted_at), + pinned_at: toNs(dates.pinned_at), + pin_expires: toNs(dates.pin_expires), + message_text_updated_at: toNs(dates.message_text_updated_at), + quoted_message: quoted_message + ? convertStreamMessageToLocalMessage( + quoted_message as unknown as StreamMessage + ) + : undefined, + } as unknown as LocalMessage; +}; 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..2b32f41f 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 @@ -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/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..fd219584 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'; @@ -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..c1723518 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 @@ -44,7 +44,7 @@ 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'); }); @@ -92,8 +92,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 +149,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 = new Date(); expect(getGroupStyles(messages[1], messages[0], messages[2])).toBe( 'bottom' 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..e1a9125c 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, 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({ @@ -239,7 +250,6 @@ describe('MessageListComponent', () => { message: newMessage, } ); - channelServiceMock.activeChannel?.state.messages.push(newMessage); 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, }); @@ -559,7 +576,7 @@ describe('MessageListComponent', () => { fixture.detectChanges(); const newMessage = mockMessage(); newMessage.created_at = new Date(); - newMessage.user!.id = 'not' + mockCurrentUser().id; + newMessage.user.id = 'not' + mockCurrentUser().id; channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -587,7 +604,6 @@ describe('MessageListComponent', () => { 'message.new', { message: newMessage } ); - channelServiceMock.activeChannel?.state.messages.push(newMessage); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -614,7 +630,6 @@ describe('MessageListComponent', () => { message: newMessage, } ); - channelServiceMock.activeChannel?.state.messages.push(newMessage); channelServiceMock.activeChannelMessages$.next([ ...channelServiceMock.activeChannelMessages$.getValue(), newMessage, @@ -668,7 +683,7 @@ describe('MessageListComponent', () => { const scrollContainer = queryScrollContainer()!; const newMessage = mockMessage(); newMessage.created_at = new Date(); - newMessage.user!.id = 'not' + mockCurrentUser().id; + newMessage.user.id = 'not' + mockCurrentUser().id; 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(); @@ -1115,7 +1132,7 @@ describe('MessageListComponent', () => { messages[messages.length - 2].created_at.setDate( messages[messages.length - 2].created_at.getDate() + 1 ); - 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; @@ -1134,7 +1151,7 @@ describe('MessageListComponent', () => { messages[messages.length - 2].created_at.setDate( messages[messages.length - 2].created_at.getDate() + 1 ); - 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; @@ -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; @@ -1205,7 +1222,7 @@ describe('MessageListComponent', () => { messages[messages.length - 2].created_at.setDate( messages[messages.length - 2].created_at.getDate() + 1 ); - 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; @@ -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..b60e1d28 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 @@ -414,6 +414,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 +445,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) { @@ -591,6 +598,7 @@ export class MessageListComponent if (this.isUserScrolled !== isUserScrolled) { this.ngZone.run(() => { this.isUserScrolled = isUserScrolled; + this.updateViewingLive(); if (!this.isUserScrolled) { this.newMessageCountWhileBeingScrolled = 0; } @@ -628,6 +636,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 +752,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 +802,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 +869,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( 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.service.ts b/projects/stream-chat-angular/src/lib/message-reactions.service.ts index 5fd4edf0..8e4728b5 100644 --- a/projects/stream-chat-angular/src/lib/message-reactions.service.ts +++ b/projects/stream-chat-angular/src/lib/message-reactions.service.ts @@ -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..ec81523d 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'; @@ -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..f0d76fc2 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 }} diff --git a/projects/stream-chat-angular/src/lib/message/message.component.spec.ts b/projects/stream-chat-angular/src/lib/message/message.component.spec.ts index 5067e0d2..6b2452fe 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message/message.component.spec.ts @@ -183,14 +183,14 @@ describe('MessageComponent', () => { component.message = { ...component.message, ...{ reaction_groups: { wow: { count: 1, sum_scores: 1 } } }, - } as StreamMessage; + } as unknown as StreamMessage; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); const container = queryContainer(); let classList = container?.classList; expect( - classList?.contains(`str-chat__message--${message.type as string}`) + classList?.contains(`str-chat__message--${message.type}`) ).toBeTrue(); expect( @@ -205,7 +205,10 @@ describe('MessageComponent', () => { classList?.contains('str-chat__message-with-thread-link') ).toBeFalse(); - component.message.user = { id: 'notcurrentUser', name: 'Jane' }; + component.message.user = { + id: 'notcurrentUser', + name: 'Jane', + } as UserResponse; component.message.reaction_groups = {}; component.message.reply_count = 3; component.ngOnChanges({ message: {} as SimpleChange }); @@ -281,7 +284,7 @@ describe('MessageComponent', () => { expect(queryDeliveredIndicator()).toBeNull(); expect(queryReadIndicator()).toBeNull(); - message.user = { id: 'notcurrentuser', name: 'Daniel' }; + message.user = { id: 'notcurrentuser', name: 'Daniel' } as UserResponse; fixture.detectChanges(); expect(querySendingIndicator()).toBeNull(); @@ -295,7 +298,13 @@ describe('MessageComponent', () => { const senderImage = 'link/to/profile/photo'; component.message = { ...message, - ...{ user: { id: 'id', name: senderName, image: senderImage } }, + ...{ + user: { + id: 'id', + name: senderName, + image: senderImage, + } as UserResponse, + }, }; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); @@ -305,7 +314,7 @@ describe('MessageComponent', () => { expect(avatar.imageUrl).toContain(senderImage); expect(avatar.type).toBe('user'); expect(avatar.location).toBe('message-sender'); - expect(avatar.user).toBe(component.message.user!); + expect(avatar.user).toBe(component.message.user); }); it('should display text message', () => { @@ -389,16 +398,10 @@ describe('MessageComponent', () => { component.message = { ...message, - moderation_details: { + moderation: { 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', + action: 'bounce', + blocklists_matched: ['hammurabi_filter'], }, type: 'error', }; @@ -419,7 +422,7 @@ describe('MessageComponent', () => { }); it('should display message sender and date', () => { - const sender = { id: 'sender', name: 'Jack' }; + const sender = { id: 'sender', name: 'Jack' } as UserResponse; component.message = { ...message, ...{ user: sender } }; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); @@ -636,7 +639,10 @@ describe('MessageComponent', () => { expect(messageActionsBoxComponent.isMine).toBeTrue(); - component.message = { ...message, ...{ user: { id: 'notcurrentuser' } } }; + component.message = { + ...message, + ...{ user: { id: 'notcurrentuser' } as UserResponse }, + }; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); @@ -663,7 +669,7 @@ describe('MessageComponent', () => { expect(queryAttachmentComponent()).toBeUndefined(); - const attachments = [{ image_url: 'image/url' }]; + const attachments = [{ image_url: 'image/url', custom: {} }]; component.message = { ...message, ...{ attachments }, @@ -880,8 +886,11 @@ describe('MessageComponent', () => { id: 'sara', name: 'Sara', image: 'http://url/to/img', - }; - quotedMessage.attachments = [{ image_url: '1' }, { image_url: '2' }]; + } as UserResponse; + quotedMessage.attachments = [ + { image_url: '1', custom: {} }, + { image_url: '2', custom: {} }, + ]; quotedMessage.text = 'This message was quoted'; component.message = { ...component.message!, @@ -904,15 +913,15 @@ describe('MessageComponent', () => { .query(By.directive(AttachmentListComponent)) .componentInstance as AttachmentListComponent; - expect(avatar.name).toBe(component.message!.quoted_message!.user!.name); + expect(avatar.name).toBe(component.message!.quoted_message!.user.name); expect(avatar.imageUrl).toBe( - component.message!.quoted_message!.user!.image + component.message!.quoted_message!.user.image ); expect(avatar.type).toBe('user'); expect(avatar.location).toBe('quoted-message-sender'); - expect(avatar.user).toBe(component.message!.quoted_message!.user!); - expect(attachments.attachments).toEqual([{ image_url: '1' }]); + expect(avatar.user).toBe(component.message!.quoted_message!.user); + expect(attachments.attachments).toEqual([{ image_url: '1', custom: {} }]); expect( nativeElement.querySelector('[data-testid="quoted-message-text"]') ?.innerHTML @@ -934,7 +943,10 @@ describe('MessageComponent', () => { expect(quotedMessageContainer?.classList).toContain('mine'); - component.message = { ...component.message!, user: { id: 'otheruser' } }; + component.message = { + ...component.message!, + user: { id: 'otheruser' } as UserResponse, + }; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); @@ -943,9 +955,9 @@ describe('MessageComponent', () => { it('should display reply if we reply with attachments without text', () => { component.message!.attachments = [ - { image_url: 'http://url/to/image', type: 'image' }, + { image_url: 'http://url/to/image', type: 'image', custom: {} }, ]; - component.message!.text = undefined; + component.message!.text = ''; component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); @@ -1010,7 +1022,7 @@ describe('MessageComponent', () => { }); it(`should display edited flag if message was edited, edited info should be collapsed`, () => { - component.message!.message_text_updated_at = new Date().toISOString(); + component.message!.message_text_updated_at = new Date(); component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); @@ -1024,7 +1036,7 @@ describe('MessageComponent', () => { }); it(`should display edited flag if message was edited, display timestamp if clicked`, () => { - component.message!.message_text_updated_at = new Date().toISOString(); + component.message!.message_text_updated_at = new Date(); component.ngOnChanges({ message: {} as SimpleChange }); fixture.detectChanges(); queryMessageInner()?.click(); @@ -1045,22 +1057,6 @@ describe('MessageComponent', () => { ?.componentInstance as MessageBlockedComponent; }); - it('should display blocked message component when moderation_details.action is MESSAGE_RESPONSE_ACTION_REMOVE', () => { - component.message = { - ...mockMessage(), - type: 'error', - moderation_details: { - action: 'MESSAGE_RESPONSE_ACTION_REMOVE', - }, - } as StreamMessage; - component.ngOnChanges({ message: {} as SimpleChange }); - fixture.detectChanges(); - - const blockedMessageComponent = queryBlockedMessageComponent(); - expect(blockedMessageComponent).not.toBeNull(); - expect(queryContainer()).toBeNull(); - }); - it('should display blocked message component when moderation.action is remove', () => { component.message = { ...mockMessage(), @@ -1081,8 +1077,8 @@ describe('MessageComponent', () => { const blockedMessage = { ...mockMessage(), type: 'error', - moderation_details: { - action: 'MESSAGE_RESPONSE_ACTION_REMOVE', + moderation: { + action: 'remove', }, } as StreamMessage; diff --git a/projects/stream-chat-angular/src/lib/message/message.component.ts b/projects/stream-chat-angular/src/lib/message/message.component.ts index 3653329e..0a2a991d 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.ts +++ b/projects/stream-chat-angular/src/lib/message/message.component.ts @@ -444,16 +444,20 @@ export class MessageComponent }; } + get isBounced() { + return ( + this.message?.type === 'error' && + this.message?.moderation?.action === 'bounce' + ); + } + messageClicked() { if ( this.message?.status === 'failed' && this.message?.errorStatusCode !== 403 ) { this.resendMessage(); - } else if ( - this.message?.type === 'error' && - this.message?.moderation_details - ) { + } else if (this.isBounced) { this.openMessageBouncePrompt(); } else { this.isEditedFlagOpened = !this.isEditedFlagOpened; diff --git a/projects/stream-chat-angular/src/lib/mocks/index.ts b/projects/stream-chat-angular/src/lib/mocks/index.ts index afb82b50..36e1fc1f 100644 --- a/projects/stream-chat-angular/src/lib/mocks/index.ts +++ b/projects/stream-chat-angular/src/lib/mocks/index.ts @@ -1,14 +1,16 @@ import { ChannelQueryState, StreamMessage } from '../types'; import { BehaviorSubject, ReplaySubject, Subject } from 'rxjs'; import { - AppSettings, Channel, ChannelMemberResponse, Event, - EventTypes, + EventType, + LocalMessage, MessageResponse, UserResponse, } from 'stream-chat'; +import { AppSettings } from '../types'; +import { convertStreamMessageToLocalMessage } from '../message-dates'; export const mockCurrentUser = () => ({ @@ -16,7 +18,7 @@ export const mockCurrentUser = () => name: 'Bob', image: 'link/to/photo', total_unread_count: 0, - } as UserResponse); + } as unknown as UserResponse); export const mockMessage = (id?: number) => ({ @@ -29,6 +31,7 @@ export const mockMessage = (id?: number) => created_at: new Date('2021-09-14T13:08:30.004112Z'), updated_at: new Date('2021-09-14T13:08:30.004112Z'), readBy: [{ id: 'alice', name: 'Alice' }], + custom: {}, } as any as StreamMessage); export const generateMockMessages = (offset = 0, isOlder = false) => { @@ -45,23 +48,306 @@ export const generateMockMessages = (offset = 0, isOlder = false) => { return messages; }; +/** + * The same messages as `generateMockMessages`, but in the format stream-chat stores them (`LocalMessage` with `TimestampNS` dates) + */ +export const generateMockLocalMessages = (offset = 0, isOlder = false) => + generateMockMessages(offset, isOlder).map((m) => + convertStreamMessageToLocalMessage(m) + ); + +/** + * Minimal implementation of the `StateStore` API (`@stream-io/state-store`) used by the SDK + */ +export class MockStateStore { + private handlers: ((value: T, prev?: T) => void)[] = []; + + constructor(private value: T) {} + + getLatestValue = () => this.value; + + next = (value: T) => { + const prev = this.value; + this.value = value; + this.handlers.forEach((h) => h(value, prev)); + }; + + partialNext = (partial: Partial) => + this.next({ ...this.value, ...partial }); + + subscribe = (handler: (value: T, prev?: T) => void) => { + this.handlers.push(handler); + handler(this.value, undefined); + return () => { + this.handlers = this.handlers.filter((h) => h !== handler); + }; + }; + + subscribeWithSelector = ( + selector: (value: T) => O, + handler: (value: O, prev?: O) => void + ) => { + let prevSelected: O | undefined; + return this.subscribe((value) => { + const selected = selector(value); + if ( + prevSelected && + Object.keys(selected).every( + (k) => + (selected as Record)[k] === + (prevSelected as Record)[k] + ) + ) { + return; + } + const prev = prevSelected; + prevSelected = selected; + handler(selected, prev); + }); + }; +} + +export type MockMessagePaginatorState = { + items: LocalMessage[] | undefined; + hasMoreTail: boolean; + hasMoreHead: boolean; + isLoading: boolean; + lastQueryError?: Error; +}; + +/** + * Minimal implementation of stream-chat's `MessagePaginator` (only the parts used by the SDK) + */ +export class MockMessagePaginator { + state: MockStateStore; + aggregateState = new MockStateStore<{ latestMessage: LocalMessage | null }>({ + latestMessage: null, + }); + messageFocusSignal = new MockStateStore<{ + signal: { messageId: string } | null; + }>({ signal: null }); + unreadStateSnapshot = new MockStateStore<{ + firstUnreadMessageId: string | null; + }>({ firstUnreadMessageId: null }); + isViewingLive = false; + /** + * Called by toTail and toHead, override in tests to simulate loading pages + */ + loadPage: ( + direction: 'tail' | 'head' + ) => LocalMessage[] | Promise = () => []; + /** + * Called by jumpToMessage, override in tests to simulate jumping + */ + loadAround: ( + messageId: string + ) => LocalMessage[] | undefined | Promise = ( + messageId + ) => { + const messages = generateMockLocalMessages(); + messages[Math.round(messages.length / 2)].id = messageId; + return messages; + }; + + constructor(items: LocalMessage[] | undefined = []) { + this.state = new MockStateStore({ + items, + hasMoreTail: true, + hasMoreHead: false, + isLoading: false, + lastQueryError: undefined, + }); + this.updateLastMessage(); + } + + get items() { + return this.state.getLatestValue().items; + } + + get hasMoreTail() { + return this.state.getLatestValue().hasMoreTail; + } + + get hasMoreHead() { + return this.state.getLatestValue().hasMoreHead; + } + + get isLoading() { + return this.state.getLatestValue().isLoading; + } + + get lastQueryError() { + return this.state.getLatestValue().lastQueryError; + } + + get headItems() { + return this.items || []; + } + + get anchoredHeadItem() { + return this.items?.[this.items.length - 1]; + } + + get latestMessage() { + return this.aggregateState.getLatestValue().latestMessage; + } + + setItems = (items: LocalMessage[]) => { + this.state.partialNext({ items }); + this.updateLastMessage(); + }; + + getItem = (id: string) => this.items?.find((m) => m.id === id); + + /** + * If set, only messages with this cid are accepted (stream-chat's message paginator filters by cid) + */ + requiredCid?: string; + + ingestItem = (message: LocalMessage) => { + if (this.requiredCid && message.cid !== this.requiredCid) { + return false; + } + const items = [...(this.items || [])]; + const index = items.findIndex((m) => m.id === message.id); + if (index === -1) { + items.push(message); + } else { + items[index] = message; + } + this.setItems(items); + return true; + }; + + removeItem = ({ id }: { id: string }) => { + this.setItems((this.items || []).filter((m) => m.id !== id)); + return { state: this.state.getLatestValue() }; + }; + + flushState = () => {}; + + setViewingLive = (isViewingLive: boolean) => { + this.isViewingLive = isViewingLive; + }; + + toTail = async () => { + const page = await this.loadPage('tail'); + this.setItems([...page, ...(this.items || [])]); + }; + + toHead = async () => { + const page = await this.loadPage('head'); + this.setItems([...(this.items || []), ...page]); + }; + + reload = async () => {}; + + jumpToMessage = async (messageId: string, _?: unknown) => { + const messages = await this.loadAround(messageId); + if (!messages) { + return false; + } + this.state.partialNext({ items: messages, hasMoreHead: true }); + this.messageFocusSignal.next({ signal: { messageId } }); + return true; + }; + + jumpToTheLatestMessage = (_?: unknown) => { + this.state.partialNext({ hasMoreHead: false }); + return Promise.resolve(true); + }; + + jumpToTheFirstUnreadMessage = (_?: unknown) => Promise.resolve(false); + + private updateLastMessage() { + const items = this.items || []; + this.aggregateState.next({ + latestMessage: items[items.length - 1] ?? null, + }); + } +} + +export type MockThread = { + id: string; + channel: Channel; + messagePaginator: MockMessagePaginator; + state: MockStateStore<{ + parentMessage: LocalMessage; + isLoading: boolean; + isStateStale: boolean; + active: boolean; + replyCount: number; + }>; + hasStaleState: boolean; + reload: () => Promise; + activate: () => () => void; +}; + +export const mockThread = (channel: Channel, parentMessage: LocalMessage) => { + const thread: MockThread = { + id: parentMessage.id, + channel, + messagePaginator: new MockMessagePaginator( + generateMockLocalMessages().map((m) => ({ + ...m, + parent_id: parentMessage.id, + })) + ), + state: new MockStateStore< + MockThread['state'] extends MockStateStore ? S : never + >({ + parentMessage, + isLoading: false, + isStateStale: false, + active: false, + replyCount: 25, + }), + hasStaleState: false, + reload: () => Promise.resolve(), + activate: () => { + thread.state.partialNext({ active: true }); + return () => thread.state.partialNext({ active: false }); + }, + }; + return thread; +}; + export type MockChannel = Channel & { handleEvent: ( - name: EventTypes | 'capabilities.changed', + name: EventType | 'capabilities.changed', payload?: any ) => void; + messagePaginator: MockMessagePaginator; + pinnedMessagesPaginator: MockMessagePaginator; }; export const generateMockChannels = (length = 25) => { const channels = Array.from({ length }, (_, index) => { const eventHandlers: { [key: string]: Function } = {}; + const state = new MockStateStore({ + read: {} as Channel['state']['read'], + }) as unknown as Channel['state'] & MockStateStore; + Object.assign(state, { + read: {}, + typing: {}, + watcher_count: 0, + members: { + jack: { user: { id: 'jack', name: 'Jack' } }, + sara: { user: { id: 'sara', name: 'Sara' } }, + eddie: { user: { id: 'eddie' } }, + }, + }); const channel = { cid: 'cid' + index.toString(), id: index.toString(), + type: 'messaging', + initialized: true, data: { id: index.toString(), - name: `Channel${index}`, - image: 'link/to/image', + custom: { + name: `Channel${index}`, + image: 'link/to/image', + }, own_capabilities: [ 'upload-file', 'flag-message', @@ -74,7 +360,21 @@ export const generateMockChannels = (length = 25) => { 'typing-events', ], }, - on: (arg1: EventTypes | Function, handler: () => {}) => { + config: { + readEvents: { enabled: true }, + typingEvents: { enabled: true }, + availableCommands: [ + { + args: '[text]', + description: 'Post a random gif to the channel', + name: 'giphy', + set: 'fun_set', + }, + ], + }, + messagePaginator: new MockMessagePaginator(generateMockLocalMessages()), + pinnedMessagesPaginator: new MockMessagePaginator([]), + on: (arg1: EventType | Function, handler: () => {}) => { eventHandlers[typeof arg1 === 'string' ? (arg1 as string) : 'on'] = handler || arg1; return { @@ -83,98 +383,32 @@ export const generateMockChannels = (length = 25) => { () => {}), }; }, + activate: () => () => {}, watch: () => Promise.resolve(), + ensureWatched: () => Promise.resolve(channel), stopWatching: () => Promise.resolve(), - sendMessage: (m: any) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - return Promise.resolve({ message: m }); + sendMessage: (request: { message: MessageResponse }) => { + return Promise.resolve({ message: request.message }); }, - sendImage: () => {}, - sendFile: () => {}, + uploadImage: () => {}, + uploadFile: () => {}, sendAction: () => {}, deleteImage: () => {}, deleteFile: () => {}, countUnread: () => 3, - markRead: () => {}, - getReplies: () => {}, + markReadViaReporter: () => Promise.resolve(), + getMessagesById: () => Promise.resolve({ messages: [] }), keystroke: () => {}, stopTyping: () => {}, - handleEvent: (name: EventTypes, payload?: any) => { + handleEvent: (name: EventType, payload?: any) => { if (eventHandlers[name as string]) { eventHandlers[name as string](payload as StreamMessage); } else { - eventHandlers['on'](payload); + eventHandlers['on']({ type: name, ...payload }); } }, - getReactions: () => {}, markUnread: () => {}, - state: { - messages: generateMockMessages(), - pinnedMessages: [], - threads: {}, - read: {}, - members: { - jack: { user: { id: 'jack', name: 'Jack' } }, - sara: { user: { id: 'sara', name: 'Sara' } }, - eddie: { user: { id: 'eddie' } }, - }, - addMessageSorted: function (response: MessageResponse) { - if (!response) { - return; - } - let array: StreamMessage[]; - const message = response as any as StreamMessage; - const threads = this.threads as { [key: string]: StreamMessage[] }; - if (message.parent_id) { - if (threads[message.parent_id]) { - array = threads[message.parent_id]; - } else { - array = []; - threads[message.parent_id] = array; - } - } else { - array = this.messages; - } - const existingMessageIndex = array.findIndex((m) => - message.id - ? m.id === message.id - : m.created_at === message.created_at - ); - if (existingMessageIndex === -1) { - array.push(message); - } else { - array[existingMessageIndex] = message; - } - }, - removeMessage: () => {}, - loadMessageIntoState: function ( - messageId: string, - parentMessageId: string - ) { - const surroundingMessages = generateMockMessages(); - const loadedMessage = - surroundingMessages[Math.round(surroundingMessages.length / 2)]; - loadedMessage.id = parentMessageId || messageId; - this.messages = surroundingMessages; - if (parentMessageId) { - const surroundingThreadMessages = generateMockMessages(); - const loadedThreadMessage = - surroundingThreadMessages[ - Math.round(surroundingThreadMessages.length / 2) - ]; - loadedThreadMessage.id = messageId; - (this.threads as { [key: string]: StreamMessage[] })[ - parentMessageId - ] = surroundingThreadMessages; - } - return Promise.resolve(); - }, - }, - query: () => { - return Promise.resolve({ - messages: generateMockMessages(channel.state.messages.length), - }); - }, + state, sendReaction: () => {}, deleteReaction: () => {}, queryMembers: () => ({ @@ -191,18 +425,7 @@ export const generateMockChannels = (length = 25) => { }, }, }), - getConfig: () => ({ - commands: [ - { - args: '[text]', - description: 'Post a random gif to the channel', - name: 'giphy', - set: 'fun_set', - }, - ], - }), } as any as MockChannel; - channel.state.latestMessages = channel.state.messages; return channel; }); @@ -210,12 +433,12 @@ export const generateMockChannels = (length = 25) => { }; export const generateMockChannel = (type: string, id: string) => { - const channel = generateMockChannels(1)[0]; + const channel = generateMockChannels(1)[0] as any as Record; channel.type = type; channel.id = id; channel.cid = `${type}:${id}`; - return channel; + return channel as any as MockChannel; }; export type MockChannelService = { @@ -243,10 +466,30 @@ export type MockChannelService = { setAsActiveParentMessage: (m: StreamMessage | undefined) => void; loadMoreThreadReplies: (d: 'older' | 'newer') => void; autocompleteMembers: (s: string) => ChannelMemberResponse[]; + activeChannelMessagesPaginationState$: BehaviorSubject<{ + hasMoreOlder: boolean; + hasMoreNewer: boolean; + isLoading: boolean; + error?: Error; + }>; + activeThreadMessagesPaginationState$: BehaviorSubject<{ + hasMoreOlder: boolean; + hasMoreNewer: boolean; + isLoading: boolean; + error?: Error; + }>; + jumpToFirstUnreadMessage: () => Promise; jumpToMessage: (id: string, parentId?: string) => void; clearMessageJump: () => void; }; +type MockPaginationState = { + hasMoreOlder: boolean; + hasMoreNewer: boolean; + isLoading: boolean; + error?: Error; +}; + export const mockChannelService = (): MockChannelService => { const messages = generateMockMessages(); const activeChannelMessages$ = new BehaviorSubject(messages); @@ -260,8 +503,9 @@ export const mockChannelService = (): MockChannelService => { const usersTypingInChannel$ = new BehaviorSubject([]); const usersTypingInThread$ = new BehaviorSubject([]); const activeChannel = generateMockChannels(1)[0]; - activeChannel.state.messages = messages; - activeChannel.state.latestMessages = messages; + activeChannel.messagePaginator.setItems( + messages.map((m) => convertStreamMessageToLocalMessage(m)) + ); const activeChannelMessages = messages; const activeChannel$ = new BehaviorSubject(activeChannel); const channels$ = new BehaviorSubject(undefined); @@ -271,11 +515,12 @@ export const mockChannelService = (): MockChannelService => { parentId?: string; }>({ id: undefined, parentId: undefined }); - const autocompleteMembers = () => [ - { user: { id: 'jack', name: 'Jack' } }, - { user: { id: 'sara', name: 'Sara' } }, - { user: { id: 'eddie' } }, - ]; + const autocompleteMembers = () => + [ + { user: { id: 'jack', name: 'Jack' } }, + { user: { id: 'sara', name: 'Sara' } }, + { user: { id: 'eddie' } }, + ] as unknown as ChannelMemberResponse[]; const loadMoreMessages = () => { const currentMessages = activeChannelMessages$.getValue(); @@ -335,6 +580,19 @@ export const mockChannelService = (): MockChannelService => { clearMessageJump, channelQueryState$, activeChannel, + activeChannelMessagesPaginationState$: + new BehaviorSubject({ + hasMoreOlder: true, + hasMoreNewer: false, + isLoading: false, + }), + activeThreadMessagesPaginationState$: + new BehaviorSubject({ + hasMoreOlder: true, + hasMoreNewer: false, + isLoading: false, + }), + jumpToFirstUnreadMessage: () => Promise.resolve(true), }; }; @@ -344,9 +602,28 @@ export type MockStreamChatClient = { user: UserResponse; connectUser: jasmine.Spy; setGuestUser: jasmine.Spy; - on: (name: EventTypes, handler: () => {}) => { unsubscribe: () => void }; - handleEvent: (name: EventTypes, event: Event) => void; - flagMessage: jasmine.Spy; + on: (name: EventType, handler: () => {}) => { unsubscribe: () => void }; + handleEvent: (name: EventType, event: Event | Record) => void; + moderation: { flagMessage: jasmine.Spy }; + threads: { + get: (id: string) => MockThread | undefined; + ensure: (options: { + channel: Channel; + parentMessage: LocalMessage; + }) => MockThread; + registerSubscriptions: jasmine.Spy; + }; + channelManager: { get: (cid: string) => Channel | undefined }; + config: { setConfig: jasmine.Spy }; + wsConnection: { state: MockStateStore<{ isHealthy: boolean }> }; + queryChannelsAndHydrate: jasmine.Spy; + getReactions: jasmine.Spy; + queryReactions: jasmine.Spy; + updateMessage: jasmine.Spy; + deleteMessage: jasmine.Spy; + pinMessage: jasmine.Spy; + unpinMessage: jasmine.Spy; + userID: string; queryUsers: jasmine.Spy; getAppSettings: jasmine.Spy; disconnectUser: jasmine.Spy; @@ -361,6 +638,20 @@ export const mockStreamChatClient = (): MockStreamChatClient => { const connectUser = jasmine.createSpy(); const setGuestUser = jasmine.createSpy(); const flagMessage = jasmine.createSpy(); + const threadsById: { [key: string]: MockThread } = {}; + const threads = { + get: (id: string) => threadsById[id], + ensure: (options: { channel: Channel; parentMessage: LocalMessage }) => { + if (!threadsById[options.parentMessage.id]) { + threadsById[options.parentMessage.id] = mockThread( + options.channel, + options.parentMessage + ); + } + return threadsById[options.parentMessage.id]; + }, + registerSubscriptions: jasmine.createSpy(), + }; const queryUsers = jasmine.createSpy(); const queryChannels = jasmine.createSpy().and.returnValue([]); const getAppSettings = jasmine.createSpy().and.resolveTo({ @@ -382,7 +673,7 @@ export const mockStreamChatClient = (): MockStreamChatClient => { const disconnectUser = jasmine.createSpy(); const connectAnonymousUser = jasmine.createSpy(); const user = mockCurrentUser(); - const on = (name: EventTypes | Function, handler: () => {}) => { + const on = (name: EventType | Function, handler: () => {}) => { if (typeof name === 'string') { eventHandlers[name as string] = handler; } else { @@ -393,7 +684,7 @@ export const mockStreamChatClient = (): MockStreamChatClient => { delete eventHandlers[typeof name === 'string' ? name : 'all'], }; }; - const handleEvent = (name: EventTypes, event: Event) => { + const handleEvent = (name: EventType, event: Event | Record) => { if (eventHandlers[name as string]) { eventHandlers[name as string](event); } else if (eventHandlers['all']) { @@ -411,7 +702,21 @@ export const mockStreamChatClient = (): MockStreamChatClient => { user, on, handleEvent, - flagMessage, + moderation: { flagMessage }, + threads, + channelManager: { get: () => undefined }, + config: { setConfig: jasmine.createSpy() }, + wsConnection: { + state: new MockStateStore<{ isHealthy: boolean }>({ isHealthy: true }), + }, + queryChannelsAndHydrate: queryChannels, + getReactions: jasmine.createSpy(), + queryReactions: jasmine.createSpy(), + updateMessage: jasmine.createSpy(), + deleteMessage: jasmine.createSpy(), + pinMessage: jasmine.createSpy(), + unpinMessage: jasmine.createSpy(), + userID: user.id, queryUsers, getAppSettings, appSettings$, @@ -433,43 +738,45 @@ export const mockVoiceRecording = { title: '99831FB3-5E35-41CD-8286-7A2E95A78FDC.aac', asset_url: 'https://us-east.stream-io-cdn.com/102399/attachments/7389dd4b-001f-43c9-8fe3-ba38930202e3.messaging-7B537E48-D-879666ea-5e07-499d-.aac?Key-Pair-Id=APKAIHG36VEWPDULE23Q&Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly91cy1lYXN0LnN0cmVhbS1pby1jZG4uY29tLzEwMjM5OS9hdHRhY2htZW50cy83Mzg5ZGQ0Yi0wMDFmLTQzYzktOGZlMy1iYTM4OTMwMjAyZTMubWVzc2FnaW5nLTdCNTM3RTQ4LUQtODc5NjY2ZWEtNWUwNy00OTlkLS5hYWMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNzExOTgyMzIyfX19XX0_&Signature=dfonQbBUmN04RHp2EiZ2cF8sYIKxaQ90jp8IgsLHQk8TidwhSdkANANqQV9mtkhQf~NhZ3AysJUCOWtG0HJHIaNHqp8ezaeqmTZE01BqyVrNgiJrrn-8v1pCwKmm73jroi3zgmTprjwnXmEqXkUSDfhtiYYW20ckfcdI7tfPN1rCqWE9txWumoRsVaVOu2i3HUqbTVb7h~gn7624mgAx4eX2in4koM29ovck5-o5zEbw~En8-u7zAllNEYRQ8JPHQgvOojfAMPUXeLdnbXDX2uft9-FlIHRkrzTglPRM6sBCJWLRRM3GNBE2OwraVXUJHSYgbugNat7xyd6StUH11Q__', - waveform_data: [ - 0.18580253422260284, 0.22792305052280426, 0.23014479875564575, - 0.2215365618467331, 0.280057817697525, 0.24526794254779816, - 0.23399925231933594, 0.3128012716770172, 0.37945452332496643, - 0.33992674946784973, 0.33231520652770996, 0.35996779799461365, - 0.24952147901058197, 0.2771506607532501, 0.18567748367786407, - 0.2669936418533325, 0.2318495213985443, 0.266754150390625, - 0.24599547684192657, 0.22025825083255768, 0.2574225664138794, - 0.2297886610031128, 0.21858176589012146, 0.22855964303016663, - 0.21926963329315186, 0.1592704802751541, 0.17932571470737457, - 0.17183426022529602, 0.16925041377544403, 0.17725585401058197, - 0.20863258838653564, 0.15763725340366364, 0.17838287353515625, - 0.2596689462661743, 0.2821672856807709, 0.2308555543422699, - 0.21800407767295837, 0.237264484167099, 0.24341285228729248, - 0.21091987192630768, 0.22752471268177032, 0.28887689113616943, - 0.25566428899765015, 0.2714449167251587, 0.30015745759010315, - 0.2883681356906891, 0.28020721673965454, 0.21566811203956604, - 0.2041303962469101, 0.1856091320514679, 0.22570304572582245, - 0.22362418472766876, 0.18938171863555908, 0.2105405479669571, - 0.22487449645996094, 0.2667095959186554, 0.29159706830978394, - 0.2825489938259125, 0.2347666174173355, 0.2768406569957733, - 0.2953290641307831, 0.26249200105667114, 0.3034127950668335, - 0.26899436116218567, 0.2931807041168213, 0.24274344742298126, - 0.23544998466968536, 0.29423996806144714, 0.2060961127281189, - 0.05098915100097656, 0.0063146972097456455, 0.015633393079042435, - 0.03022758476436138, 0.026124343276023865, 0.014958725310862064, - 0.01488060038536787, 0.0297449491918087, 0.046876221895217896, - 0.016678161919116974, 0.030463561415672302, 0.022851256653666496, - 0.040972135961055756, 0.050609588623046875, 0.04940780624747276, - 0.04304153472185135, 0.08744628727436066, 0.0762052908539772, - 0.0801474004983902, 0.0818980410695076, 0.08521285653114319, - 0.07830711454153061, 0.07637504488229752, 0.09124496579170227, - 0.16371025145053864, 0.08206146210432053, 0.0569305419921875, - 0.043944090604782104, 0.07224594056606293, 0.06922554224729538, - 5.340576194612368e-7, - ], - duration: 21.07675, - file_size: 56504, - mime_type: 'audio/aac', + custom: { + waveform_data: [ + 0.18580253422260284, 0.22792305052280426, 0.23014479875564575, + 0.2215365618467331, 0.280057817697525, 0.24526794254779816, + 0.23399925231933594, 0.3128012716770172, 0.37945452332496643, + 0.33992674946784973, 0.33231520652770996, 0.35996779799461365, + 0.24952147901058197, 0.2771506607532501, 0.18567748367786407, + 0.2669936418533325, 0.2318495213985443, 0.266754150390625, + 0.24599547684192657, 0.22025825083255768, 0.2574225664138794, + 0.2297886610031128, 0.21858176589012146, 0.22855964303016663, + 0.21926963329315186, 0.1592704802751541, 0.17932571470737457, + 0.17183426022529602, 0.16925041377544403, 0.17725585401058197, + 0.20863258838653564, 0.15763725340366364, 0.17838287353515625, + 0.2596689462661743, 0.2821672856807709, 0.2308555543422699, + 0.21800407767295837, 0.237264484167099, 0.24341285228729248, + 0.21091987192630768, 0.22752471268177032, 0.28887689113616943, + 0.25566428899765015, 0.2714449167251587, 0.30015745759010315, + 0.2883681356906891, 0.28020721673965454, 0.21566811203956604, + 0.2041303962469101, 0.1856091320514679, 0.22570304572582245, + 0.22362418472766876, 0.18938171863555908, 0.2105405479669571, + 0.22487449645996094, 0.2667095959186554, 0.29159706830978394, + 0.2825489938259125, 0.2347666174173355, 0.2768406569957733, + 0.2953290641307831, 0.26249200105667114, 0.3034127950668335, + 0.26899436116218567, 0.2931807041168213, 0.24274344742298126, + 0.23544998466968536, 0.29423996806144714, 0.2060961127281189, + 0.05098915100097656, 0.0063146972097456455, 0.015633393079042435, + 0.03022758476436138, 0.026124343276023865, 0.014958725310862064, + 0.01488060038536787, 0.0297449491918087, 0.046876221895217896, + 0.016678161919116974, 0.030463561415672302, 0.022851256653666496, + 0.040972135961055756, 0.050609588623046875, 0.04940780624747276, + 0.04304153472185135, 0.08744628727436066, 0.0762052908539772, + 0.0801474004983902, 0.0818980410695076, 0.08521285653114319, + 0.07830711454153061, 0.07637504488229752, 0.09124496579170227, + 0.16371025145053864, 0.08206146210432053, 0.0569305419921875, + 0.043944090604782104, 0.07224594056606293, 0.06922554224729538, + 5.340576194612368e-7, + ], + duration: 21.07675, + file_size: 56504, + mime_type: 'audio/aac', + }, }; diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts index 19e76944..c784feb7 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/add-option/add-option.component.ts @@ -6,7 +6,7 @@ import { } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { BasePollComponent } from '../../base-poll.component'; -import { Poll, PollOption } from 'stream-chat'; +import { Poll, PollOptionResponseData } from 'stream-chat'; import { createUniqueValidator } from '../../unique.validator'; /** @@ -35,7 +35,7 @@ export class AddOptionComponent extends BasePollComponent { }), ]), }); - options: PollOption[] = []; + options: PollOptionResponseData[] = []; async addOption() { if (this.formGroup.invalid || !this.messageId) { diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts index f1bb9c65..ea243cb4 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-actions.component.ts @@ -7,7 +7,11 @@ import { ViewChild, } from '@angular/core'; import { BasePollComponent } from '../base-poll.component'; -import { Poll, PollAnswer, PollOption } from 'stream-chat'; +import { + Poll, + PollVoteResponseData, + PollOptionResponseData, +} from 'stream-chat'; import { ModalContext } from '../../types'; import { CustomTemplatesService } from '../../custom-templates.service'; import { ChatClientService } from '../../chat-client.service'; @@ -42,13 +46,13 @@ export class PollActionsComponent extends BasePollComponent { */ @Input() maxPollOptions = 100; name = ''; - options: PollOption[] = []; + options: PollOptionResponseData[] = []; isClosed = false; allowUserSuggestions = false; allowAnswers = false; answerCount = 0; isOwnPoll = false; - ownAnwer: PollAnswer | undefined; + ownAnwer: PollVoteResponseData | undefined; selectedAction: Action | undefined = undefined; isModalOpen = false; @ViewChild('allOptions') allOptions!: TemplateRef; diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts index 40047ed0..46a14be5 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-answers-list/poll-answers-list.component.ts @@ -8,7 +8,7 @@ import { SimpleChanges, } from '@angular/core'; import { BasePollComponent } from '../../base-poll.component'; -import { Poll, PollAnswer } from 'stream-chat'; +import { Poll, PollVoteResponseData } from 'stream-chat'; /** * @@ -30,9 +30,9 @@ export class PollAnswersListComponent @Output() readonly upsertOwnAnswer = new EventEmitter(); isLoading = false; next?: string | undefined; - answers: PollAnswer[] = []; + answers: PollVoteResponseData[] = []; isClosed = false; - ownAnswer: PollAnswer | undefined; + ownAnswer: PollVoteResponseData | undefined; ngOnChanges(changes: SimpleChanges): void { super.ngOnChanges(changes); @@ -49,7 +49,7 @@ export class PollAnswersListComponent this.isLoading = true; const response = await this.poll.queryAnswers({ filter: {}, - sort: { created_at: -1 }, + sort: [{ field: 'created_at', direction: -1 }], options: { next: this.next, }, @@ -70,7 +70,7 @@ export class PollAnswersListComponent } } - trackByAnswerId(_: number, answer: PollAnswer) { + trackByAnswerId(_: number, answer: PollVoteResponseData) { return answer.id; } diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts index 223a2cbf..6836c644 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-results-list/poll-results-list.component.ts @@ -1,6 +1,10 @@ import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; import { BasePollComponent } from '../../../base-poll.component'; -import { Poll, PollOption, PollVote } from 'stream-chat'; +import { + Poll, + PollOptionResponseData, + PollVoteResponseData, +} from 'stream-chat'; /** * @@ -14,12 +18,12 @@ import { Poll, PollOption, PollVote } from 'stream-chat'; export class PollResultsListComponent extends BasePollComponent { @HostBinding('class') class = 'str-chat__modal__poll-results'; name = ''; - options: PollOption[] = []; - optionToView: PollOption | undefined; + options: PollOptionResponseData[] = []; + optionToView: PollOptionResponseData | undefined; votePreviewCount = 5; maxVotedOptionIds: string[] = []; voteCountsByOption: Record = {}; - latestVotesByOption: Record = {}; + latestVotesByOption: Record = {}; protected stateStoreSelector( poll: Poll, @@ -59,11 +63,11 @@ export class PollResultsListComponent extends BasePollComponent { ); } - trackByOptionId(_: number, option: PollOption) { + trackByOptionId(_: number, option: PollOptionResponseData) { return option.id; } - trackByVoteId(_: number, vote: PollVote) { + trackByVoteId(_: number, vote: PollVoteResponseData) { return vote.id; } } diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts index bf7a2340..9486c9af 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote-results-list/poll-vote-results-list.component.ts @@ -6,7 +6,11 @@ import { SimpleChanges, } from '@angular/core'; import { BasePollComponent } from '../../../base-poll.component'; -import { Poll, PollOption, PollVote } from 'stream-chat'; +import { + Poll, + PollOptionResponseData, + PollVoteResponseData, +} from 'stream-chat'; /** * @@ -24,12 +28,12 @@ export class PollVoteResultsListComponent /** * The poll option to display the votes for */ - @Input() option: PollOption | undefined; + @Input() option: PollOptionResponseData | undefined; isWinner = false; voteCount = 0; isLoading = false; next?: string | undefined; - votes: PollVote[] = []; + votes: PollVoteResponseData[] = []; ngOnChanges(changes: SimpleChanges): void { super.ngOnChanges(changes); @@ -51,7 +55,7 @@ export class PollVoteResultsListComponent filter: { option_id: this.option?.id ?? '', }, - sort: { created_at: -1 }, + sort: [{ field: 'created_at', direction: -1 }], options: { next: this.next, }, @@ -72,7 +76,7 @@ export class PollVoteResultsListComponent } } - trackByVoteId = (_: number, vote: PollVote) => { + trackByVoteId = (_: number, vote: PollVoteResponseData) => { return vote.id; }; diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts index 99bf977d..cd22841c 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/poll-results/poll-vote/poll-vote.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { PollAnswer, PollVote } from 'stream-chat'; +import { nsToDate, PollVoteResponseData, TimestampNS } from 'stream-chat'; import { DateParserService } from '../../../../date-parser.service'; /** @@ -16,7 +16,7 @@ export class PollVoteComponent { /** * The poll vote or answer to display */ - @Input() vote: PollVote | PollAnswer | undefined; + @Input() vote: PollVoteResponseData | undefined; anonymousTranslation = 'Anonymous'; constructor( @@ -30,7 +30,7 @@ export class PollVoteComponent { }); } - parseDate(date: string) { - return this.dateParser.parseDateTime(new Date(date)); + parseDate(date: TimestampNS) { + return this.dateParser.parseDateTime(nsToDate(date)); } } diff --git a/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts index f680b0e3..a95484e8 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-actions/upsert-answer/upsert-answer.component.ts @@ -7,7 +7,7 @@ import { SimpleChanges, } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { Poll, PollAnswer } from 'stream-chat'; +import { Poll, PollVoteResponseData } from 'stream-chat'; import { BasePollComponent } from '../../base-poll.component'; /** @@ -27,7 +27,7 @@ export class UpsertAnswerComponent /** * The poll comment to edit, when in edit mode */ - @Input() answer: PollAnswer | undefined; + @Input() answer: PollVoteResponseData | undefined; /** * The callback to close the modal the component is displayed in */ diff --git a/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts index 1ca279a1..348fbe65 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-composer/poll-composer.component.ts @@ -6,7 +6,6 @@ import { ViewChild, } from '@angular/core'; import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; -import { VotingVisibility } from 'stream-chat'; import { CustomTemplatesService } from '../../custom-templates.service'; import { ModalContext } from '../../types'; import { ChatClientService } from '../../chat-client.service'; @@ -132,8 +131,8 @@ export class PollComposerComponent { ? +maxVotesControl.value : undefined, voting_visibility: this.formGroup.get('is_anonymous')?.value - ? VotingVisibility.anonymous - : VotingVisibility.public, + ? 'anonymous' + : 'public', allow_user_suggested_options: !!this.formGroup.get( 'allow_user_suggested_options' )?.value, diff --git a/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts index 44f9cb23..967256f2 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-option-selector/poll-option-selector.component.ts @@ -9,8 +9,8 @@ import { BasePollComponent } from '../base-poll.component'; import { isVoteAnswer, Poll, - PollOption, - PollVote, + PollOptionResponseData, + PollVoteResponseData, VotingVisibility, } from 'stream-chat'; @@ -27,13 +27,13 @@ export class PollOptionSelectorComponent extends BasePollComponent implements OnChanges { - @Input() option: PollOption | undefined; + @Input() option: PollOptionResponseData | undefined; @Input() displayAvatarCount = 3; @Input() voteCountVerbose = false; isClosed = false; - latestVotes: PollVote[] = []; + latestVotes: PollVoteResponseData[] = []; isWinner = false; - ownVote: PollVote | undefined; + ownVote: PollVoteResponseData | undefined; voteCount = 0; votingVisibility: VotingVisibility | undefined; winningOptionCount = 0; @@ -73,7 +73,7 @@ export class PollOptionSelectorComponent } } - trackByVoteId(_: number, vote: PollVote) { + trackByVoteId(_: number, vote: PollVoteResponseData) { return vote.id; } diff --git a/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts b/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts index 055b3f5e..04cc1f62 100644 --- a/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts +++ b/projects/stream-chat-angular/src/lib/polls/poll-options-list/poll-options-list.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { BasePollComponent } from '../base-poll.component'; -import { Poll, PollOption } from 'stream-chat'; +import { Poll, PollOptionResponseData } from 'stream-chat'; /** * @@ -16,7 +16,7 @@ export class PollOptionsListComponent extends BasePollComponent { * How many options should be displayed. If there are more options than this number, use the poll actions to display all options */ @Input() maxOptionsDisplayed: number | undefined = 10; - options: PollOption[] = []; + options: PollOptionResponseData[] = []; protected stateStoreSelector( poll: Poll, @@ -35,7 +35,7 @@ export class PollOptionsListComponent extends BasePollComponent { return unsubscribe; } - trackByOptionId(_: number, option: PollOption) { + trackByOptionId(_: number, option: PollOptionResponseData) { return option.id; } } diff --git a/projects/stream-chat-angular/src/lib/read-by.spec.ts b/projects/stream-chat-angular/src/lib/read-by.spec.ts index 1d1dd387..4db85f6c 100644 --- a/projects/stream-chat-angular/src/lib/read-by.spec.ts +++ b/projects/stream-chat-angular/src/lib/read-by.spec.ts @@ -1,4 +1,5 @@ -import { Channel, FormatMessageResponse } from 'stream-chat'; +import { Channel, dateToNs } from 'stream-chat'; +import { StreamMessage } from './types'; import { getReadBy } from './read-by'; describe('getReadBy', () => { @@ -8,7 +9,7 @@ describe('getReadBy', () => { const message = { created_at: new Date('2021-09-14T13:08:30.004112Z'), user: sender, - } as FormatMessageResponse; + } as unknown as StreamMessage; const channel = { state: { read: {} }, } as Channel; @@ -17,7 +18,7 @@ describe('getReadBy', () => { channel.state.read = { id1: { - last_read: new Date('2021-09-14T13:08:33.004112Z'), + last_read: dateToNs(new Date('2021-09-14T13:08:33.004112Z')), user: { id: 'id1', name: 'John', @@ -25,12 +26,12 @@ describe('getReadBy', () => { unread_messages: 2, }, sender: { - last_read: new Date('2021-09-14T13:09:01.004112Z'), + last_read: dateToNs(new Date('2021-09-14T13:09:01.004112Z')), user: sender, unread_messages: 2, }, id2: { - last_read: new Date('2021-09-14T13:05:30.004112Z'), + last_read: dateToNs(new Date('2021-09-14T13:05:30.004112Z')), user: { id: 'id2', name: 'Alice', @@ -38,14 +39,14 @@ describe('getReadBy', () => { unread_messages: 2, }, id3: { - last_read: new Date('2021-09-14T13:08:30.004112Z'), + last_read: dateToNs(new Date('2021-09-14T13:08:30.004112Z')), user: { id: 'id3', name: 'Bob', }, unread_messages: 2, }, - }; + } as unknown as Channel['state']['read']; const result = getReadBy(message, channel); expect(result.length).toBe(2); @@ -60,13 +61,13 @@ describe('getReadBy', () => { const message = { created_at: new Date('2021-09-14T13:08:30.004112Z'), user: sender, - } as FormatMessageResponse; + } as unknown as StreamMessage; const channel = { state: { read: { id1: { - last_read: new Date('2021-09-14T13:08:33.004112Z'), + last_read: dateToNs(new Date('2021-09-14T13:08:33.004112Z')), user: { id: 'id1', name: 'John', @@ -82,7 +83,7 @@ describe('getReadBy', () => { unread_messages: 2, }, id3: { - last_read: new Date('2021-09-14T13:08:30.004112Z'), + last_read: dateToNs(new Date('2021-09-14T13:08:30.004112Z')), user: { id: 'id3', name: 'Bob', diff --git a/projects/stream-chat-angular/src/lib/read-by.ts b/projects/stream-chat-angular/src/lib/read-by.ts index 8646d290..429f6c04 100644 --- a/projects/stream-chat-angular/src/lib/read-by.ts +++ b/projects/stream-chat-angular/src/lib/read-by.ts @@ -1,15 +1,21 @@ -import { Channel, FormatMessageResponse, UserResponse } from 'stream-chat'; +import { Channel, nsToDate, UserResponse } from 'stream-chat'; +import { MessageWithDates } from './types'; -export const getReadBy = (message: FormatMessageResponse, channel: Channel) => { +export const getReadBy = ( + message: Pick, + channel: Channel +) => { const readBy: UserResponse[] = []; - Object.keys(channel.state.read).forEach((key) => { - const lastRead = channel.state.read[key].last_read; + const read = channel.state.read; + Object.keys(read).forEach((key) => { + const lastRead = read[key].last_read; if ( - lastRead && - lastRead.getTime() >= message.created_at.getTime() && + lastRead !== undefined && + lastRead !== null && + nsToDate(lastRead).getTime() >= message.created_at.getTime() && message.user?.id !== key ) { - readBy.push(channel.state.read[key].user); + readBy.push(read[key].user); } }); diff --git a/projects/stream-chat-angular/src/lib/thread/thread.component.spec.ts b/projects/stream-chat-angular/src/lib/thread/thread.component.spec.ts index 74c975ec..6f8c8d44 100644 --- a/projects/stream-chat-angular/src/lib/thread/thread.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/thread/thread.component.spec.ts @@ -50,7 +50,7 @@ describe('ThreadComponent', () => { (fixture.nativeElement as HTMLElement).querySelector( '[data-testid="channel-name"]' )!.innerHTML - ).toContain(channel.data!.name!); + ).toContain(channel.data!.custom!.name!); }); it('should close thread', () => { diff --git a/projects/stream-chat-angular/src/lib/types-custom.ts b/projects/stream-chat-angular/src/lib/types-custom.ts index 11427ee5..d0dec7a6 100644 --- a/projects/stream-chat-angular/src/lib/types-custom.ts +++ b/projects/stream-chat-angular/src/lib/types-custom.ts @@ -14,4 +14,12 @@ export interface DefaultAttachmentData { * This is a non-standard property, other SDKs will ignore this property */ isCustomAttachment?: boolean; + /** + * The duration of the attachment in seconds (used for voice recordings) + */ + duration?: number; + /** + * The waveform data of a voice recording, values are between 0 and 1 + */ + waveform_data?: number[]; } diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts index 4c59de28..95991e53 100644 --- a/projects/stream-chat-angular/src/lib/types.ts +++ b/projects/stream-chat-angular/src/lib/types.ts @@ -1,15 +1,17 @@ import { TemplateRef } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import type { + AppResponseFields, Attachment, Channel, ChannelFilters, ChannelMemberResponse, - CommandResponse, + Command, CustomMessageData, - FormatMessageResponse, - MessageResponseBase, + LocalMessage, + MessageResponse, ReactionGroupResponse, + SortParamRequest, ReactionResponse, User, UserResponse, @@ -26,11 +28,61 @@ export type CustomTrigger = { }; }; -export type StreamMessage = FormatMessageResponse & { +/** + * Message date fields that stream-chat stores as `TimestampNS` (unix nanoseconds), the SDK converts these to `Date` objects + */ +export type MessageDateFields = + | 'created_at' + | 'updated_at' + | 'deleted_at' + | 'pinned_at' + | 'pin_expires' + | 'message_text_updated_at'; + +export type MessageWithDates = + Omit & { + created_at: Date; + updated_at: Date; + deleted_at?: Date; + pinned_at?: Date; + pin_expires?: Date; + message_text_updated_at?: Date; + }; + +export type StreamMessage = MessageWithDates & { readBy: UserResponse[]; translation?: string; errorStatusCode?: number; - quoted_message?: MessageResponseBase & { translation?: string }; + quoted_message?: MessageWithDates & { translation?: string }; +}; + +export type AppSettings = Partial; + +/** + * Channel sort, either in the format expected by stream-chat (`[{ field: 'last_message_at', direction: -1 }]`) or in the legacy object format (`{ last_message_at: -1 }`) + */ +export type ChannelSort = + | SortParamRequest[] + | Record + | Record[]; + +export type MessageListPaginationState = { + /** + * `true` if there are older messages that can be loaded + */ + hasMoreOlder: boolean; + /** + * `true` if there are newer messages that can be loaded (for example after jumping to an older message) + */ + hasMoreNewer: boolean; + /** + * `true` if a page is being loaded + */ + isLoading: boolean; + /** + * The error of the last failed query (cleared on the next successful query) + */ + error?: Error; }; export type AttachmentUploadErrorReason = @@ -66,7 +118,7 @@ export type MentionAutcompleteListItem = ( autocompleteLabel: string; }; -export type ComandAutocompleteListItem = CommandResponse & { +export type ComandAutocompleteListItem = Command & { autocompleteLabel: string; }; @@ -463,13 +515,18 @@ export type CustomAutocomplete = { }; export type MessageTextContext = { - message: StreamMessage | undefined | MessageResponseBase; + message: + | StreamMessage + | undefined + | MessageResponse + | (MessageWithDates & { translation?: string }); isQuoted: boolean; shouldTranslate: boolean; }; export type GalleryAttachment = { type: 'gallery'; + custom: Attachment['custom']; images: Attachment[]; }; diff --git a/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts b/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts index 75372af1..89363b2b 100644 --- a/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/user-list/user-list.component.spec.ts @@ -29,8 +29,8 @@ describe('UserListComponent', () => { it('should display users', () => { component.users = [ - { id: 'jane', name: 'Jane', image: 'link/to/image' }, - { id: 'jack', name: 'Jack', image: 'link/to/image2' }, + { id: 'jane', name: 'Jane', image: 'link/to/image' } as UserResponse, + { id: 'jack', name: 'Jack', image: 'link/to/image2' } as UserResponse, ]; fixture.detectChanges(); @@ -60,8 +60,8 @@ describe('UserListComponent', () => { it('should provide data to paginated list component', () => { const users = [ - { id: 'jane', name: 'Jane', image: 'link/to/image' }, - { id: 'jack', name: 'Jack', image: 'link/to/image2' }, + { id: 'jane', name: 'Jane', image: 'link/to/image' } as UserResponse, + { id: 'jack', name: 'Jack', image: 'link/to/image2' } as UserResponse, ]; component.users = users; component.hasMore = true; diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts b/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts index bc8cec20..400d8d72 100644 --- a/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts +++ b/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts @@ -159,7 +159,7 @@ export class AmplitudeRecorderService { } private logError(error: Error) { - this.chatService.chatClient?.logger('error', error.message, { + this.chatService.logger.error(error.message, { error: error, tag: ['AmplitudeRecorderService'], }); diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts b/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts index b81afca2..2631ec8b 100644 --- a/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts +++ b/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts @@ -262,7 +262,7 @@ export abstract class MultimediaRecorder { protected abstract enrichWithExtraData(): T; protected logError(error: Error) { - this.chatService.chatClient?.logger('error', error.message, { + this.chatService.logger.error(error.message, { error: error, tag: ['MediaRecorder'], }); diff --git a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html index 569f193b..5efef51f 100644 --- a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html +++ b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.html @@ -15,7 +15,7 @@