├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .npmignore ├── .publishrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── example ├── index.html ├── main.js ├── service-worker.js └── style.css ├── package.json ├── react-native.js ├── rollup ├── react-native.js ├── shared.js └── web.js ├── script └── puppeteer-run.js ├── src ├── attachment.js ├── batch.js ├── chat-manager.js ├── constants.js ├── current-user.js ├── cursor-store.js ├── cursor-subscription.js ├── cursor.js ├── main.js ├── membership-subscription.js ├── message-subscription.js ├── message.js ├── notification-subscription.js ├── notification.js ├── parsers.js ├── presence-subscription.js ├── reconnection-handlers.js ├── room-store.js ├── room-subscription.js ├── room.js ├── service-worker.js ├── token-provider.js ├── typing-indicators.js ├── user-presence-subscription.js ├── user-store.js ├── user-subscription.js ├── user.js └── utils.js ├── tests ├── integration │ ├── README.md │ ├── config │ │ └── example.js │ └── main.js ├── jest │ ├── chat-manager.js │ ├── cursors.js │ ├── helpers │ │ ├── config │ │ │ └── example.js │ │ └── main.js │ ├── messages.js │ ├── presence.js │ ├── rooms.js │ ├── tab-open-notifications.js │ └── web-push-notifications.js └── unit │ ├── batch.js │ ├── chat-manager-constructor.js │ ├── cursor-sub-reconnection.js │ ├── membership-sub-reconnection.js │ └── user-sub-reconnection.js └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Additional context** 31 | SDK version: 32 | Platform/ OS/ Browser: 33 | 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Is your feature request related to a problem? Please describe.** 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | 17 | **Describe the solution you'd like** 18 | A clear and concise description of what you want to happen. 19 | 20 | **Describe alternatives you've considered** 21 | A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | **Additional context** 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | npm-debug.log 4 | yarn-error.log 5 | .DS_Store 6 | .vscode/ 7 | tests/integration/config/production.js 8 | tests/integration/config/staging.js 9 | tests/integration/config/development.js 10 | tests/jest/helpers/config/production.js 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # This file is written to be a whitelist instead of a blacklist. Start by 2 | # ignoring everything, then add back the files we want to be included in the 3 | # final NPM package. 4 | * 5 | 6 | # And these are the files that are allowed. 7 | !/LICENSE.md 8 | !/package.json 9 | !/react-native.js 10 | !/dist/**/* 11 | -------------------------------------------------------------------------------- /.publishrc: -------------------------------------------------------------------------------- 1 | { 2 | "validations": { 3 | "vulnerableDependencies": false, 4 | "uncommittedChanges": true, 5 | "untrackedFiles": true, 6 | "sensitiveData": false, 7 | "branch": "master", 8 | "gitTag": true 9 | }, 10 | "confirm": true, 11 | "publishCommand": "npm publish", 12 | "publishTag": "latest", 13 | "prePublishScript": "yarn lint:build:test", 14 | "postPublishScript": false 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | 5 | before_script: 6 | - yarn 7 | - cp tests/integration/config/example.js tests/integration/config/production.js 8 | - sed -i -e "s|your:instance:locator|$CHATKIT_INSTANCE_LOCATOR|g" tests/integration/config/production.js 9 | - sed -i -e "s|your:key|$CHATKIT_INSTANCE_KEY|g" tests/integration/config/production.js 10 | - sed -i -e "s|https://token.provider.url|$CHATKIT_TOKEN_PROVIDER_URL|g" tests/integration/config/production.js 11 | - cp tests/jest/helpers/config/example.js tests/jest/helpers/config/production.js 12 | - sed -i -e "s|your:instance:locator|$CHATKIT_INSTANCE_LOCATOR|g" tests/jest/helpers/config/production.js 13 | - sed -i -e "s|your:key|$CHATKIT_INSTANCE_KEY|g" tests/jest/helpers/config/production.js 14 | - sed -i -e "s|https://token.provider.url|$CHATKIT_TOKEN_PROVIDER_URL|g" tests/jest/helpers/config/production.js 15 | 16 | script: 17 | - yarn lint 18 | - yarn build 19 | - yarn test 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to [Semantic Versioning Scheme](http://semver.org) 4 | 5 | --- 6 | 7 | ## [Unreleased](https://github.com/pusher/chatkit-client-js/compare/1.14.0...HEAD) 8 | 9 | ## [1.14.0](https://github.com/pusher/chatkit-client-js/compare/1.13.2...1.14.0) 10 | 11 | ### Additions 12 | 13 | - The token provider constructor will now additionally accept functions for 14 | `url`, `queryParams`, and `headers`. Thanks zoozalp. 15 | 16 | ## [1.13.3](https://github.com/pusher/chatkit-client-js/compare/1.13.2...1.13.3) 17 | 18 | ### Changes 19 | - Logs a warning if `onMessageDeleted` hook is passed to `subscribeToRoom` 20 | 21 | ## [1.13.2](https://github.com/pusher/chatkit-client-js/compare/1.13.1...1.13.2) 22 | 23 | ### Fixes 24 | 25 | - A bug which would under certain circumstances cause calls to fetch users by ID 26 | to not be properly batched. 27 | 28 | ### Additions 29 | 30 | - A `disableCursors` option for `subscribeToRoomMultipart` which opts out of 31 | receiving other user's cursors. 32 | 33 | ## [1.13.1](https://github.com/pusher/chatkit-client-js/compare/1.13.0...1.13.1) 34 | 35 | ### Fixes 36 | 37 | - Bump push-notifications-web version. This will return a descriptive error 38 | if an invalid service worker registration is given to the SDK. 39 | 40 | ## [1.13.0](https://github.com/pusher/chatkit-client-js/compare/1.12.1...1.13.0) 41 | 42 | ### Additions 43 | 44 | - Bump pusher-platform-js version. No changes to the public interface. 45 | 46 | ## [1.12.1](https://github.com/pusher/chatkit-client-js/compare/1.11.0...1.12.1) 47 | 48 | ### Additions 49 | 50 | - `enablePushNotifications` now takes `showNotificationsTabOpen` and 51 | `showNotificationsTabClosed` options which enable or disable notifications 52 | when there is a tab open or closed. 53 | 54 | ## [1.11.0](https://github.com/pusher/chatkit-client-js/compare/1.10.0...1.11.0) 55 | 56 | ### Additions 57 | 58 | - Support for `pushNotificationTitleOverride` in `createRoom` and `updateRoom` methods. 59 | 60 | ## [1.10.0](https://github.com/pusher/chatkit-client-js/compare/1.9.2...1.10.0) 61 | 62 | ### Additions 63 | 64 | - Enabling push notifications now also enables notifications when the 65 | application tab is open but hidden. 66 | - `enablePushNotifications` takes an `onClick` callback which is fired when one 67 | of these notifications is clicked, and passed the relevant room ID. 68 | 69 | ### Additions 70 | 71 | - Support for pushNotificationTitleOverride in createRoom & updateRoom 72 | 73 | ## [1.9.2](https://github.com/pusher/chatkit-client-js/compare/1.9.1...1.9.2) 74 | 75 | ### Changes 76 | 77 | - Bump lodash version for security patch. 78 | 79 | ## [1.9.1](https://github.com/pusher/chatkit-client-js/compare/1.9.0...1.9.1) 80 | 81 | ### Changes 82 | 83 | - Internal improvements to web push notifications 84 | 85 | ## [1.9.0](https://github.com/pusher/chatkit-client-js/compare/1.8.0...1.9.0) 86 | 87 | ### Additions 88 | 89 | - Support for user specified room IDs. Provide an `id` parameter to the 90 | `createRoom` method. 91 | 92 | ## [1.8.0](https://github.com/pusher/chatkit-client-js/compare/1.7.1...1.8.0) 93 | 94 | ### Additions 95 | 96 | - Better error messages for web push integrations: 97 | - Log a warning when using an unsupported browser 98 | - Raise an exception with a more helpful error message when it is detected 99 | that the service worker is missing. 100 | 101 | ## [1.7.1](https://github.com/pusher/chatkit-client-js/compare/1.7.0...1.7.1) 102 | 103 | ### Fixes 104 | 105 | - Bad pinned version of push notifications dependency 106 | 107 | ## [1.7.0](https://github.com/pusher/chatkit-client-js/compare/1.6.1...1.7.0) 108 | 109 | ### Additions 110 | 111 | - Beta support for web push notifications (currently Chrome-only) 112 | 113 | ## [1.6.1](https://github.com/pusher/chatkit-client-js/compare/1.6.0...1.6.1) 114 | 115 | ### Fixes 116 | 117 | - Check for non-null content (rather than falsy) in message parsing so that 118 | empty content doesn't throw an error 119 | 120 | ## [1.6.0](https://github.com/pusher/chatkit-client-js/compare/1.5.0...1.6.0) 121 | 122 | ### Additions 123 | 124 | - Support the `onMessageDeleted` hook 125 | 126 | ## [1.5.0](https://github.com/pusher/chatkit-client-js/compare/1.4.1...1.5.0) 127 | 128 | ### Additions 129 | 130 | - `unreadCount` and `lastMessageAt` properties on room objects which contain 131 | the number of unread messages and the timestamp of the most recent message in 132 | a room. 133 | 134 | ## [1.4.1](https://github.com/pusher/chatkit-client-js/compare/1.4.0...1.4.1) 135 | 136 | ### Additions 137 | 138 | - Throw an error if file to attach doesn't have a size. 139 | 140 | ## [1.4.0](https://github.com/pusher/chatkit-client-js/compare/1.3.2...1.4.0) 141 | 142 | ### Additions 143 | 144 | - Multipart messaging support: `sendSimpleMessage`, `sendMultipartMessage`, 145 | `fetchMessagesMultipart`, and `subscribeToRoomMultipart` all use the new 146 | multipart message format. 147 | 148 | ### Deprications 149 | 150 | - `sendMessage`, `fetchMessages`, and `subscribeToRoom` are depricated in 151 | favour of their multipart counterparts. They will be removed in a future 152 | major release. 153 | 154 | ## [1.3.2](https://github.com/pusher/chatkit-client-js/compare/1.3.1...1.3.2) 155 | 156 | ### Additions 157 | 158 | - The `TokenProvider` now accepts a `withCredentials` option which it forwards 159 | to `XMLHttpRequest` internally. [See 160 | here.](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials) 161 | 162 | ## [1.3.1](https://github.com/pusher/chatkit-client-js/compare/1.3.0...1.3.1) 163 | 164 | ### Fixes 165 | 166 | - a race condition when subscribing to the same room twice in very quick succession 167 | - buffer room events until all relevant subscriptions are complete 168 | 169 | ## [1.3.0](https://github.com/pusher/chatkit-client-js/compare/1.2.2...1.3.0) 170 | 171 | ### Changes 172 | 173 | - On reconnection hooks will now be fired for events that were missed during disconnection. 174 | 175 | ### Fixes 176 | 177 | - Race condition between leaving a room and receiving the removed-from-room 178 | event (the latter will now always fire). 179 | - Errors when unsubscribing while receiving an event. 180 | 181 | ## [1.2.2](https://github.com/pusher/chatkit-client-js/compare/1.2.1...1.2.2) 182 | 183 | - Update the `@pusher/platform` dependency to 0.16.0 and so reconnections are much more reliable now (thanks [@albertopriore](https://github.com/albertopriore) in particular for helping with debugging) 184 | 185 | ## [1.2.1](https://github.com/pusher/chatkit-client-js/compare/1.2.0...1.2.1) 186 | 187 | ### Changes 188 | 189 | - The `deletedAt` field is populated on the room object (it will be `undefined` 190 | unless the room has been deleted) 191 | 192 | ## [1.2.0](https://github.com/pusher/chatkit-client-js/compare/1.1.2...1.2.0) 193 | 194 | ### Additions 195 | 196 | - The message attachment object now has a `name` field 197 | 198 | ## [1.1.2](https://github.com/pusher/chatkit-client-js/compare/1.1.1...1.1.2) 199 | 200 | - Move the dependency `pusher-platform` to `@pusher/platform` 201 | 202 | ## [1.1.1](https://github.com/pusher/chatkit-client-js/compare/1.1.0...1.1.1) 203 | 204 | ### Fixes 205 | 206 | - Reduce time taken to reconnect broken websocket connection (e.g. network change 207 | or plug pulled) on Chrome by 60 seconds. 208 | 209 | ## [1.1.0](https://github.com/pusher/chatkit-client-js/compare/1.0.5...1.1.0) 210 | 211 | ### Additions 212 | 213 | - A `customData` option for `createRoom` and `updateRoom` 214 | - A `customData` property on the room object throughout 215 | 216 | ## [1.0.5](https://github.com/pusher/chatkit-client-js/compare/1.0.4...1.0.5) 217 | 218 | ### Fixes 219 | 220 | - Re-includes the react-native.js adapter in the published package. 221 | 222 | ## [1.0.4](https://github.com/pusher/chatkit-client-js/compare/1.0.3...1.0.4) - 2018-11-05 223 | 224 | ### Fixes 225 | 226 | - The `users` property on the room object. 227 | - Subscribe to user's own presence state. 228 | 229 | ## [1.0.3](https://github.com/pusher/chatkit-client-js/compare/1.0.2...1.0.3) 230 | 231 | ### Changes 232 | 233 | ## [1.0.2](https://github.com/pusher/chatkit-client-js/compare/0.7.16...1.0.2) 234 | 235 | ### Changes 236 | 237 | - The `fetchRequired` property on message attachments is no longer defined 238 | (fetch is never required any more, just use the provided link directly). 239 | 240 | - The `fetchAttachment` method is removed from the current user object since it 241 | is never required. 242 | 243 | - renames `onNewMessage` to `onMessage` 244 | 245 | - `onPresenceChanged` replaces `onUserCameOnline` and `onUserWentOffline`. 246 | Takes parameters `(state, user)` -- where `state` is `{ current, previous }` 247 | and `current` and `previous` are one of `"online"`, `"offline"`, or 248 | `"unknown"`. 249 | 250 | - Room memberships (the user property on rooms) are now available only after 251 | subscribing to a room. Attempting to access them before subscribing will 252 | throw an error. 253 | 254 | - room IDs are now strings everywhere 255 | 256 | ## [0.7.18](https://github.com/pusher/chatkit-client-js/compare/0.7.17...0.7.18) - 2018-10-12 257 | 258 | ### Changes 259 | 260 | - Increased default connection timeout from 10 to 20 seconds 261 | - Bump pusher-platform-js dependency to 0.15.2 262 | 263 | ## [0.7.17](https://github.com/pusher/chatkit-client-js/compare/0.7.16...0.7.17) - 2018-06-18 264 | 265 | ### Changes 266 | 267 | - Internal fix to ensure that the room is properly returned from `leaveRoom`. 268 | No external change. 269 | 270 | ## [0.7.16](https://github.com/pusher/chatkit-client-js/compare/0.7.14...0.7.16) - 2018-06-18 271 | 272 | ### Additions 273 | 274 | - The connection timeout introduced in 0.7.13 is configurable by passing 275 | `connectionTimeout` (milliseconds) to the `ChatManager` constructor. 276 | 277 | ## [0.7.14](https://github.com/pusher/chatkit-client-js/compare/0.7.13...0.7.14) - 2018-06-12 278 | 279 | ### Changes 280 | 281 | - Adds a `disconnect` method to `ChatManager` which disconnects a user from Chatkit. 282 | 283 | ## [0.7.13](https://github.com/pusher/chatkit-client-js/compare/0.7.12...0.7.13) - 2018-06-12 284 | 285 | ### Changes 286 | 287 | - Subscriptions will now time out after 5s if no initial state is received. 288 | 289 | ## [0.7.12](https://github.com/pusher/chatkit-client-js/compare/0.7.11...0.7.12) - 2018-04-30 290 | 291 | ### Changes 292 | 293 | - Uploads files to path scoped by user ID (no external change) 294 | 295 | ## [0.7.11](https://github.com/pusher/chatkit-client-js/compare/0.7.9...0.7.11) - 2018-04-30 296 | 297 | ### Changes 298 | 299 | - Batch set cursor requests (no external change) 300 | 301 | ## [0.7.9](https://github.com/pusher/chatkit-client-js/compare/0.7.8...0.7.9) - 2018-04-10 302 | 303 | ### Additions 304 | 305 | - De-duplicate user information requests. 306 | - Send SDK info headers along with every request (version, platform, etc). 307 | 308 | ## [0.7.8](https://github.com/pusher/chatkit-client-js/compare/0.7.7...0.7.8) - 2018-04-04 309 | 310 | ### Changes 311 | 312 | - Remove the es build because it was causing problems with webpack. If we want 313 | to add it back later more investigation and testing will be required. 314 | 315 | ## [0.7.7](https://github.com/pusher/chatkit-client-js/compare/0.7.6...0.7.7) - 2018-04-03 316 | 317 | ### Changes 318 | 319 | - Point `es.js` to the es module build not the web build. 320 | 321 | ## [0.7.6](https://github.com/pusher/chatkit-client-js/compare/0.7.5...0.7.6) - 2018-04-03 322 | 323 | ### Changes 324 | 325 | - Fill in a sensible default for missing presence data so we don't have to 326 | explicitly check for undefined. 327 | - Use ES5 syntax in `es.js` to satisfy `create-react-app`'s build script. 328 | 329 | ## [0.7.5](https://github.com/pusher/chatkit-client-js/compare/0.7.4...0.7.5) - 2018-03-26 330 | 331 | ### Changes 332 | 333 | - type check the `private` option to `updateRoom` rather than casting, so that 334 | default is `undefined` not `false`. 335 | 336 | ## [0.7.4](https://github.com/pusher/chatkit-client-js/compare/0.7.3...0.7.4) - 2018-03-20 337 | 338 | ### Additions 339 | 340 | - es module build for named imports and tree shaking when consuming the SDK 341 | with rollup 342 | 343 | ## [0.7.3](https://github.com/pusher/chatkit-client-js/compare/0.7.2...0.7.3) - 2018-03-20 344 | 345 | ### Changes 346 | 347 | - removed `getAllRooms` from the current user. It only causes confusion. Anyone 348 | using `getAllRooms` can replace swap it out for something like the following: 349 | 350 | ```javascript 351 | // instead of this 352 | currentUser.getAllRooms().then(allRooms => { 353 | doTheThing(allRooms) 354 | }) 355 | 356 | // do this 357 | currentUser.getJoinableRooms().then(joinable => { 358 | doTheThing(joinable.concat(currentUser.rooms)) 359 | }) 360 | ``` 361 | 362 | ## [0.7.2](https://github.com/pusher/chatkit-client-js/compare/0.7.1...0.7.2) - 2018-03-19 363 | 364 | ### Changes 365 | 366 | - Subobjects of the current user (Rooms, Users, etc) are now mutated instead of 367 | replaced, so any reference to a room will represent the up to date state of 368 | that room. 369 | 370 | ### Fixes 371 | 372 | - Remove chatty logs about requiring room membership after leaving a room 373 | 374 | ## [0.7.0](https://github.com/pusher/chatkit-client-js/compare/0.6.2...0.7.0) - 2018-03-13 375 | 376 | This version represents a radical departure from 0.6.X. The interface is very 377 | different, and there's a good chance we'll miss some of the changes in this 378 | log. If something isn't working after migration, the best place to look first 379 | is probably the 380 | [documentation](https://docs.pusher.com/chatkit/reference/javascript). 381 | 382 | ### Changes 383 | 384 | - Methods with `onSuccess`, `onFailure` callbacks changed to return promises 385 | instead. e.g. 386 | 387 | ```javascript 388 | chatManager 389 | .connect() 390 | .then(currentUser => {}) 391 | .catch(err => {}) 392 | ``` 393 | 394 | - All methods take a single object parameter (see the 395 | [documentation](https://docs.pusher.com/chatkit/reference/javascript) for 396 | details on each method's arguments) 397 | 398 | - Delegates renamed to `hooks` throughout. e.g. 399 | 400 | ```javascript 401 | currentUser.subscribeToRoom({ 402 | roomId, 403 | hooks: { 404 | onNewMessage: m => {}, 405 | }, 406 | }) 407 | ``` 408 | 409 | - Hooks all prefixed with `on`. e.g. `onNewMessage`, `onUserStartedTyping` 410 | 411 | - `cursorSet` hook renamed to `onNewCursor` 412 | 413 | - `authContext.queryParams` and `authContext.headers` both moved to the root 414 | options object in the token provider. e.g. 415 | 416 | ```javascript 417 | const tokenProvider = new TokenProvider({ 418 | url: 'your.auth.url', 419 | queryParams: { 420 | someKey: someValue, 421 | ... 422 | }, 423 | headers: { 424 | SomeHeader: 'some-value', 425 | ... 426 | } 427 | }) 428 | ``` 429 | 430 | - `addUser` and `removeUser` renamed to `addUserToRoom` and `removeUserFromRoom` 431 | 432 | - methods that used to accept a `Room` object now accept a `roomId`. e.g. 433 | 434 | instead of 435 | 436 | ```javascript 437 | currentUser.subscribeToRoom(myRoom, hooks) // WRONG 438 | ``` 439 | 440 | do 441 | 442 | ```javascript 443 | currentUser.subscribeToRoom({ roomId: myRoom.id, hooks }) 444 | ``` 445 | 446 | - The behaviour of read cursors has changed: in particular cursors are now 447 | accessed via `currentUser.readCursor` and set with 448 | `currentUser.setReadCursor`. See the [Read Cursors section of the 449 | documentation](https://docs.pusher.com/chatkit/reference/javascript#read-cursors) 450 | for details. 451 | 452 | - Presence data is now accessable on any user object under `user.presence`. e.g. 453 | 454 | ```javascript 455 | const isOnline = user.presence.state === "online" 456 | ``` 457 | 458 | - All users that share a common room membership are accesable under 459 | `currentUser.users`, and all members of a room are accessable under 460 | `room.users`. 461 | 462 | ## [0.6.2](https://github.com/pusher/chatkit-client-js/compare/0.6.1...0.6.2) - 2018-02-05 463 | 464 | ### Fixes 465 | 466 | - Catch errors in cursors get request 467 | 468 | ## [0.6.1](https://github.com/pusher/chatkit-client-js/compare/0.6.0...0.6.1) - 2018-01-25 469 | 470 | ### Fixes 471 | 472 | - Made sure that the `messageLimit` argument in `subscribeToRoom` was being 473 | validated as a number. 474 | - Ensured that the `position` argument in `setCursor` is a valid number. 475 | - Throw an error if the userId isn't provided to the ChatManager. 476 | 477 | ## [0.6.0](https://github.com/pusher/chatkit-client-js/compare/0.5.1...0.6.0) - 2018-01-19 478 | 479 | ### Changes 480 | 481 | - Simplify typing indicator API 482 | - removed `startedTypingIn` and `stoppedTypingIn` methods 483 | - instead call `isTypingIn` as frequently as you like (rate limited by the SDK) 484 | - `startedTyping` and `stoppedTyping` are fired exactly once each per burst 485 | of typing 486 | 487 | ## [0.5.1](https://github.com/pusher/chatkit-client-js/compare/0.5.0...0.5.1) - 2018-01-16 488 | 489 | ### Fixes 490 | 491 | - Fixed `fetchMessageFromRoom` which wasn't passing along the values provided in the `FetchRoomMessagesOptions` parameter as query params. Thanks [@apalmer0](https://github.com/apalmer0)! 492 | 493 | ## [0.5.0](https://github.com/pusher/chatkit-client-js/compare/0.4.0...0.5.0) - 2018-01-09 494 | 495 | ### Changes 496 | 497 | - `ChatManager` takes a `userId` as a required option, `TokenProvider` no 498 | longer does. (`ChatManager` passes the user ID to the token provider 499 | internally before requesting a token.) 500 | 501 | ### Additions 502 | 503 | - `RoomDelegate` has a `cursorSet` callback, fired whenever a cursor is set in 504 | the given room. 505 | 506 | - `CurrentUser` has a `setCursor` method, to set a cursor in a given room. 507 | 508 | - The `CurrentUser` object now has a `cursors` property, which contains all the 509 | user's own cursors, mapped by room ID. This is guaranteed to be populated 510 | before room subscriptions succeed, so e.g. `currentUser.cursors[roomId]` can 511 | be used upon receiving messages to determine if they have been read already. 512 | 513 | ## [0.4.0](https://github.com/pusher/chatkit-client-js/compare/0.3.2...0.4.0) - 2018-01-04 514 | 515 | ### Additions 516 | 517 | - Add initial support for receiving cursors. 518 | 519 | ## [0.3.2](https://github.com/pusher/chatkit-client-js/compare/0.3.1...0.3.2) - 2017-12-19 520 | 521 | ### Changes 522 | 523 | - `addMessage` has been renamed to `sendMessage` and now expects a different set of parameters: 524 | 525 | What previously would have been this: 526 | 527 | ```typescript 528 | currentUser.addMessage( 529 | "Hi there! 👋", 530 | myRoom, 531 | messageId => { 532 | console.log("Success!", messageId) 533 | }, 534 | error => { 535 | console.log("Error", error) 536 | }, 537 | ) 538 | ``` 539 | 540 | now needs to be written like this: 541 | 542 | ```typescript 543 | currentUser.sendMessage( 544 | { 545 | text: "Hey there!", 546 | roomId: myRoom.id, 547 | }, 548 | messageId => { 549 | console.log("Success!", messageId) 550 | }, 551 | error => { 552 | console.log("Error", error) 553 | }, 554 | ) 555 | ``` 556 | 557 | ### Additions 558 | 559 | - `sendMessage` supports adding an attachment to a message. See [the docs](https://docs.pusher.com/chatkit/client/javascript#messages) for more information. 560 | 561 | --- 562 | 563 | Older releases are not covered by this changelog. 564 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT license 2 | 3 | Copyright (c) 2017 Pusher Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chatkit Retirement Announcement 2 | We are sorry to say that as of April 23 2020, we will be fully retiring our 3 | Chatkit product. We understand that this will be disappointing to customers who 4 | have come to rely on the service, and are very sorry for the disruption that 5 | this will cause for them. Our sales and customer support teams are available at 6 | this time to handle enquiries and will support existing Chatkit customers as 7 | far as they can with transition. All Chatkit billing has now ceased , and 8 | customers will pay no more up to or beyond their usage for the remainder of the 9 | service. You can read more about our decision to retire Chatkit here: 10 | [https://blog.pusher.com/narrowing-our-product-focus](https://blog.pusher.com/narrowing-our-product-focus). 11 | If you are interested in learning about how you can build chat with Pusher 12 | Channels, check out our tutorials. 13 | 14 | # Chatkit JS 15 | 16 | [![Read the docs](https://img.shields.io/badge/read_the-docs-92A8D1.svg)](https://docs.pusher.com/chatkit/reference/javascript) 17 | [![Twitter](https://img.shields.io/badge/twitter-@Pusher-blue.svg?style=flat)](http://twitter.com/Pusher) 18 | [![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://github.com/pusher/chatkit-client-js/blob/master/LICENSE.md) 19 | [![npm version](https://badge.fury.io/js/%40pusher%2Fchatkit-client.svg)](https://badge.fury.io/js/%40pusher%2Fchatkit-client) 20 | [![Build Status](https://travis-ci.org/pusher/chatkit-client-js.svg?branch=master)](https://travis-ci.org/pusher/chatkit-client-js) 21 | 22 | The JavaScript client for Pusher Chatkit. If you aren't already here, you can find the source [on Github](https://github.com/pusher/chatkit-client-js). 23 | 24 | For more information on the Chatkit service, [see here](https://pusher.com/chatkit). For full documentation, [see here](https://docs.pusher.com/chatkit) 25 | 26 | ## Installation 27 | 28 | ### Yarn 29 | 30 | [yarn](https://yarnpkg.com/): 31 | 32 | ```sh 33 | $ yarn add @pusher/chatkit-client 34 | ``` 35 | 36 | [npm](https://www.npmjs.com/): 37 | 38 | ```sh 39 | $ npm install @pusher/chatkit-client 40 | ``` 41 | 42 | ## Getting started 43 | 44 | Head over to [our documentation](https://docs.pusher.com/chatkit/reference/javascript). 45 | 46 | ## Development 47 | 48 | ### Testing 49 | 50 | Lint, build, and run the tests with 51 | 52 | ```sh 53 | yarn lint:build:test 54 | ``` 55 | 56 | Formatting should largely be delegated to prettier, which can be invoked manually with 57 | 58 | ```sh 59 | yarn format 60 | ``` 61 | 62 | or you can set your editor up to run prettier on save. 63 | 64 | ### Publishing 65 | 66 | Running `yarn publish-please` will walk you through the publishing steps. 67 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Chatkit Example 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | + 15 | 16 |
17 |
SEND
18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | const INSTANCE_LOCATOR = "YOUR_INSTANCE_LOCATOR" 2 | const TOKEN_PROVIDER_URL = "YOUR_TOKEN_PROVIDER_URL" 3 | const USER_ID = "YOUR_USER_ID" 4 | 5 | let currentUser 6 | let room 7 | 8 | const tokenProvider = new Chatkit.TokenProvider({ 9 | url: TOKEN_PROVIDER_URL, 10 | }) 11 | 12 | const noopLogger = (...items) => {} 13 | 14 | const chatManager = new Chatkit.ChatManager({ 15 | instanceLocator: INSTANCE_LOCATOR, 16 | tokenProvider: tokenProvider, 17 | userId: USER_ID, 18 | logger: { 19 | info: console.log, 20 | warn: console.log, 21 | error: console.log, 22 | debug: console.log, 23 | verbose: console.log, 24 | }, 25 | }) 26 | 27 | chatManager 28 | .connect({ 29 | onAddedToRoom: room => { 30 | console.log("added to room: ", room) 31 | }, 32 | onRemovedFromRoom: room => { 33 | console.log("removed from room: ", room) 34 | }, 35 | onUserJoinedRoom: (room, user) => { 36 | console.log("user: ", user, " joined room: ", room) 37 | }, 38 | onUserLeftRoom: (room, user) => { 39 | console.log("user: ", user, " left room: ", room) 40 | }, 41 | onPresenceChanged: ({ previous, current }, user) => { 42 | console.log("user: ", user, " was ", previous, " but is now ", current) 43 | }, 44 | }) 45 | .then(cUser => { 46 | window.navigator.serviceWorker 47 | .register("/example/service-worker.js") 48 | .then(registration => 49 | cUser 50 | .enablePushNotifications({ 51 | serviceWorkerRegistration: registration, 52 | }) 53 | .then(() => { 54 | console.log("Push notifications enabled") 55 | }) 56 | .catch(err => { 57 | console.error("Push notifications not enabled", err) 58 | }), 59 | ) 60 | 61 | currentUser = cUser 62 | window.currentUser = cUser 63 | const roomToSubscribeTo = currentUser.rooms[0] 64 | 65 | if (roomToSubscribeTo) { 66 | room = roomToSubscribeTo 67 | console.log("Going to subscribe to", roomToSubscribeTo) 68 | currentUser.subscribeToRoom({ 69 | roomId: roomToSubscribeTo.id, 70 | hooks: { 71 | onMessage: message => { 72 | console.log("new message:", message) 73 | const messagesList = document.getElementById("messages") 74 | const messageItem = document.createElement("li") 75 | messageItem.className = "message" 76 | messagesList.append(messageItem) 77 | const textDiv = document.createElement("div") 78 | textDiv.innerHTML = `${message.sender.name}: ${message.text}` 79 | messageItem.appendChild(textDiv) 80 | 81 | if (message.attachment) { 82 | let attachment 83 | switch (message.attachment.type) { 84 | case "image": 85 | attachment = document.createElement("img") 86 | break 87 | case "video": 88 | attachment = document.createElement("video") 89 | attachment.controls = "controls" 90 | break 91 | case "audio": 92 | attachment = document.createElement("audio") 93 | attachment.controls = "controls" 94 | break 95 | default: 96 | break 97 | } 98 | 99 | attachment.className += " attachment" 100 | attachment.width = "400" 101 | attachment.src = message.attachment.link 102 | messageItem.appendChild(attachment) 103 | } 104 | }, 105 | }, 106 | }) 107 | } else { 108 | console.log("No room to subscribe to") 109 | } 110 | console.log("Successful connection", currentUser) 111 | }) 112 | .catch(err => { 113 | console.log("Error on connection: ", err) 114 | }) 115 | 116 | document.getElementById("send-button").addEventListener("click", ev => { 117 | const fileInput = document.querySelector("input[name=testfile]") 118 | const textInput = document.getElementById("text-input") 119 | 120 | currentUser 121 | .sendMessage({ 122 | text: textInput.value, 123 | roomId: room.id, 124 | // attachment: { 125 | // link: 'https://assets.zeit.co/image/upload/front/api/deployment-state.png', 126 | // type: 'image', 127 | // }, 128 | attachment: fileInput.value 129 | ? { 130 | file: fileInput.files[0], 131 | // Split on slashes, remove whitespace 132 | name: fileInput.value 133 | .split(/(\\|\/)/g) 134 | .pop() 135 | .replace(/\s+/g, ""), 136 | } 137 | : undefined, 138 | }) 139 | .then(messageId => { 140 | console.log("Success!", messageId) 141 | fileInput.value = "" 142 | textInput.value = "" 143 | }) 144 | .catch(error => { 145 | console.log("Error", error) 146 | }) 147 | }) 148 | 149 | document.querySelector(".choose-file").addEventListener("click", () => { 150 | document.querySelector("input[name=testfile]").click() 151 | }) 152 | -------------------------------------------------------------------------------- /example/service-worker.js: -------------------------------------------------------------------------------- 1 | importScripts("https://js.pusher.com/chatkit/service-worker.js") 2 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Helvetica, sans-serif; 3 | } 4 | 5 | #messages { 6 | padding: 0; 7 | margin: 0; 8 | list-style: none; 9 | width: 100%; 10 | text-align: center; 11 | padding-bottom: 50px; 12 | } 13 | 14 | .message { 15 | margin: 8px 0; 16 | } 17 | 18 | .attachment { 19 | margin-top: 4px; 20 | } 21 | 22 | .choose-file { 23 | position: relative; 24 | display: inline-block; 25 | border-left: 1px solid #ebebeb; 26 | border-right: 1px solid #ebebeb; 27 | width: 40px; 28 | height: 40px; 29 | font-size: 30px; 30 | color: #7f7f7f; 31 | background: white; 32 | text-align: center; 33 | float: left; 34 | overflow: hidden; 35 | } 36 | 37 | .choose-file:hover { 38 | cursor: pointer; 39 | } 40 | 41 | .choose-file input[type="file"] { 42 | /* -webkit-appearance: none; */ 43 | /* position: absolute; */ 44 | top: 0; 45 | left: 0; 46 | opacity: 0; 47 | height: 0; 48 | width: 0; 49 | } 50 | 51 | #compose-wrapper { 52 | position: fixed; 53 | bottom: 0; 54 | left: 0; 55 | right: 0; 56 | height: 40px; 57 | border-top: 1px solid #ebebeb; 58 | } 59 | 60 | #text-input { 61 | height: 100%; 62 | /* full width minus send-button width minus choose-file width 63 | (including border * 2 and padding * 2) 64 | */ 65 | width: calc(100% - 70px - 40px - 2px - 8px); 66 | border: none; 67 | font-size: 28px; 68 | padding: 2px 4px; 69 | float: left; 70 | } 71 | 72 | #text-input:focus { 73 | outline: none; 74 | } 75 | 76 | #send-button { 77 | height: 100%; 78 | width: 70px; 79 | font-weight: 500; 80 | display: inline-block; 81 | text-align: center; 82 | transition: all 0.3s; 83 | padding-top: 10px; 84 | float: left; 85 | } 86 | 87 | #send-button:hover { 88 | cursor: pointer; 89 | color: red; 90 | } 91 | 92 | input[name=testfile] { 93 | display: none; 94 | } 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pusher/chatkit-client", 3 | "description": "Pusher Chatkit client SDK for browsers and react native", 4 | "main": "dist/web/chatkit.js", 5 | "version": "1.14.1", 6 | "author": "Pusher", 7 | "license": "MIT", 8 | "homepage": "https://github.com/pusher/chatkit-client-js", 9 | "bugs": { 10 | "url": "https://github.com/pusher/chatkit-client-js/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/pusher/chatkit-client-js.git" 15 | }, 16 | "devDependencies": { 17 | "@pusher/chatkit-server": "^2.0.1", 18 | "@pusher/platform": "^0.18.0", 19 | "@pusher/push-notifications-web": "^0.9.2", 20 | "babel-cli": "^6.26.0", 21 | "babel-core": "^6.26.0", 22 | "babel-plugin-external-helpers": "^6.22.0", 23 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 24 | "babel-plugin-transform-runtime": "^6.23.0", 25 | "babel-preset-env": "^1.6.1", 26 | "babel-preset-es2015": "^6.24.1", 27 | "babelify": "^8.0.0", 28 | "browserify": "^15.2.0", 29 | "eslint": "^5.8.0", 30 | "eslint-config-prettier": "^3.1.0", 31 | "eslint-plugin-prettier": "^3.0.0", 32 | "jest": "^24.8.0", 33 | "jest-puppeteer": "^4.2.0", 34 | "jsonwebtoken": "^8.5.1", 35 | "prettier": "1.14.3", 36 | "publish-please": "^5.2.0", 37 | "puppeteer": "^1.18.1", 38 | "ramda": "^0.25.0", 39 | "request": "^2.88.0", 40 | "request-promise": "^4.2.4", 41 | "rollup": "^0.55.3", 42 | "rollup-plugin-alias": "^1.4.0", 43 | "rollup-plugin-babel": "^3.0.3", 44 | "rollup-plugin-commonjs": "^8.3.0", 45 | "rollup-plugin-json": "^2.3.0", 46 | "rollup-plugin-node-resolve": "^3.0.2", 47 | "rollup-plugin-uglify": "^3.0.0", 48 | "snazzy": "^7.0.0", 49 | "tap-colorize": "^1.2.0", 50 | "tape": "^4.8.0", 51 | "got": "^9.6.0" 52 | }, 53 | "scripts": { 54 | "lint": "eslint src tests/integration tests/unit rollup", 55 | "format": "prettier --write src/**/*.js tests/**/*.js rollup/**/*.js example/**/*.js", 56 | "build": "yarn build:web && yarn build:react-native", 57 | "build:web": "rollup -c rollup/web.js", 58 | "build:react-native": "rollup -c rollup/react-native.js", 59 | "test:unit": "set -e; for file in tests/unit/*.js; do printf '\\n\\e[1;34m%s\\e[0m\\n' $file; browserify $file -t [ babelify --presets env --plugins transform-object-rest-spread ] | node script/puppeteer-run; done", 60 | "test:integration": "browserify tests/integration/main.js -t [ babelify --presets env --plugins transform-runtime transform-object-rest-spread ] | node script/puppeteer-run", 61 | "test": "yarn test:unit && yarn test:integration && jest", 62 | "lint:build": "clear && yarn lint && clear && yarn build", 63 | "lint:build:test": "yarn lint:build && clear && yarn test", 64 | "lint:test:unit": "yarn lint && clear && yarn test:unit", 65 | "publish-please": "publish-please", 66 | "prepublishOnly": "publish-please guard" 67 | }, 68 | "prettier": { 69 | "semi": false, 70 | "trailingComma": "all" 71 | }, 72 | "resolutions": { 73 | "**/**/lodash": "^4.17.13" 74 | }, 75 | "eslintConfig": { 76 | "extends": [ 77 | "prettier", 78 | "eslint:recommended" 79 | ], 80 | "plugins": [ 81 | "prettier" 82 | ], 83 | "rules": { 84 | "prettier/prettier": "error" 85 | }, 86 | "parserOptions": { 87 | "sourceType": "module", 88 | "ecmaVersion": 2018 89 | }, 90 | "env": { 91 | "browser": true, 92 | "es6": true 93 | } 94 | }, 95 | "jest": { 96 | "preset": "jest-puppeteer", 97 | "testRegex": "/tests/jest/[^/]*js$" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /react-native.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/react-native/chatkit') 2 | -------------------------------------------------------------------------------- /rollup/react-native.js: -------------------------------------------------------------------------------- 1 | import { merge } from "ramda" 2 | import alias from "rollup-plugin-alias" 3 | import path from "path" 4 | 5 | import shared from "./shared" 6 | 7 | export default merge(shared, { 8 | output: { 9 | file: "dist/react-native/chatkit.js", 10 | format: "cjs", 11 | name: "Chatkit", 12 | }, 13 | plugins: [ 14 | alias({ 15 | "@pusher/platform": path.resolve( 16 | "./node_modules/@pusher/platform/react-native.js", 17 | ), 18 | }), 19 | ...shared.plugins, 20 | ], 21 | }) 22 | -------------------------------------------------------------------------------- /rollup/shared.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel" 2 | import commonjs from "rollup-plugin-commonjs" 3 | import resolve from "rollup-plugin-node-resolve" 4 | import uglify from "rollup-plugin-uglify" 5 | import json from "rollup-plugin-json" 6 | 7 | const pusherPlatformExports = [ 8 | "BaseClient", 9 | "HOST_BASE", 10 | "Instance", 11 | "sendRawRequest", 12 | ] 13 | 14 | export default { 15 | input: "src/main.js", 16 | plugins: [ 17 | json(), 18 | babel({ 19 | presets: [ 20 | [ 21 | "env", 22 | { 23 | modules: false, 24 | }, 25 | ], 26 | ], 27 | plugins: ["external-helpers", "transform-object-rest-spread"], 28 | exclude: ["node_modules/**"], 29 | }), 30 | resolve(), 31 | commonjs({ 32 | namedExports: { 33 | "node_modules/@pusher/platform/dist/web/pusher-platform.js": pusherPlatformExports, 34 | "node_modules/@pusher/platform/react-native.js": pusherPlatformExports, 35 | }, 36 | }), 37 | uglify(), 38 | ], 39 | } 40 | -------------------------------------------------------------------------------- /rollup/web.js: -------------------------------------------------------------------------------- 1 | import { merge } from "ramda" 2 | 3 | import shared from "./shared" 4 | 5 | export default merge(shared, { 6 | output: { 7 | file: "dist/web/chatkit.js", 8 | format: "umd", 9 | name: "Chatkit", 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /script/puppeteer-run.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require("puppeteer") 2 | const script = require("fs").readFileSync(0, "utf-8") 3 | 4 | puppeteer.launch().then(browser => 5 | browser.newPage().then(page => { 6 | page.on("console", async msg => { 7 | const argsWithRichErrors = await Promise.all( 8 | msg 9 | .args() 10 | .map(arg => 11 | arg 12 | .executionContext() 13 | .evaluate(arg => (arg instanceof Error ? arg.message : arg), arg), 14 | ), 15 | ) 16 | console.log(...argsWithRichErrors) 17 | 18 | // This is a horrendous hack. Tests starting with "ok" or "fail" will 19 | // cause an early exit. 20 | if (/^# ok/.test(msg.text())) { 21 | browser.close() 22 | } else if (/^# fail/.test(msg.text())) { 23 | browser.close().then(() => process.exit(1)) 24 | } 25 | }) 26 | 27 | page.on("pageerror", err => console.error("pageerror:", err)) 28 | page.on("error", err => console.error("error:", err)) 29 | 30 | page.evaluate(script) 31 | }), 32 | ) 33 | -------------------------------------------------------------------------------- /src/attachment.js: -------------------------------------------------------------------------------- 1 | export class Attachment { 2 | constructor(basicAttachment, roomId, instance) { 3 | this.type = basicAttachment.type 4 | this.name = basicAttachment.name 5 | this.size = basicAttachment.size 6 | 7 | if (basicAttachment.customData !== undefined) { 8 | this.customData = basicAttachment.customData 9 | } 10 | 11 | this._id = basicAttachment._id 12 | this._downloadURL = basicAttachment._downloadURL 13 | this._expiration = basicAttachment._expiration 14 | 15 | this._roomId = roomId 16 | this._instance = instance 17 | 18 | this.url = this.url.bind(this) 19 | this.urlExpiry = this.urlExpiry.bind(this) 20 | this._fetchNewDownloadURL = this._fetchNewDownloadURL.bind(this) 21 | } 22 | 23 | url() { 24 | return this.urlExpiry().getTime() - Date.now() < 1000 * 60 * 30 25 | ? this._fetchNewDownloadURL() 26 | : Promise.resolve(this._downloadURL) 27 | } 28 | 29 | urlExpiry() { 30 | return this._expiration 31 | } 32 | 33 | _fetchNewDownloadURL() { 34 | return this._instance 35 | .request({ 36 | method: "GET", 37 | path: `rooms/${encodeURIComponent(this._roomId)}/attachments/${ 38 | this._id 39 | }`, 40 | }) 41 | .then(res => { 42 | const { download_url, expiration } = JSON.parse(res) 43 | this._downloadURL = download_url 44 | this._expiration = new Date(expiration) 45 | return this._downloadURL 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/batch.js: -------------------------------------------------------------------------------- 1 | // `batch` decorates a function with lazy batching logic. Suppose 2 | // 3 | // const g = batch(f, maxWait, maxPending) 4 | // 5 | // Then `g` is a function which takes a single argument, `arg` and returns a Promise. `g` keeps 6 | // track of multiple calls, either for `maxWait` ms after the first call, or until it has been 7 | // called with `maxPending` unique arguments -- whichever comes first. Then `f` is called with an 8 | // array of all the unique arguments at once. If `f` resolves, then all the waiting calls to `g` 9 | // resolve too; likewise if `f` rejects. Once `f` has been called, the process begins again. 10 | export function batch(f, maxWait, maxPending) { 11 | const state = { 12 | callbacks: {}, 13 | pending: new Set(), 14 | inProgress: new Set(), 15 | } 16 | 17 | return arg => { 18 | return new Promise((resolve, reject) => { 19 | if (state.pending.has(arg) || state.inProgress.has(arg)) { 20 | state.callbacks[arg].push({ resolve, reject }) 21 | } else { 22 | state.pending.add(arg) 23 | state.callbacks[arg] = [{ resolve, reject }] 24 | } 25 | 26 | if (state.pending.size >= maxPending) { 27 | clearTimeout(state.timeout) 28 | fire(f, state) 29 | delete state.timeout 30 | } else if (!state.timeout) { 31 | state.timeout = setTimeout(() => { 32 | fire(f, state) 33 | delete state.timeout 34 | }, maxWait) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | function fire(f, state) { 41 | const args = [] 42 | for (let arg of state.pending) { 43 | args.push(arg) 44 | state.inProgress.add(arg) 45 | } 46 | 47 | state.pending.clear() 48 | 49 | return f(args) 50 | .then(res => { 51 | for (let arg of args) { 52 | for (let callbacks of state.callbacks[arg]) { 53 | callbacks.resolve(res) 54 | } 55 | state.inProgress.delete(arg) 56 | delete state.callbacks[arg] 57 | } 58 | }) 59 | .catch(err => { 60 | for (let arg of args) { 61 | for (let callbacks of state.callbacks[arg]) { 62 | callbacks.reject(err) 63 | } 64 | state.inProgress.delete(arg) 65 | delete state.callbacks[arg] 66 | } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /src/chat-manager.js: -------------------------------------------------------------------------------- 1 | import { BaseClient, HOST_BASE, Instance } from "@pusher/platform" 2 | import { split } from "ramda" 3 | 4 | import { CurrentUser } from "./current-user" 5 | import { typeCheck, typeCheckObj } from "./utils" 6 | import { DEFAULT_CONNECTION_TIMEOUT } from "./constants" 7 | 8 | import { version as sdkVersion } from "../package.json" 9 | import * as PusherPushNotifications from "@pusher/push-notifications-web" 10 | 11 | export class ChatManager { 12 | constructor({ instanceLocator, tokenProvider, userId, ...options } = {}) { 13 | typeCheck("instanceLocator", "string", instanceLocator) 14 | typeCheck("tokenProvider", "object", tokenProvider) 15 | typeCheck("tokenProvider.fetchToken", "function", tokenProvider.fetchToken) 16 | typeCheck("userId", "string", userId) 17 | const [version, cluster, instanceId] = split(":", instanceLocator) 18 | if (!version || !cluster || !instanceId) { 19 | throw new TypeError( 20 | `expected instanceLocator to be of the format x:y:z, but was ${instanceLocator}`, 21 | ) 22 | } 23 | const baseClient = 24 | options.baseClient || 25 | new BaseClient({ 26 | host: `${cluster}.${HOST_BASE}`, 27 | logger: options.logger, 28 | sdkProduct: "chatkit", 29 | sdkLanguage: options.sdkLanguage, 30 | sdkVersion, 31 | }) 32 | if (typeof tokenProvider.setUserId === "function") { 33 | tokenProvider.setUserId(userId) 34 | } 35 | const instanceOptions = { 36 | client: baseClient, 37 | locator: instanceLocator, 38 | logger: options.logger, 39 | tokenProvider, 40 | } 41 | this.serverInstanceV2 = new Instance({ 42 | serviceName: "chatkit", 43 | serviceVersion: "v2", 44 | ...instanceOptions, 45 | }) 46 | this.serverInstanceV6 = new Instance({ 47 | serviceName: "chatkit", 48 | serviceVersion: "v6", 49 | ...instanceOptions, 50 | }) 51 | this.filesInstance = new Instance({ 52 | serviceName: "chatkit_files", 53 | serviceVersion: "v1", 54 | ...instanceOptions, 55 | }) 56 | this.cursorsInstance = new Instance({ 57 | serviceName: "chatkit_cursors", 58 | serviceVersion: "v2", 59 | ...instanceOptions, 60 | }) 61 | this.presenceInstance = new Instance({ 62 | serviceName: "chatkit_presence", 63 | serviceVersion: "v2", 64 | ...instanceOptions, 65 | }) 66 | this.beamsTokenProviderInstance = new Instance({ 67 | serviceName: "chatkit_beams_token_provider", 68 | serviceVersion: "v1", 69 | ...instanceOptions, 70 | }) 71 | this.pushNotificationsInstance = new Instance({ 72 | serviceName: "chatkit_push_notifications", 73 | serviceVersion: "v1", 74 | ...instanceOptions, 75 | }) 76 | // capturing the `instanceId` in a closure here as the `CurrentUser` model 77 | // doesn't need to be concerned about such details 78 | this.beamsInstanceInitFn = 79 | options.beamsInstanceInitFn || 80 | (args => { 81 | return PusherPushNotifications.init({ 82 | instanceId, 83 | ...args, 84 | }) 85 | }) 86 | 87 | this.logger = this.serverInstanceV6.logger 88 | this.userId = userId 89 | this.connectionTimeout = 90 | options.connectionTimeout || DEFAULT_CONNECTION_TIMEOUT 91 | 92 | this.connect = this.connect.bind(this) 93 | this.disconnect = this.disconnect.bind(this) 94 | } 95 | 96 | connect(hooks = {}) { 97 | typeCheckObj("hooks", "function", hooks) 98 | const currentUser = new CurrentUser({ 99 | hooks, 100 | id: this.userId, 101 | serverInstanceV2: this.serverInstanceV2, 102 | serverInstanceV6: this.serverInstanceV6, 103 | filesInstance: this.filesInstance, 104 | cursorsInstance: this.cursorsInstance, 105 | presenceInstance: this.presenceInstance, 106 | beamsTokenProviderInstance: this.beamsTokenProviderInstance, 107 | pushNotificationsInstance: this.pushNotificationsInstance, 108 | beamsInstanceInitFn: this.beamsInstanceInitFn, 109 | connectionTimeout: this.connectionTimeout, 110 | }) 111 | return Promise.all([ 112 | currentUser.establishUserSubscription(), 113 | currentUser.establishPresenceSubscription(), 114 | ]).then(() => { 115 | this.currentUser = currentUser 116 | return currentUser 117 | }) 118 | } 119 | 120 | disconnect() { 121 | if (this.currentUser) this.currentUser.disconnect() 122 | } 123 | 124 | disablePushNotifications() { 125 | if (this.currentUser) { 126 | return this.currentUser.disablePushNotifications().catch(err => { 127 | return Promise.reject( 128 | `Chatkit error when disabling push notifications: ${err.message}`, 129 | ) 130 | }) 131 | } else { 132 | return Promise.reject( 133 | "Cannot disable notifications until .connect is called", 134 | ) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const TYPING_INDICATOR_TTL = 1500 2 | export const TYPING_INDICATOR_LEEWAY = 500 3 | export const SET_CURSOR_WAIT = 500 4 | export const MISSING_USER_WAIT = 50 5 | export const MAX_FETCH_USER_BATCH = 250 6 | export const DEFAULT_CONNECTION_TIMEOUT = 20 * 1000 7 | -------------------------------------------------------------------------------- /src/current-user.js: -------------------------------------------------------------------------------- 1 | import { 2 | contains, 3 | has, 4 | map, 5 | forEachObjIndexed, 6 | max, 7 | pipe, 8 | prop, 9 | sort, 10 | uniq, 11 | values, 12 | } from "ramda" 13 | 14 | import { sendRawRequest } from "@pusher/platform" 15 | 16 | import { 17 | checkOneOf, 18 | typeCheck, 19 | typeCheckArr, 20 | typeCheckObj, 21 | urlEncode, 22 | } from "./utils" 23 | import { parseBasicMessage, parseBasicRoom } from "./parsers" 24 | import { UserStore } from "./user-store" 25 | import { RoomStore } from "./room-store" 26 | import { CursorStore } from "./cursor-store" 27 | import { TypingIndicators } from "./typing-indicators" 28 | import { UserSubscription } from "./user-subscription" 29 | import { PresenceSubscription } from "./presence-subscription" 30 | import { UserPresenceSubscription } from "./user-presence-subscription" 31 | import { RoomSubscription } from "./room-subscription" 32 | import { NotificationSubscription } from "./notification-subscription" 33 | import { showNotification } from "./notification" 34 | import { Message } from "./message" 35 | import { SET_CURSOR_WAIT } from "./constants" 36 | 37 | export class CurrentUser { 38 | constructor({ 39 | serverInstanceV2, 40 | serverInstanceV6, 41 | connectionTimeout, 42 | cursorsInstance, 43 | filesInstance, 44 | hooks, 45 | id, 46 | presenceInstance, 47 | beamsTokenProviderInstance, 48 | pushNotificationsInstance, 49 | beamsInstanceInitFn, 50 | }) { 51 | this.hooks = { 52 | global: hooks, 53 | rooms: {}, 54 | } 55 | this.id = id 56 | this.encodedId = encodeURIComponent(this.id) 57 | this.serverInstanceV2 = serverInstanceV2 58 | this.serverInstanceV6 = serverInstanceV6 59 | this.filesInstance = filesInstance 60 | this.cursorsInstance = cursorsInstance 61 | this.connectionTimeout = connectionTimeout 62 | this.presenceInstance = presenceInstance 63 | this.beamsTokenProviderInstance = beamsTokenProviderInstance 64 | this.pushNotificationsInstance = pushNotificationsInstance 65 | this.beamsInstanceInitFn = beamsInstanceInitFn 66 | this.logger = serverInstanceV6.logger 67 | this.presenceStore = {} 68 | this.userStore = new UserStore({ 69 | instance: this.serverInstanceV6, 70 | presenceStore: this.presenceStore, 71 | logger: this.logger, 72 | }) 73 | this.roomStore = new RoomStore({ 74 | instance: this.serverInstanceV6, 75 | userStore: this.userStore, 76 | isSubscribedTo: userId => this.isSubscribedTo(userId), 77 | logger: this.logger, 78 | }) 79 | this.cursorStore = new CursorStore({ 80 | instance: this.cursorsInstance, 81 | userStore: this.userStore, 82 | roomStore: this.roomStore, 83 | logger: this.logger, 84 | }) 85 | this.typingIndicators = new TypingIndicators({ 86 | hooks: this.hooks, 87 | instance: this.serverInstanceV6, 88 | logger: this.logger, 89 | }) 90 | this.userStore.onSetHooks.push(userId => 91 | this.subscribeToUserPresence(userId), 92 | ) 93 | this.roomSubscriptions = {} 94 | this.readCursorBuffer = {} // roomId -> { position, [{ resolve, reject }] } 95 | this.userPresenceSubscriptions = {} 96 | 97 | this.setReadCursor = this.setReadCursor.bind(this) 98 | this.readCursor = this.readCursor.bind(this) 99 | this.isTypingIn = this.isTypingIn.bind(this) 100 | this.createRoom = this.createRoom.bind(this) 101 | this.getJoinableRooms = this.getJoinableRooms.bind(this) 102 | this.joinRoom = this.joinRoom.bind(this) 103 | this.leaveRoom = this.leaveRoom.bind(this) 104 | this.addUserToRoom = this.addUserToRoom.bind(this) 105 | this.removeUserFromRoom = this.removeUserFromRoom.bind(this) 106 | this.sendMessage = this.sendMessage.bind(this) 107 | this.sendSimpleMessage = this.sendSimpleMessage.bind(this) 108 | this.sendMultipartMessage = this.sendMultipartMessage.bind(this) 109 | this.fetchMessages = this.fetchMessages.bind(this) 110 | this.fetchMultipartMessages = this.fetchMultipartMessages.bind(this) 111 | this.subscribeToRoom = this.subscribeToRoom.bind(this) 112 | this.subscribeToRoomMultipart = this.subscribeToRoomMultipart.bind(this) 113 | this.updateRoom = this.updateRoom.bind(this) 114 | this.deleteRoom = this.deleteRoom.bind(this) 115 | this.setReadCursorRequest = this.setReadCursorRequest.bind(this) 116 | this.uploadDataAttachment = this.uploadDataAttachment.bind(this) 117 | this.isMemberOf = this.isMemberOf.bind(this) 118 | this.isSubscribedTo = this.isSubscribedTo.bind(this) 119 | this.decorateMessage = this.decorateMessage.bind(this) 120 | this.setPropertiesFromBasicUser = this.setPropertiesFromBasicUser.bind(this) 121 | this.establishUserSubscription = this.establishUserSubscription.bind(this) 122 | this.establishPresenceSubscription = this.establishPresenceSubscription.bind( 123 | this, 124 | ) 125 | this.subscribeToUserPresence = this.subscribeToUserPresence.bind(this) 126 | this.disconnect = this.disconnect.bind(this) 127 | this._uploadAttachment = this._uploadAttachment.bind(this) 128 | } 129 | 130 | /* public */ 131 | 132 | get rooms() { 133 | return values(this.roomStore.snapshot()) 134 | } 135 | 136 | get users() { 137 | return values(this.userStore.snapshot()) 138 | } 139 | 140 | setReadCursor({ roomId, position } = {}) { 141 | typeCheck("roomId", "string", roomId) 142 | typeCheck("position", "number", position) 143 | return new Promise((resolve, reject) => { 144 | if (this.readCursorBuffer[roomId] !== undefined) { 145 | this.readCursorBuffer[roomId].position = max( 146 | this.readCursorBuffer[roomId].position, 147 | position, 148 | ) 149 | this.readCursorBuffer[roomId].callbacks.push({ resolve, reject }) 150 | } else { 151 | this.readCursorBuffer[roomId] = { 152 | position, 153 | callbacks: [{ resolve, reject }], 154 | } 155 | setTimeout(() => { 156 | this.setReadCursorRequest({ 157 | roomId, 158 | ...this.readCursorBuffer[roomId], 159 | }) 160 | delete this.readCursorBuffer[roomId] 161 | }, SET_CURSOR_WAIT) 162 | } 163 | }) 164 | } 165 | 166 | readCursor({ roomId, userId = this.id } = {}) { 167 | typeCheck("roomId", "string", roomId) 168 | typeCheck("userId", "string", userId) 169 | if (userId !== this.id && !this.isSubscribedTo(roomId)) { 170 | const err = new Error( 171 | `Must be subscribed to room ${roomId} to access member's read cursors`, 172 | ) 173 | this.logger.error(err) 174 | throw err 175 | } 176 | return this.cursorStore.getSync(userId, roomId) 177 | } 178 | 179 | isTypingIn({ roomId } = {}) { 180 | typeCheck("roomId", "string", roomId) 181 | return this.typingIndicators.sendThrottledRequest(roomId) 182 | } 183 | 184 | createRoom({ 185 | id, 186 | name, 187 | pushNotificationTitleOverride, 188 | addUserIds, 189 | customData, 190 | ...rest 191 | } = {}) { 192 | id && typeCheck("id", "string", id) 193 | name && typeCheck("name", "string", name) 194 | pushNotificationTitleOverride && 195 | typeCheck( 196 | "pushNotificationTitleOverride", 197 | "string", 198 | pushNotificationTitleOverride, 199 | ) 200 | addUserIds && typeCheckArr("addUserIds", "string", addUserIds) 201 | customData && typeCheck("customData", "object", customData) 202 | return this.serverInstanceV6 203 | .request({ 204 | method: "POST", 205 | path: "/rooms", 206 | json: { 207 | id, 208 | created_by_id: this.id, 209 | name, 210 | push_notification_title_override: pushNotificationTitleOverride, 211 | private: !!rest.private, // private is a reserved word in strict mode! 212 | user_ids: addUserIds, 213 | custom_data: customData, 214 | }, 215 | }) 216 | .then(res => this.roomStore.set(parseBasicRoom(JSON.parse(res)))) 217 | .catch(err => { 218 | this.logger.warn("error creating room:", err) 219 | throw err 220 | }) 221 | } 222 | 223 | getJoinableRooms() { 224 | return this.serverInstanceV6 225 | .request({ 226 | method: "GET", 227 | path: `/users/${this.encodedId}/rooms?joinable=true`, 228 | }) 229 | .then( 230 | pipe( 231 | JSON.parse, 232 | map(parseBasicRoom), 233 | ), 234 | ) 235 | .catch(err => { 236 | this.logger.warn("error getting joinable rooms:", err) 237 | throw err 238 | }) 239 | } 240 | 241 | joinRoom({ roomId } = {}) { 242 | typeCheck("roomId", "string", roomId) 243 | if (this.isMemberOf(roomId)) { 244 | return this.roomStore.get(roomId) 245 | } 246 | return this.serverInstanceV6 247 | .request({ 248 | method: "POST", 249 | path: `/users/${this.encodedId}/rooms/${encodeURIComponent( 250 | roomId, 251 | )}/join`, 252 | }) 253 | .then(res => this.roomStore.set(parseBasicRoom(JSON.parse(res)))) 254 | .catch(err => { 255 | this.logger.warn(`error joining room ${roomId}:`, err) 256 | throw err 257 | }) 258 | } 259 | 260 | leaveRoom({ roomId } = {}) { 261 | typeCheck("roomId", "string", roomId) 262 | return this.roomStore 263 | .get(roomId) 264 | .then(room => 265 | this.serverInstanceV6 266 | .request({ 267 | method: "POST", 268 | path: `/users/${this.encodedId}/rooms/${encodeURIComponent( 269 | roomId, 270 | )}/leave`, 271 | }) 272 | .then(() => room), 273 | ) 274 | .catch(err => { 275 | this.logger.warn(`error leaving room ${roomId}:`, err) 276 | throw err 277 | }) 278 | } 279 | 280 | addUserToRoom({ userId, roomId } = {}) { 281 | typeCheck("userId", "string", userId) 282 | typeCheck("roomId", "string", roomId) 283 | return this.serverInstanceV6 284 | .request({ 285 | method: "PUT", 286 | path: `/rooms/${encodeURIComponent(roomId)}/users/add`, 287 | json: { 288 | user_ids: [userId], 289 | }, 290 | }) 291 | .then(() => this.roomStore.addUserToRoom(roomId, userId)) 292 | .catch(err => { 293 | this.logger.warn(`error adding user ${userId} to room ${roomId}:`, err) 294 | throw err 295 | }) 296 | } 297 | 298 | removeUserFromRoom({ userId, roomId } = {}) { 299 | typeCheck("userId", "string", userId) 300 | typeCheck("roomId", "string", roomId) 301 | return this.serverInstanceV6 302 | .request({ 303 | method: "PUT", 304 | path: `/rooms/${encodeURIComponent(roomId)}/users/remove`, 305 | json: { 306 | user_ids: [userId], 307 | }, 308 | }) 309 | .then(() => this.roomStore.removeUserFromRoom(roomId, userId)) 310 | .catch(err => { 311 | this.logger.warn( 312 | `error removing user ${userId} from room ${roomId}:`, 313 | err, 314 | ) 315 | throw err 316 | }) 317 | } 318 | 319 | sendMessage({ text, roomId, attachment } = {}) { 320 | typeCheck("text", "string", text) 321 | typeCheck("roomId", "string", roomId) 322 | return new Promise((resolve, reject) => { 323 | if (attachment !== undefined && isDataAttachment(attachment)) { 324 | resolve(this.uploadDataAttachment(roomId, attachment)) 325 | } else if (attachment !== undefined && isLinkAttachment(attachment)) { 326 | resolve({ resource_link: attachment.link, type: attachment.type }) 327 | } else if (attachment !== undefined) { 328 | reject(new TypeError("attachment was malformed")) 329 | } else { 330 | resolve() 331 | } 332 | }) 333 | .then(attachment => 334 | this.serverInstanceV2.request({ 335 | method: "POST", 336 | path: `/rooms/${encodeURIComponent(roomId)}/messages`, 337 | json: { text, attachment }, 338 | }), 339 | ) 340 | .then( 341 | pipe( 342 | JSON.parse, 343 | prop("message_id"), 344 | ), 345 | ) 346 | .catch(err => { 347 | this.logger.warn(`error sending message to room ${roomId}:`, err) 348 | throw err 349 | }) 350 | } 351 | 352 | sendSimpleMessage({ roomId, text } = {}) { 353 | return this.sendMultipartMessage({ 354 | roomId, 355 | parts: [{ type: "text/plain", content: text }], 356 | }) 357 | } 358 | 359 | sendMultipartMessage({ roomId, parts } = {}) { 360 | typeCheck("roomId", "string", roomId) 361 | typeCheckArr("parts", "object", parts) 362 | if (parts.length === 0) { 363 | return Promise.reject( 364 | new TypeError("message must contain at least one part"), 365 | ) 366 | } 367 | return Promise.all( 368 | parts.map(part => { 369 | part.type = part.type || (part.file && part.file.type) 370 | typeCheck("part.type", "string", part.type) 371 | part.content && typeCheck("part.content", "string", part.content) 372 | part.url && typeCheck("part.url", "string", part.url) 373 | part.name && typeCheck("part.name", "string", part.name) 374 | part.file && typeCheck("part.file.size", "number", part.file.size) 375 | return part.file ? this._uploadAttachment({ roomId, part }) : part 376 | }), 377 | ) 378 | .then(parts => 379 | this.serverInstanceV6.request({ 380 | method: "POST", 381 | path: `/rooms/${encodeURIComponent(roomId)}/messages`, 382 | json: { 383 | parts: parts.map(({ type, content, url, attachment }) => ({ 384 | type, 385 | content, 386 | url, 387 | attachment, 388 | })), 389 | }, 390 | }), 391 | ) 392 | .then(res => JSON.parse(res).message_id) 393 | .catch(err => { 394 | this.logger.warn(`error sending message to room ${roomId}:`, err) 395 | throw err 396 | }) 397 | } 398 | 399 | fetchMessages({ roomId, initialId, limit, direction, serverInstance } = {}) { 400 | typeCheck("roomId", "string", roomId) 401 | initialId && typeCheck("initialId", "number", initialId) 402 | limit && typeCheck("limit", "number", limit) 403 | direction && checkOneOf("direction", ["older", "newer"], direction) 404 | return (serverInstance || this.serverInstanceV2) 405 | .request({ 406 | method: "GET", 407 | path: `/rooms/${encodeURIComponent(roomId)}/messages?${urlEncode({ 408 | initial_id: initialId, 409 | limit, 410 | direction, 411 | })}`, 412 | }) 413 | .then(res => { 414 | const messages = JSON.parse(res).map(m => 415 | this.decorateMessage(parseBasicMessage(m)), 416 | ) 417 | return this.userStore 418 | .fetchMissingUsers(uniq(map(prop("senderId"), messages))) 419 | .then(() => sort((x, y) => x.id - y.id, messages)) 420 | }) 421 | .catch(err => { 422 | this.logger.warn(`error fetching messages from room ${roomId}:`, err) 423 | throw err 424 | }) 425 | } 426 | 427 | fetchMultipartMessages(options = {}) { 428 | return this.fetchMessages({ 429 | ...options, 430 | serverInstance: this.serverInstanceV6, 431 | }) 432 | } 433 | 434 | subscribeToRoom({ roomId, hooks = {}, messageLimit, serverInstance } = {}) { 435 | typeCheck("roomId", "string", roomId) 436 | typeCheckObj("hooks", "function", hooks) 437 | if (!serverInstance && hooks.onMessageDeleted) { 438 | // v2 does not send message_deleted events 439 | // eslint-disable-next-line no-console 440 | this.logger.warn( 441 | "`subscribeToRoom` does not support the `onMessageDeleted` hook. Please use `subscribeToRoomMultipart` instead.", 442 | ) 443 | } 444 | messageLimit && typeCheck("messageLimit", "number", messageLimit) 445 | if (this.roomSubscriptions[roomId]) { 446 | this.roomSubscriptions[roomId].cancel() 447 | } 448 | this.hooks.rooms[roomId] = hooks 449 | const roomSubscription = new RoomSubscription({ 450 | serverInstance: serverInstance || this.serverInstanceV2, 451 | connectionTimeout: this.connectionTimeout, 452 | cursorStore: this.cursorStore, 453 | cursorsInstance: this.cursorsInstance, 454 | hooks: this.hooks, 455 | logger: this.logger, 456 | messageLimit, 457 | roomId, 458 | roomStore: this.roomStore, 459 | typingIndicators: this.typingIndicators, 460 | userId: this.id, 461 | userStore: this.userStore, 462 | }) 463 | this.roomSubscriptions[roomId] = roomSubscription 464 | return this.joinRoom({ roomId }) 465 | .then(room => roomSubscription.connect().then(() => room)) 466 | .catch(err => { 467 | this.logger.warn(`error subscribing to room ${roomId}:`, err) 468 | throw err 469 | }) 470 | } 471 | 472 | subscribeToRoomMultipart(options = {}) { 473 | return this.subscribeToRoom({ 474 | ...options, 475 | serverInstance: this.serverInstanceV6, 476 | }) 477 | } 478 | 479 | updateRoom({ 480 | roomId, 481 | name, 482 | pushNotificationTitleOverride, 483 | customData, 484 | ...rest 485 | } = {}) { 486 | typeCheck("roomId", "string", roomId) 487 | name && typeCheck("name", "string", name) 488 | pushNotificationTitleOverride && 489 | typeCheck( 490 | "pushNotificationTitleOverride", 491 | "string", 492 | pushNotificationTitleOverride, 493 | ) 494 | rest.private && typeCheck("private", "boolean", rest.private) 495 | customData && typeCheck("customData", "object", customData) 496 | return this.serverInstanceV6 497 | .request({ 498 | method: "PUT", 499 | path: `/rooms/${encodeURIComponent(roomId)}`, 500 | json: { 501 | name, 502 | push_notification_title_override: pushNotificationTitleOverride, 503 | private: rest.private, // private is a reserved word in strict mode! 504 | custom_data: customData, 505 | }, 506 | }) 507 | .then(() => {}) 508 | .catch(err => { 509 | this.logger.warn("error updating room:", err) 510 | throw err 511 | }) 512 | } 513 | 514 | deleteRoom({ roomId } = {}) { 515 | typeCheck("roomId", "string", roomId) 516 | return this.serverInstanceV2 517 | .request({ 518 | method: "DELETE", 519 | path: `/rooms/${encodeURIComponent(roomId)}`, 520 | }) 521 | .then(() => {}) 522 | .catch(err => { 523 | this.logger.warn("error deleting room:", err) 524 | throw err 525 | }) 526 | } 527 | 528 | /* internal */ 529 | 530 | setReadCursorRequest({ roomId, position, callbacks }) { 531 | return this.cursorsInstance 532 | .request({ 533 | method: "PUT", 534 | path: `/cursors/0/rooms/${encodeURIComponent(roomId)}/users/${ 535 | this.encodedId 536 | }`, 537 | json: { position }, 538 | }) 539 | .then(() => map(x => x.resolve(), callbacks)) 540 | .catch(err => { 541 | this.logger.warn("error setting cursor:", err) 542 | map(x => x.reject(err), callbacks) 543 | }) 544 | } 545 | 546 | uploadDataAttachment(roomId, { file, name }) { 547 | // TODO polyfill FormData? 548 | const body = new FormData() // eslint-disable-line no-undef 549 | body.append("file", file, name) 550 | return this.filesInstance 551 | .request({ 552 | method: "POST", 553 | path: `/rooms/${encodeURIComponent(roomId)}/users/${ 554 | this.encodedId 555 | }/files/${encodeURIComponent(name)}`, 556 | body, 557 | }) 558 | .then(JSON.parse) 559 | } 560 | 561 | _uploadAttachment({ roomId, part: { type, name, customData, file } }) { 562 | return this.serverInstanceV6 563 | .request({ 564 | method: "POST", 565 | path: `/rooms/${encodeURIComponent(roomId)}/attachments`, 566 | json: { 567 | content_type: type, 568 | content_length: file.size, 569 | name: name || file.name, 570 | custom_data: customData, 571 | }, 572 | }) 573 | .then(res => { 574 | const { 575 | attachment_id: attachmentId, 576 | upload_url: uploadURL, 577 | } = JSON.parse(res) 578 | return sendRawRequest({ 579 | method: "PUT", 580 | url: uploadURL, 581 | body: file, 582 | headers: { 583 | "content-type": type, 584 | }, 585 | }).then(() => ({ type, attachment: { id: attachmentId } })) 586 | }) 587 | } 588 | 589 | isMemberOf(roomId) { 590 | return contains(roomId, map(prop("id"), this.rooms)) 591 | } 592 | 593 | isSubscribedTo(roomId) { 594 | return has(roomId, this.roomSubscriptions) 595 | } 596 | 597 | decorateMessage(basicMessage) { 598 | return new Message( 599 | basicMessage, 600 | this.userStore, 601 | this.roomStore, 602 | this.serverInstanceV6, 603 | ) 604 | } 605 | 606 | setPropertiesFromBasicUser(basicUser) { 607 | this.avatarURL = basicUser.avatarURL 608 | this.createdAt = basicUser.createdAt 609 | this.customData = basicUser.customData 610 | this.name = basicUser.name 611 | this.updatedAt = basicUser.updatedAt 612 | } 613 | 614 | establishUserSubscription() { 615 | this.userSubscription = new UserSubscription({ 616 | hooks: this.hooks, 617 | userId: this.id, 618 | instance: this.serverInstanceV6, 619 | roomStore: this.roomStore, 620 | cursorStore: this.cursorStore, 621 | typingIndicators: this.typingIndicators, 622 | logger: this.logger, 623 | connectionTimeout: this.connectionTimeout, 624 | currentUser: this, 625 | }) 626 | return this.userSubscription 627 | .connect() 628 | .then(({ basicUser, basicRooms, basicCursors }) => { 629 | this.setPropertiesFromBasicUser(basicUser) 630 | return Promise.all([ 631 | ...basicRooms.map(basicRoom => this.roomStore.set(basicRoom)), 632 | ...basicCursors.map(basicCursor => this.cursorStore.set(basicCursor)), 633 | ]) 634 | }) 635 | .catch(err => { 636 | this.logger.error("error establishing user subscription:", err) 637 | throw err 638 | }) 639 | } 640 | 641 | establishPresenceSubscription() { 642 | this.presenceSubscription = new PresenceSubscription({ 643 | userId: this.id, 644 | instance: this.presenceInstance, 645 | logger: this.logger, 646 | connectionTimeout: this.connectionTimeout, 647 | }) 648 | 649 | return Promise.all([ 650 | this.userStore.fetchMissingUser(this.id), 651 | this.subscribeToUserPresence(this.id), 652 | this.presenceSubscription.connect().catch(err => { 653 | this.logger.warn("error establishing presence subscription:", err) 654 | throw err 655 | }), 656 | ]) 657 | } 658 | 659 | subscribeToUserPresence(userId) { 660 | if (this.userPresenceSubscriptions[userId]) { 661 | return Promise.resolve() 662 | } 663 | 664 | const userPresenceSub = new UserPresenceSubscription({ 665 | hooks: this.hooks, 666 | userId: userId, 667 | instance: this.presenceInstance, 668 | userStore: this.userStore, 669 | roomStore: this.roomStore, 670 | presenceStore: this.presenceStore, 671 | logger: this.logger, 672 | connectionTimeout: this.connectionTimeout, 673 | }) 674 | 675 | this.userPresenceSubscriptions[userId] = userPresenceSub 676 | return userPresenceSub.connect() 677 | } 678 | 679 | enablePushNotifications({ 680 | onClick, 681 | serviceWorkerRegistration, 682 | showNotificationsTabOpen = true, 683 | showNotificationsTabClosed = true, 684 | _Notification = Notification, 685 | _visibilityStateOverride, 686 | } = {}) { 687 | try { 688 | onClick && typeCheck("onClick", "function", onClick) 689 | typeCheck("showNotificationsTabOpen", "boolean", showNotificationsTabOpen) 690 | typeCheck( 691 | "showNotificationsTabClosed", 692 | "boolean", 693 | showNotificationsTabClosed, 694 | ) 695 | 696 | if (!this._hasPermissionToSendNotifications()) { 697 | return Promise.reject("Failed to get permission to send notifications") 698 | } 699 | 700 | const actions = [] 701 | if (showNotificationsTabOpen) { 702 | actions.push( 703 | this._enableTabOpenNotifications({ 704 | onClick, 705 | Notification: _Notification, 706 | visibilityStateOverride: _visibilityStateOverride, 707 | }), 708 | ) 709 | } 710 | 711 | if (showNotificationsTabClosed) { 712 | actions.push( 713 | this._enableTabClosedNotifications(serviceWorkerRegistration), 714 | ) 715 | } else { 716 | actions.push(this._disableTabClosedNotifications()) 717 | } 718 | 719 | return Promise.all(actions).catch(err => { 720 | this.logger.warn(`Chatkit error when enabling push notifications:`, err) 721 | return Promise.reject( 722 | `Chatkit error when enabling push notifications: ${err}`, 723 | ) 724 | }) 725 | } catch (err) { 726 | this.logger.warn(`Chatkit error when enabling push notifications:`, err) 727 | return Promise.reject( 728 | `Chatkit error when enabling push notifications: ${err}`, 729 | ) 730 | } 731 | } 732 | 733 | disablePushNotifications() { 734 | return this._disableTabClosedNotifications().then(() => { 735 | return this._disableTabOpenNotifications() 736 | }) 737 | } 738 | 739 | _hasPermissionToSendNotifications() { 740 | return Notification.requestPermission().then( 741 | permission => permission === "granted", 742 | ) 743 | } 744 | 745 | _enableTabOpenNotifications({ 746 | onClick, 747 | Notification, 748 | visibilityStateOverride, 749 | }) { 750 | const notificationSubscription = new NotificationSubscription({ 751 | onNotificationHook: ({ notification, data }) => 752 | showNotification({ 753 | notification, 754 | data, 755 | onClick, 756 | Notification, 757 | visibilityStateOverride, 758 | }), 759 | userId: this.id, 760 | instance: this.pushNotificationsInstance, 761 | logger: this.logger, 762 | connectionTimeout: this.connectionTimeout, 763 | }) 764 | this.notificationSubscription = notificationSubscription 765 | 766 | return notificationSubscription.connect() 767 | } 768 | 769 | _disableTabOpenNotifications() { 770 | this.notificationSubscription.cancel() 771 | } 772 | 773 | _enableTabClosedNotifications(serviceWorkerRegistration) { 774 | const fetchBeamsToken = userId => 775 | this.beamsTokenProviderInstance 776 | .request({ 777 | method: "GET", 778 | path: `/beams-tokens?user_id=${encodeURIComponent(userId)}`, 779 | }) 780 | .then(JSON.parse) 781 | .catch(req => { 782 | return Promise.reject( 783 | `Internal error: ${req.statusCode} status code, info: ${ 784 | req.info.error_description 785 | }`, 786 | ) 787 | }) 788 | 789 | return this.beamsInstanceInitFn({ 790 | serviceWorkerRegistration, 791 | }) 792 | .then(beamsClient => beamsClient.start()) 793 | .then(beamsClient => { 794 | return beamsClient.setUserId(this.id, { 795 | fetchToken: fetchBeamsToken, 796 | }) 797 | }) 798 | } 799 | 800 | _disableTabClosedNotifications() { 801 | return this.beamsInstanceInitFn().then(beamsClient => beamsClient.stop()) 802 | } 803 | 804 | disconnect() { 805 | this.userSubscription.cancel() 806 | this.presenceSubscription.cancel() 807 | if (this.notificationSubscription) { 808 | this.notificationSubscription.cancel() 809 | } 810 | forEachObjIndexed(sub => sub.cancel(), this.roomSubscriptions) 811 | forEachObjIndexed(sub => sub.cancel(), this.userPresenceSubscriptions) 812 | } 813 | } 814 | 815 | const isDataAttachment = ({ file, name }) => { 816 | if (file === undefined || name === undefined) { 817 | return false 818 | } 819 | typeCheck("attachment.file", "object", file) 820 | typeCheck("attachment.name", "string", name) 821 | return true 822 | } 823 | 824 | const isLinkAttachment = ({ link, type }) => { 825 | if (link === undefined || type === undefined) { 826 | return false 827 | } 828 | typeCheck("attachment.link", "string", link) 829 | typeCheck("attachment.type", "string", type) 830 | return true 831 | } 832 | -------------------------------------------------------------------------------- /src/cursor-store.js: -------------------------------------------------------------------------------- 1 | import { Cursor } from "./cursor" 2 | import { parseBasicCursor } from "./parsers" 3 | 4 | export class CursorStore { 5 | constructor({ instance, userStore, roomStore, logger }) { 6 | this.instance = instance 7 | this.userStore = userStore 8 | this.roomStore = roomStore 9 | this.logger = logger 10 | this.cursors = {} 11 | 12 | this.set = this.set.bind(this) 13 | this.get = this.get.bind(this) 14 | this.getSync = this.getSync.bind(this) 15 | this.fetchBasicCursor = this.fetchBasicCursor.bind(this) 16 | this.decorate = this.decorate.bind(this) 17 | } 18 | 19 | set(basicCursor) { 20 | const k = key(basicCursor.userId, basicCursor.roomId) 21 | this.cursors[k] = this.decorate(basicCursor) 22 | return this.userStore 23 | .fetchMissingUser(basicCursor.userId) 24 | .then(() => this.cursors[k]) 25 | } 26 | 27 | get(userId, roomId) { 28 | const k = key(userId, roomId) 29 | if (this.cursors[k]) { 30 | return Promise.resolve(this.cursors[k]) 31 | } 32 | return this.fetchBasicCursor(userId, roomId).then(basicCursor => 33 | this.set(basicCursor), 34 | ) 35 | } 36 | 37 | getSync(userId, roomId) { 38 | return this.cursors[key(userId, roomId)] 39 | } 40 | 41 | fetchBasicCursor(userId, roomId) { 42 | return this.instance 43 | .request({ 44 | method: "GET", 45 | path: `/cursors/0/rooms/${encodeURIComponent( 46 | roomId, 47 | )}/users/${encodeURIComponent(userId)}`, 48 | }) 49 | .then(res => { 50 | const data = JSON.parse(res) 51 | if (data) { 52 | return parseBasicCursor(data) 53 | } 54 | return undefined 55 | }) 56 | .catch(err => { 57 | this.logger.warn("error fetching cursor:", err) 58 | throw err 59 | }) 60 | } 61 | 62 | decorate(basicCursor) { 63 | return basicCursor 64 | ? new Cursor(basicCursor, this.userStore, this.roomStore) 65 | : undefined 66 | } 67 | } 68 | 69 | const key = (userId, roomId) => 70 | `${encodeURIComponent(userId)}/${encodeURIComponent(roomId)}` 71 | -------------------------------------------------------------------------------- /src/cursor-subscription.js: -------------------------------------------------------------------------------- 1 | import { parseBasicCursor } from "./parsers" 2 | import { handleCursorSubReconnection } from "./reconnection-handlers" 3 | 4 | export class CursorSubscription { 5 | constructor(options) { 6 | this.onNewCursorHook = options.onNewCursorHook 7 | this.roomId = options.roomId 8 | this.cursorStore = options.cursorStore 9 | this.instance = options.instance 10 | this.logger = options.logger 11 | this.connectionTimeout = options.connectionTimeout 12 | 13 | this.connect = this.connect.bind(this) 14 | this.cancel = this.cancel.bind(this) 15 | this.onEvent = this.onEvent.bind(this) 16 | this.onInitialState = this.onInitialState.bind(this) 17 | this.onNewCursor = this.onNewCursor.bind(this) 18 | } 19 | 20 | connect() { 21 | return new Promise((resolve, reject) => { 22 | this.timeout = setTimeout(() => { 23 | reject(new Error("cursor subscription timed out")) 24 | }, this.connectionTimeout) 25 | this.onSubscriptionEstablished = initialState => { 26 | clearTimeout(this.timeout) 27 | resolve(initialState) 28 | } 29 | this.sub = this.instance.subscribeNonResuming({ 30 | path: `/cursors/0/rooms/${encodeURIComponent(this.roomId)}`, 31 | listeners: { 32 | onError: err => { 33 | clearTimeout(this.timeout) 34 | reject(err) 35 | }, 36 | onEvent: this.onEvent, 37 | }, 38 | }) 39 | }) 40 | } 41 | 42 | cancel() { 43 | clearTimeout(this.timeout) 44 | try { 45 | this.sub && this.sub.unsubscribe() 46 | } catch (err) { 47 | this.logger.debug("error when cancelling cursor subscription", err) 48 | } 49 | } 50 | 51 | onEvent({ body }) { 52 | switch (body.event_name) { 53 | case "initial_state": 54 | this.onInitialState(body.data) 55 | break 56 | case "new_cursor": 57 | this.onNewCursor(body.data) 58 | break 59 | } 60 | } 61 | 62 | onInitialState({ cursors }) { 63 | const basicCursors = cursors.map(c => parseBasicCursor(c)) 64 | 65 | if (!this.established) { 66 | this.established = true 67 | Promise.all(basicCursors.map(c => this.cursorStore.set(c))).then( 68 | this.onSubscriptionEstablished, 69 | ) 70 | } else { 71 | handleCursorSubReconnection({ 72 | basicCursors, 73 | cursorStore: this.cursorStore, 74 | onNewCursorHook: this.onNewCursorHook, 75 | }) 76 | } 77 | } 78 | 79 | onNewCursor(data) { 80 | return this.cursorStore 81 | .set(parseBasicCursor(data)) 82 | .then(cursor => this.onNewCursorHook(cursor)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/cursor.js: -------------------------------------------------------------------------------- 1 | export class Cursor { 2 | constructor(basicCursor, userStore, roomStore) { 3 | this.position = basicCursor.position 4 | this.updatedAt = basicCursor.updatedAt 5 | this.userId = basicCursor.userId 6 | this.roomId = basicCursor.roomId 7 | this.type = basicCursor.type 8 | this.userStore = userStore 9 | this.roomStore = roomStore 10 | } 11 | 12 | get user() { 13 | return this.userStore.getSync(this.userId) 14 | } 15 | 16 | get room() { 17 | return this.roomStore.getSync(this.roomId) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { TokenProvider } from "./token-provider" 2 | import { ChatManager } from "./chat-manager" 3 | 4 | export default { TokenProvider, ChatManager } 5 | -------------------------------------------------------------------------------- /src/membership-subscription.js: -------------------------------------------------------------------------------- 1 | import { handleMembershipSubReconnection } from "./reconnection-handlers" 2 | 3 | export class MembershipSubscription { 4 | constructor(options) { 5 | this.roomId = options.roomId 6 | this.instance = options.instance 7 | this.userStore = options.userStore 8 | this.roomStore = options.roomStore 9 | this.logger = options.logger 10 | this.connectionTimeout = options.connectionTimeout 11 | this.onUserJoinedRoomHook = options.onUserJoinedRoomHook 12 | this.onUserLeftRoomHook = options.onUserLeftRoomHook 13 | 14 | this.connect = this.connect.bind(this) 15 | this.cancel = this.cancel.bind(this) 16 | this.onEvent = this.onEvent.bind(this) 17 | this.onInitialState = this.onInitialState.bind(this) 18 | this.onUserJoined = this.onUserJoined.bind(this) 19 | this.onUserLeft = this.onUserLeft.bind(this) 20 | } 21 | 22 | connect() { 23 | return new Promise((resolve, reject) => { 24 | this.timeout = setTimeout(() => { 25 | reject(new Error("membership subscription timed out")) 26 | }, this.connectionTimeout) 27 | this.onSubscriptionEstablished = initialState => { 28 | clearTimeout(this.timeout) 29 | resolve(initialState) 30 | } 31 | this.sub = this.instance.subscribeNonResuming({ 32 | path: `/rooms/${encodeURIComponent(this.roomId)}/memberships`, 33 | listeners: { 34 | onError: err => { 35 | clearTimeout(this.timeout) 36 | reject(err) 37 | }, 38 | onEvent: this.onEvent, 39 | }, 40 | }) 41 | }) 42 | } 43 | 44 | cancel() { 45 | clearTimeout(this.timeout) 46 | try { 47 | this.sub && this.sub.unsubscribe() 48 | } catch (err) { 49 | this.logger.debug("error when cancelling membership subscription", err) 50 | } 51 | } 52 | 53 | onEvent({ body }) { 54 | switch (body.event_name) { 55 | case "initial_state": 56 | this.onInitialState(body.data) 57 | break 58 | case "user_joined": 59 | this.onUserJoined(body.data) 60 | break 61 | case "user_left": 62 | this.onUserLeft(body.data) 63 | break 64 | } 65 | } 66 | 67 | onInitialState({ user_ids: userIds }) { 68 | if (!this.established) { 69 | this.established = true 70 | this.roomStore.update(this.roomId, { userIds }).then(() => { 71 | this.onSubscriptionEstablished() 72 | }) 73 | } else { 74 | handleMembershipSubReconnection({ 75 | userIds, 76 | roomId: this.roomId, 77 | roomStore: this.roomStore, 78 | userStore: this.userStore, 79 | onUserJoinedRoomHook: this.onUserJoinedRoomHook, 80 | onUserLeftRoomHook: this.onUserLeftRoomHook, 81 | }) 82 | } 83 | } 84 | 85 | onUserJoined({ user_id: userId }) { 86 | this.roomStore 87 | .addUserToRoom(this.roomId, userId) 88 | .then(room => 89 | this.userStore 90 | .get(userId) 91 | .then(user => this.onUserJoinedRoomHook(room, user)), 92 | ) 93 | } 94 | 95 | onUserLeft({ user_id: userId }) { 96 | this.roomStore 97 | .removeUserFromRoom(this.roomId, userId) 98 | .then(room => 99 | this.userStore 100 | .get(userId) 101 | .then(user => this.onUserLeftRoomHook(room, user)), 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/message-subscription.js: -------------------------------------------------------------------------------- 1 | import { parseBasicMessage } from "./parsers" 2 | import { urlEncode } from "./utils" 3 | import { Message } from "./message" 4 | 5 | export class MessageSubscription { 6 | constructor(options) { 7 | this.roomId = options.roomId 8 | this.messageLimit = options.messageLimit 9 | this.userId = options.userId 10 | this.instance = options.instance 11 | this.userStore = options.userStore 12 | this.roomStore = options.roomStore 13 | this.typingIndicators = options.typingIndicators 14 | this.messageBuffer = [] // { message, ready } 15 | this.logger = options.logger 16 | this.connectionTimeout = options.connectionTimeout 17 | this.onMessageHook = options.onMessageHook 18 | this.onMessageDeletedHook = options.onMessageDeletedHook 19 | 20 | this.connect = this.connect.bind(this) 21 | this.cancel = this.cancel.bind(this) 22 | this.onEvent = this.onEvent.bind(this) 23 | this.onMessage = this.onMessage.bind(this) 24 | this.onMessageDeleted = this.onMessageDeleted.bind(this) 25 | this.flushBuffer = this.flushBuffer.bind(this) 26 | this.onIsTyping = this.onIsTyping.bind(this) 27 | } 28 | 29 | connect() { 30 | return new Promise((resolve, reject) => { 31 | this.timeout = setTimeout(() => { 32 | reject(new Error("message subscription timed out")) 33 | }, this.connectionTimeout) 34 | this.sub = this.instance.subscribeResuming({ 35 | path: `/rooms/${encodeURIComponent(this.roomId)}?${urlEncode({ 36 | message_limit: this.messageLimit, 37 | })}`, 38 | listeners: { 39 | onOpen: () => { 40 | clearTimeout(this.timeout) 41 | resolve() 42 | }, 43 | onError: err => { 44 | clearTimeout(this.timeout) 45 | reject(err) 46 | }, 47 | onEvent: this.onEvent, 48 | }, 49 | }) 50 | }) 51 | } 52 | 53 | cancel() { 54 | clearTimeout(this.timeout) 55 | try { 56 | this.sub && this.sub.unsubscribe() 57 | } catch (err) { 58 | this.logger.debug("error when cancelling message subscription", err) 59 | } 60 | } 61 | 62 | onEvent({ body }) { 63 | switch (body.event_name) { 64 | case "new_message": 65 | this.onMessage(body.data) 66 | break 67 | case "message_deleted": 68 | this.onMessageDeleted(body.data) 69 | break 70 | case "is_typing": 71 | this.onIsTyping(body.data) 72 | break 73 | } 74 | } 75 | 76 | onMessage(data) { 77 | const pending = { 78 | message: new Message( 79 | parseBasicMessage(data), 80 | this.userStore, 81 | this.roomStore, 82 | this.instance, 83 | ), 84 | ready: false, 85 | } 86 | this.messageBuffer.push(pending) 87 | this.userStore 88 | .fetchMissingUser(pending.message.senderId) 89 | .catch(err => { 90 | this.logger.error("error fetching missing user information:", err) 91 | }) 92 | .then(() => { 93 | pending.ready = true 94 | this.flushBuffer() 95 | }) 96 | } 97 | 98 | onMessageDeleted(data) { 99 | this.onMessageDeletedHook(data.message_id) 100 | } 101 | 102 | flushBuffer() { 103 | while (this.messageBuffer.length > 0 && this.messageBuffer[0].ready) { 104 | this.onMessageHook(this.messageBuffer.shift().message) 105 | } 106 | } 107 | 108 | onIsTyping({ user_id: userId }) { 109 | if (userId !== this.userId) { 110 | Promise.all([ 111 | this.roomStore.get(this.roomId), 112 | this.userStore.get(userId), 113 | ]).then(([room, user]) => this.typingIndicators.onIsTyping(room, user)) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/message.js: -------------------------------------------------------------------------------- 1 | import { Attachment } from "./attachment" 2 | 3 | export class Message { 4 | constructor(basicMessage, userStore, roomStore, instance) { 5 | this.id = basicMessage.id 6 | this.senderId = basicMessage.senderId 7 | this.roomId = basicMessage.roomId 8 | this.createdAt = basicMessage.createdAt 9 | this.updatedAt = basicMessage.updatedAt 10 | this.deletedAt = basicMessage.deletedAt 11 | 12 | if (basicMessage.parts) { 13 | // v3 message 14 | this.parts = basicMessage.parts.map( 15 | ({ partType, payload }) => 16 | partType === "attachment" 17 | ? { 18 | partType, 19 | payload: new Attachment(payload, this.roomId, instance), 20 | } 21 | : { partType, payload }, 22 | ) 23 | } else { 24 | // v2 message 25 | this.text = basicMessage.text 26 | if (basicMessage.attachment) { 27 | this.attachment = basicMessage.attachment 28 | } 29 | } 30 | 31 | this.userStore = userStore 32 | this.roomStore = roomStore 33 | } 34 | 35 | get sender() { 36 | return this.userStore.getSync(this.senderId) 37 | } 38 | 39 | get room() { 40 | return this.roomStore.getSync(this.roomId) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/notification-subscription.js: -------------------------------------------------------------------------------- 1 | export class NotificationSubscription { 2 | constructor(options) { 3 | this.onNotificationHook = options.onNotificationHook 4 | this.userId = options.userId 5 | this.instance = options.instance 6 | this.logger = options.logger 7 | this.connectionTimeout = options.connectionTimeout 8 | 9 | this.connect = this.connect.bind(this) 10 | this.cancel = this.cancel.bind(this) 11 | this.onEvent = this.onEvent.bind(this) 12 | this.onNotification = this.onNotification.bind(this) 13 | } 14 | 15 | connect() { 16 | return new Promise((resolve, reject) => { 17 | this.timeout = setTimeout(() => { 18 | reject(new Error("notification subscription timed out")) 19 | }, this.connectionTimeout) 20 | this.sub = this.instance.subscribeNonResuming({ 21 | path: `/users/${encodeURIComponent(this.userId)}`, 22 | listeners: { 23 | onOpen: () => { 24 | clearTimeout(this.timeout) 25 | resolve() 26 | }, 27 | onError: err => { 28 | clearTimeout(this.timeout) 29 | reject(err) 30 | }, 31 | onEvent: this.onEvent, 32 | }, 33 | }) 34 | }) 35 | } 36 | 37 | cancel() { 38 | clearTimeout(this.timeout) 39 | try { 40 | this.sub && this.sub.unsubscribe() 41 | } catch (err) { 42 | this.logger.debug("error when cancelling notification subscription", err) 43 | } 44 | } 45 | 46 | onEvent({ body }) { 47 | switch (body.event_name) { 48 | case "push_notification": 49 | this.onNotification(body.data) 50 | break 51 | } 52 | } 53 | 54 | onNotification(data) { 55 | this.onNotificationHook(data) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/notification.js: -------------------------------------------------------------------------------- 1 | export function showNotification({ 2 | notification, 3 | data, 4 | onClick, 5 | Notification, 6 | visibilityStateOverride, 7 | }) { 8 | if ((visibilityStateOverride || document.visibilityState) !== "hidden") { 9 | return 10 | } 11 | 12 | const n = new Notification(notification.title || "", { 13 | body: notification.body || "", 14 | icon: notification.icon, 15 | data: Object.assign(data, { 16 | pusher: { deep_link: notification.deep_link }, 17 | }), 18 | }) 19 | 20 | n.onclick = e => { 21 | e.preventDefault() 22 | window.focus() 23 | if (onClick) { 24 | onClick(e.target.data.chatkit) 25 | } 26 | e.target.close() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/parsers.js: -------------------------------------------------------------------------------- 1 | export const parseBasicRoom = data => ({ 2 | createdAt: data.created_at, 3 | createdByUserId: data.created_by_id, 4 | id: data.id, 5 | isPrivate: data.private, 6 | name: data.name, 7 | updatedAt: data.updated_at, 8 | customData: data.custom_data, 9 | deletedAt: data.deleted_at, 10 | unreadCount: data.unread_count, 11 | lastMessageAt: data.last_message_at, 12 | }) 13 | 14 | export const parseBasicUser = data => ({ 15 | avatarURL: data.avatar_url, 16 | createdAt: data.created_at, 17 | customData: data.custom_data, 18 | id: data.id, 19 | name: data.name, 20 | updatedAt: data.updated_at, 21 | }) 22 | 23 | export const parsePresence = data => ({ 24 | state: ["online", "offline"].includes(data.state) ? data.state : "unknown", 25 | }) 26 | 27 | export const parseBasicMessage = data => { 28 | const roomId = data.room_id 29 | 30 | const basicMessage = { 31 | roomId, 32 | id: data.id, 33 | senderId: data.user_id, 34 | createdAt: data.created_at, 35 | updatedAt: data.updated_at, 36 | deletedAt: data.deleted_at, 37 | } 38 | 39 | if (data.parts) { 40 | // v3 message 41 | basicMessage.parts = data.parts.map(p => parseMessagePart(p)) 42 | } else { 43 | // v2 message 44 | basicMessage.text = data.text 45 | if (data.attachment) { 46 | basicMessage.attachment = parseMessageAttachment(data.attachment) 47 | } 48 | } 49 | 50 | return basicMessage 51 | } 52 | 53 | export const parseBasicCursor = data => ({ 54 | position: data.position, 55 | updatedAt: data.updated_at, 56 | userId: data.user_id, 57 | roomId: data.room_id, 58 | type: data.cursor_type, 59 | }) 60 | 61 | const parseMessageAttachment = data => ({ 62 | link: data.resource_link, 63 | type: data.type, 64 | name: data.name, 65 | }) 66 | 67 | const parseMessagePart = data => { 68 | if (data.content != null) { 69 | return { 70 | partType: "inline", 71 | payload: { 72 | type: data.type, 73 | content: data.content, 74 | }, 75 | } 76 | } else if (data.url != null) { 77 | return { 78 | partType: "url", 79 | payload: { 80 | type: data.type, 81 | url: data.url, 82 | }, 83 | } 84 | } else if (data.attachment != null) { 85 | return { 86 | partType: "attachment", 87 | payload: { 88 | type: data.type, 89 | name: data.attachment.name, 90 | size: data.attachment.size, 91 | customData: data.attachment.custom_data, 92 | _id: data.attachment.id, 93 | _downloadURL: data.attachment.download_url, 94 | _expiration: new Date(data.attachment.expiration), 95 | }, 96 | } 97 | } else { 98 | throw new TypeError("failed to parse message part") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/presence-subscription.js: -------------------------------------------------------------------------------- 1 | export class PresenceSubscription { 2 | constructor(options) { 3 | this.userId = options.userId 4 | this.instance = options.instance 5 | this.logger = options.logger 6 | this.connectionTimeout = options.connectionTimeout 7 | } 8 | 9 | connect() { 10 | return new Promise((resolve, reject) => { 11 | this.timeout = setTimeout(() => { 12 | reject(new Error("presence subscription timed out")) 13 | }, this.connectionTimeout) 14 | this.sub = this.instance.subscribeNonResuming({ 15 | path: `/users/${encodeURIComponent(this.userId)}/register`, 16 | listeners: { 17 | onOpen: () => { 18 | clearTimeout(this.timeout) 19 | resolve() 20 | }, 21 | onError: err => { 22 | clearTimeout(this.timeout) 23 | reject(err) 24 | }, 25 | }, 26 | }) 27 | }) 28 | } 29 | 30 | cancel() { 31 | clearTimeout(this.timeout) 32 | try { 33 | this.sub && this.sub.unsubscribe() 34 | } catch (err) { 35 | this.logger.debug("error when cancelling presence subscription", err) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/reconnection-handlers.js: -------------------------------------------------------------------------------- 1 | export function handleUserSubReconnection({ 2 | basicUser, 3 | basicRooms, 4 | basicCursors, 5 | currentUser, 6 | roomStore, 7 | cursorStore, 8 | hooks, 9 | }) { 10 | currentUser.setPropertiesFromBasicUser(basicUser) 11 | 12 | for (const basicRoom of basicRooms) { 13 | const existingRoom = roomStore.getSync(basicRoom.id) 14 | 15 | if (!existingRoom) { 16 | const room = roomStore.setSync(basicRoom) 17 | if (hooks.global.onAddedToRoom) { 18 | hooks.global.onAddedToRoom(room) 19 | } 20 | } 21 | 22 | if (existingRoom && !existingRoom.eq(basicRoom)) { 23 | roomStore.updateSync(basicRoom.id, basicRoom) 24 | if (hooks.global.onRoomUpdated) { 25 | hooks.global.onRoomUpdated(existingRoom) 26 | } 27 | } 28 | } 29 | 30 | for (const roomId in roomStore.snapshot()) { 31 | if (!basicRooms.some(r => r.id === roomId)) { 32 | const room = roomStore.popSync(roomId) 33 | if (hooks.global.onRemovedFromRoom) { 34 | hooks.global.onRemovedFromRoom(room) 35 | } 36 | } 37 | } 38 | 39 | return handleCursorSubReconnection({ 40 | basicCursors, 41 | cursorStore, 42 | onNewCursorHook: hooks.global.onNewReadCursor, 43 | }) 44 | } 45 | 46 | export function handleMembershipSubReconnection({ 47 | userIds, 48 | roomId, 49 | roomStore, 50 | userStore, 51 | onUserJoinedRoomHook, 52 | onUserLeftRoomHook, 53 | }) { 54 | return userStore.fetchMissingUsers(userIds).then(() => { 55 | const room = roomStore.getSync(roomId) 56 | 57 | userIds 58 | .filter(userId => !room.userIds.includes(userId)) 59 | .forEach(userId => 60 | userStore.get(userId).then(user => onUserJoinedRoomHook(room, user)), 61 | ) 62 | 63 | room.userIds 64 | .filter(userId => !userIds.includes(userId)) 65 | .forEach(userId => 66 | userStore.get(userId).then(user => onUserLeftRoomHook(room, user)), 67 | ) 68 | 69 | return roomStore.update(roomId, { userIds }) 70 | }) 71 | } 72 | 73 | export function handleCursorSubReconnection({ 74 | basicCursors, 75 | cursorStore, 76 | onNewCursorHook, 77 | }) { 78 | return Promise.all( 79 | basicCursors.map(basicCursor => { 80 | const existingCursor = cursorStore.getSync( 81 | basicCursor.userId, 82 | basicCursor.roomId, 83 | ) 84 | 85 | if (!existingCursor || existingCursor.position !== basicCursor.position) { 86 | return cursorStore.set(basicCursor).then(cursor => { 87 | if (onNewCursorHook) { 88 | onNewCursorHook(cursor) 89 | } 90 | }) 91 | } 92 | }), 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /src/room-store.js: -------------------------------------------------------------------------------- 1 | import { append, uniq, pipe } from "ramda" 2 | 3 | import { parseBasicRoom } from "./parsers" 4 | import { Room } from "./room" 5 | 6 | export class RoomStore { 7 | constructor(options) { 8 | this.instance = options.instance 9 | this.userStore = options.userStore 10 | this.isSubscribedTo = options.isSubscribedTo 11 | this.logger = options.logger 12 | this.rooms = {} 13 | 14 | this.setSync = this.setSync.bind(this) 15 | this.set = this.set.bind(this) 16 | this.get = this.get.bind(this) 17 | this.popSync = this.popSync.bind(this) 18 | this.pop = this.pop.bind(this) 19 | this.addUserToRoom = this.addUserToRoom.bind(this) 20 | this.removeUserFromRoom = this.removeUserFromRoom.bind(this) 21 | this.updateSync = this.updateSync.bind(this) 22 | this.update = this.update.bind(this) 23 | this.fetchBasicRoom = this.fetchBasicRoom.bind(this) 24 | this.snapshot = this.snapshot.bind(this) 25 | this.getSync = this.getSync.bind(this) 26 | this.decorate = this.decorate.bind(this) 27 | } 28 | 29 | setSync(basicRoom) { 30 | if (!this.rooms[basicRoom.id]) { 31 | this.rooms[basicRoom.id] = this.decorate(basicRoom) 32 | } 33 | return this.rooms[basicRoom.id] 34 | } 35 | 36 | set(basicRoom) { 37 | return Promise.resolve(this.setSync(basicRoom)) 38 | } 39 | 40 | get(roomId) { 41 | return Promise.resolve(this.rooms[roomId]).then( 42 | room => 43 | room || 44 | this.fetchBasicRoom(roomId).then(basicRoom => 45 | this.set(roomId, basicRoom), 46 | ), 47 | ) 48 | } 49 | 50 | popSync(roomId) { 51 | const room = this.rooms[roomId] 52 | delete this.rooms[roomId] 53 | return room 54 | } 55 | 56 | pop(roomId) { 57 | return Promise.resolve(this.popSync(roomId)) 58 | } 59 | 60 | addUserToRoom(roomId, userId) { 61 | return Promise.all([ 62 | this.get(roomId).then(room => { 63 | room.userIds = uniq(append(userId, room.userIds)) 64 | return room 65 | }), 66 | this.userStore.fetchMissingUser(userId), 67 | ]).then(([room]) => room) 68 | } 69 | 70 | removeUserFromRoom(roomId, userId) { 71 | return this.get(roomId).then(room => { 72 | room.userIds = room.userIds.filter(id => id !== userId) 73 | return room 74 | }) 75 | } 76 | 77 | updateSync(roomId, updates) { 78 | const room = this.getSync(roomId) 79 | for (const k in updates) { 80 | room[k] = updates[k] 81 | } 82 | return room 83 | } 84 | 85 | update(roomId, updates) { 86 | return Promise.all([ 87 | this.get(roomId).then(() => this.updateSync(roomId, updates)), 88 | this.userStore.fetchMissingUsers(updates.userIds || []), 89 | ]).then(([room]) => room) 90 | } 91 | 92 | fetchBasicRoom(roomId) { 93 | return this.instance 94 | .request({ 95 | method: "GET", 96 | path: `/rooms/${encodeURIComponent(roomId)}`, 97 | }) 98 | .then( 99 | pipe( 100 | JSON.parse, 101 | parseBasicRoom, 102 | ), 103 | ) 104 | .catch(err => { 105 | this.logger.warn(`error fetching details for room ${roomId}:`, err) 106 | }) 107 | } 108 | 109 | snapshot() { 110 | return this.rooms 111 | } 112 | 113 | getSync(roomId) { 114 | return this.rooms[roomId] 115 | } 116 | 117 | decorate(basicRoom) { 118 | return basicRoom 119 | ? new Room({ 120 | basicRoom, 121 | userStore: this.userStore, 122 | isSubscribedTo: this.isSubscribedTo, 123 | logger: this.logger, 124 | }) 125 | : undefined 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/room-subscription.js: -------------------------------------------------------------------------------- 1 | import { CursorSubscription } from "./cursor-subscription" 2 | import { MessageSubscription } from "./message-subscription" 3 | import { MembershipSubscription } from "./membership-subscription" 4 | 5 | export class RoomSubscription { 6 | constructor(options) { 7 | this.buffer = [] 8 | 9 | this.messageSub = new MessageSubscription({ 10 | roomId: options.roomId, 11 | messageLimit: options.messageLimit, 12 | userId: options.userId, 13 | instance: options.serverInstance, 14 | userStore: options.userStore, 15 | roomStore: options.roomStore, 16 | typingIndicators: options.typingIndicators, 17 | logger: options.logger, 18 | connectionTimeout: options.connectionTimeout, 19 | onMessageHook: this.bufferWhileConnecting(message => { 20 | if ( 21 | options.hooks.rooms[options.roomId] && 22 | options.hooks.rooms[options.roomId].onMessage 23 | ) { 24 | options.hooks.rooms[options.roomId].onMessage(message) 25 | } 26 | }), 27 | onMessageDeletedHook: this.bufferWhileConnecting(messageId => { 28 | if ( 29 | options.hooks.rooms[options.roomId] && 30 | options.hooks.rooms[options.roomId].onMessageDeleted 31 | ) { 32 | options.hooks.rooms[options.roomId].onMessageDeleted(messageId) 33 | } 34 | }), 35 | }) 36 | 37 | this.cursorSub = new CursorSubscription({ 38 | roomId: options.roomId, 39 | cursorStore: options.cursorStore, 40 | instance: options.cursorsInstance, 41 | logger: options.logger, 42 | connectionTimeout: options.connectionTimeout, 43 | onNewCursorHook: this.bufferWhileConnecting(cursor => { 44 | if ( 45 | options.hooks.rooms[options.roomId] && 46 | options.hooks.rooms[options.roomId].onNewReadCursor && 47 | cursor.type === 0 && 48 | cursor.userId !== options.userId 49 | ) { 50 | options.hooks.rooms[options.roomId].onNewReadCursor(cursor) 51 | } 52 | }), 53 | }) 54 | 55 | this.membershipSub = new MembershipSubscription({ 56 | roomId: options.roomId, 57 | instance: options.serverInstance, 58 | userStore: options.userStore, 59 | roomStore: options.roomStore, 60 | logger: options.logger, 61 | connectionTimeout: options.connectionTimeout, 62 | onUserJoinedRoomHook: this.bufferWhileConnecting((room, user) => { 63 | if (options.hooks.global.onUserJoinedRoom) { 64 | options.hooks.global.onUserJoinedRoom(room, user) 65 | } 66 | if ( 67 | options.hooks.rooms[room.id] && 68 | options.hooks.rooms[room.id].onUserJoined 69 | ) { 70 | options.hooks.rooms[room.id].onUserJoined(user) 71 | } 72 | }), 73 | onUserLeftRoomHook: this.bufferWhileConnecting((room, user) => { 74 | if (options.hooks.global.onUserLeftRoom) { 75 | options.hooks.global.onUserLeftRoom(room, user) 76 | } 77 | if ( 78 | options.hooks.rooms[room.id] && 79 | options.hooks.rooms[room.id].onUserLeft 80 | ) { 81 | options.hooks.rooms[room.id].onUserLeft(user) 82 | } 83 | }), 84 | }) 85 | } 86 | 87 | connect() { 88 | if (this.cancelled) { 89 | return Promise.reject( 90 | new Error("attempt to connect a cancelled room subscription"), 91 | ) 92 | } 93 | return Promise.all([ 94 | this.messageSub.connect(), 95 | this.cursorSub.connect(), 96 | this.membershipSub.connect(), 97 | ]).then(() => this.flushBuffer()) 98 | } 99 | 100 | cancel() { 101 | this.cancelled = true 102 | this.messageSub.cancel() 103 | this.cursorSub.cancel() 104 | this.membershipSub.cancel() 105 | } 106 | 107 | bufferWhileConnecting(f) { 108 | return (...args) => { 109 | if (this.connected) { 110 | f(...args) 111 | } else { 112 | this.buffer.push(f.bind(this, ...args)) 113 | } 114 | } 115 | } 116 | 117 | flushBuffer() { 118 | this.connected = true 119 | this.buffer.forEach(f => f()) 120 | delete this.buffer 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/room.js: -------------------------------------------------------------------------------- 1 | import { contains, filter, values } from "ramda" 2 | 3 | export class Room { 4 | constructor({ basicRoom, userStore, isSubscribedTo, logger }) { 5 | this.createdAt = basicRoom.createdAt 6 | this.createdByUserId = basicRoom.createdByUserId 7 | this.deletedAt = basicRoom.deletedAt 8 | this.id = basicRoom.id 9 | this.isPrivate = basicRoom.isPrivate 10 | this.name = basicRoom.name 11 | this.updatedAt = basicRoom.updatedAt 12 | this.customData = basicRoom.customData 13 | this.unreadCount = basicRoom.unreadCount 14 | this.lastMessageAt = basicRoom.lastMessageAt 15 | this.userIds = [] 16 | this.userStore = userStore 17 | this.isSubscribedTo = isSubscribedTo 18 | this.logger = logger 19 | 20 | this.eq = this.eq.bind(this) 21 | } 22 | 23 | get users() { 24 | if (!this.isSubscribedTo(this.id)) { 25 | const err = new Error( 26 | `Must be subscribed to room ${this.id} to access users property`, 27 | ) 28 | this.logger.error(err) 29 | throw err 30 | } 31 | return filter( 32 | user => contains(user.id, this.userIds), 33 | values(this.userStore.snapshot()), 34 | ) 35 | } 36 | 37 | eq(other) { 38 | return ( 39 | this.createdAt === other.createdAt && 40 | this.createdByUserId === other.createdByUserId && 41 | this.deletedAt === other.deletedAt && 42 | this.id === other.id && 43 | this.isPrivate === other.isPrivate && 44 | this.name === other.name && 45 | this.updatedAt === other.updatedAt && 46 | JSON.stringify(this.customData) === JSON.stringify(other.customData) 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | importScripts("https://js.pusher.com/beams/service-worker.js") 3 | -------------------------------------------------------------------------------- /src/token-provider.js: -------------------------------------------------------------------------------- 1 | import { sendRawRequest } from "@pusher/platform" 2 | import { 3 | appendQueryParams, 4 | typeCheckStringOrFunction, 5 | typeCheckObjectOrFunction, 6 | unixSeconds, 7 | urlEncode, 8 | } from "./utils" 9 | 10 | export class TokenProvider { 11 | constructor({ url, queryParams, headers, withCredentials } = {}) { 12 | typeCheckStringOrFunction("url", url) 13 | queryParams && typeCheckObjectOrFunction("queryParams", queryParams) 14 | headers && typeCheckObjectOrFunction("headers", headers) 15 | this.url = url 16 | this.queryParams = queryParams 17 | this.headers = headers 18 | this.withCredentials = withCredentials 19 | 20 | this.fetchToken = this.fetchToken.bind(this) 21 | this.fetchFreshToken = this.fetchFreshToken.bind(this) 22 | this.cacheIsStale = this.cacheIsStale.bind(this) 23 | this.cache = this.cache.bind(this) 24 | this.clearCache = this.clearCache.bind(this) 25 | this.setUserId = this.setUserId.bind(this) 26 | } 27 | 28 | getValueOrFunction(value) { 29 | return new Promise(resolve => { 30 | if (typeof value === "function") { 31 | resolve(value()) 32 | } else { 33 | resolve(value) 34 | } 35 | }) 36 | } 37 | 38 | fetchToken() { 39 | return !this.cacheIsStale() 40 | ? Promise.resolve(this.cachedToken) 41 | : (this.req || this.fetchFreshToken()).then(({ token, expiresIn }) => { 42 | this.cache(token, expiresIn) 43 | return token 44 | }) 45 | } 46 | 47 | fetchFreshToken() { 48 | this.req = Promise.all([ 49 | this.getValueOrFunction(this.url), 50 | this.getValueOrFunction(this.queryParams), 51 | this.getValueOrFunction(this.headers), 52 | ]) 53 | .then(([url, queryParams, headers]) => { 54 | return sendRawRequest({ 55 | method: "POST", 56 | url: appendQueryParams({ user_id: this.userId, ...queryParams }, url), 57 | body: urlEncode({ grant_type: "client_credentials" }), 58 | headers: { 59 | "content-type": "application/x-www-form-urlencoded", 60 | ...headers, 61 | }, 62 | withCredentials: this.withCredentials, 63 | }) 64 | }) 65 | .then(res => { 66 | const { access_token: token, expires_in: expiresIn } = JSON.parse(res) 67 | delete this.req 68 | return { token, expiresIn } 69 | }) 70 | .catch(err => { 71 | delete this.req 72 | throw err 73 | }) 74 | return this.req 75 | } 76 | 77 | cacheIsStale() { 78 | return !this.cachedToken || unixSeconds() > this.cacheExpiresAt 79 | } 80 | 81 | cache(token, expiresIn) { 82 | this.cachedToken = token 83 | this.cacheExpiresAt = unixSeconds() + expiresIn 84 | } 85 | 86 | clearCache() { 87 | this.cachedToken = undefined 88 | this.cacheExpiresAt = undefined 89 | } 90 | 91 | // To allow ChatManager to feed the userId to the TokenProvider. Not set 92 | // directly so as not to mess with a custom TokenProvider implementation. 93 | setUserId(userId) { 94 | this.clearCache() 95 | this.userId = userId 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/typing-indicators.js: -------------------------------------------------------------------------------- 1 | import { TYPING_INDICATOR_TTL, TYPING_INDICATOR_LEEWAY } from "./constants" 2 | 3 | export class TypingIndicators { 4 | constructor({ hooks, instance, logger }) { 5 | this.hooks = hooks 6 | this.instance = instance 7 | this.logger = logger 8 | this.lastSentRequests = {} 9 | this.timers = {} 10 | 11 | this.sendThrottledRequest = this.sendThrottledRequest.bind(this) 12 | this.onIsTyping = this.onIsTyping.bind(this) 13 | this.onStarted = this.onStarted.bind(this) 14 | this.onStopped = this.onStopped.bind(this) 15 | } 16 | 17 | sendThrottledRequest(roomId) { 18 | const now = Date.now() 19 | const sent = this.lastSentRequests[roomId] 20 | if (sent && now - sent < TYPING_INDICATOR_TTL - TYPING_INDICATOR_LEEWAY) { 21 | return Promise.resolve() 22 | } 23 | this.lastSentRequests[roomId] = now 24 | return this.instance 25 | .request({ 26 | method: "POST", 27 | path: `/rooms/${encodeURIComponent(roomId)}/typing_indicators`, 28 | }) 29 | .catch(err => { 30 | delete this.typingRequestSent[roomId] 31 | this.logger.warn( 32 | `Error sending typing indicator in room ${roomId}`, 33 | err, 34 | ) 35 | throw err 36 | }) 37 | } 38 | 39 | onIsTyping(room, user) { 40 | if (!this.timers[room.id]) { 41 | this.timers[room.id] = {} 42 | } 43 | if (this.timers[room.id][user.id]) { 44 | clearTimeout(this.timers[room.id][user.id]) 45 | } else { 46 | this.onStarted(room, user) 47 | } 48 | this.timers[room.id][user.id] = setTimeout(() => { 49 | this.onStopped(room, user) 50 | delete this.timers[room.id][user.id] 51 | }, TYPING_INDICATOR_TTL) 52 | } 53 | 54 | onStarted(room, user) { 55 | if (this.hooks.global.onUserStartedTyping) { 56 | this.hooks.global.onUserStartedTyping(room, user) 57 | } 58 | if ( 59 | this.hooks.rooms[room.id] && 60 | this.hooks.rooms[room.id].onUserStartedTyping 61 | ) { 62 | this.hooks.rooms[room.id].onUserStartedTyping(user) 63 | } 64 | } 65 | 66 | onStopped(room, user) { 67 | if (this.hooks.global.onUserStoppedTyping) { 68 | this.hooks.global.onUserStoppedTyping(room, user) 69 | } 70 | if ( 71 | this.hooks.rooms[room.id] && 72 | this.hooks.rooms[room.id].onUserStoppedTyping 73 | ) { 74 | this.hooks.rooms[room.id].onUserStoppedTyping(user) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/user-presence-subscription.js: -------------------------------------------------------------------------------- 1 | import { contains, compose, forEach, filter, toPairs } from "ramda" 2 | 3 | import { parsePresence } from "./parsers" 4 | 5 | export class UserPresenceSubscription { 6 | constructor(options) { 7 | this.userId = options.userId 8 | this.hooks = options.hooks 9 | this.instance = options.instance 10 | this.userStore = options.userStore 11 | this.roomStore = options.roomStore 12 | this.presenceStore = options.presenceStore 13 | this.logger = options.logger 14 | this.connectionTimeout = options.connectionTimeout 15 | 16 | this.connect = this.connect.bind(this) 17 | this.cancel = this.cancel.bind(this) 18 | this.onEvent = this.onEvent.bind(this) 19 | this.onPresenceState = this.onPresenceState.bind(this) 20 | } 21 | 22 | connect() { 23 | return new Promise((resolve, reject) => { 24 | this.timeout = setTimeout(() => { 25 | reject(new Error("user presence subscription timed out")) 26 | }, this.connectionTimeout) 27 | this.onSubscriptionEstablished = () => { 28 | clearTimeout(this.timeout) 29 | resolve() 30 | } 31 | this.sub = this.instance.subscribeNonResuming({ 32 | path: `/users/${encodeURIComponent(this.userId)}`, 33 | listeners: { 34 | onError: err => { 35 | clearTimeout(this.timeout) 36 | reject(err) 37 | }, 38 | onEvent: this.onEvent, 39 | }, 40 | }) 41 | }) 42 | } 43 | 44 | cancel() { 45 | clearTimeout(this.timeout) 46 | try { 47 | this.sub && this.sub.unsubscribe() 48 | } catch (err) { 49 | this.logger.debug("error when cancelling user presence subscription", err) 50 | } 51 | } 52 | 53 | onEvent({ body }) { 54 | switch (body.event_name) { 55 | case "presence_state": 56 | this.onPresenceState(body.data) 57 | break 58 | } 59 | } 60 | 61 | onPresenceState(data) { 62 | this.onSubscriptionEstablished() 63 | const previous = this.presenceStore[this.userId] || "unknown" 64 | const current = parsePresence(data).state 65 | if (current === previous) { 66 | return 67 | } 68 | this.presenceStore[this.userId] = current 69 | this.userStore.get(this.userId).then(user => { 70 | if (this.hooks.global.onPresenceChanged) { 71 | this.hooks.global.onPresenceChanged({ current, previous }, user) 72 | } 73 | compose( 74 | forEach(([roomId, hooks]) => 75 | this.roomStore.get(roomId).then(room => { 76 | if (contains(user.id, room.userIds)) { 77 | hooks.onPresenceChanged({ current, previous }, user) 78 | } 79 | }), 80 | ), 81 | filter(pair => pair[1].onPresenceChanged !== undefined), 82 | toPairs, 83 | )(this.hooks.rooms) 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/user-store.js: -------------------------------------------------------------------------------- 1 | import { appendQueryParamsAsArray } from "./utils" 2 | import { parseBasicUser } from "./parsers" 3 | import { User } from "./user" 4 | import { batch } from "./batch" 5 | import { MISSING_USER_WAIT, MAX_FETCH_USER_BATCH } from "./constants" 6 | 7 | export class UserStore { 8 | constructor({ instance, presenceStore, logger }) { 9 | this.instance = instance 10 | this.presenceStore = presenceStore 11 | this.logger = logger 12 | this.onSetHooks = [] // hooks called when a new user is added to the store 13 | this.users = {} 14 | 15 | this.set = this.set.bind(this) 16 | this.get = this.get.bind(this) 17 | this.fetchMissingUser = batch( 18 | this._fetchMissingUserBatch.bind(this), 19 | MISSING_USER_WAIT, 20 | MAX_FETCH_USER_BATCH, 21 | ) 22 | this.fetchMissingUsers = this.fetchMissingUsers.bind(this) 23 | this.fetchMissingUserReq = this.fetchMissingUserReq.bind(this) 24 | this.snapshot = this.snapshot.bind(this) 25 | this.getSync = this.getSync.bind(this) 26 | this.decorate = this.decorate.bind(this) 27 | } 28 | 29 | set(basicUser) { 30 | this.users[basicUser.id] = this.decorate(basicUser) 31 | this.onSetHooks.forEach(hook => hook(basicUser.id)) 32 | return Promise.resolve(this.users[basicUser.id]) 33 | } 34 | 35 | get(userId) { 36 | return this.fetchMissingUser(userId).then(() => this.users[userId]) 37 | } 38 | 39 | fetchMissingUsers(userIds) { 40 | return Promise.all(userIds.map(userId => this.fetchMissingUser(userId))) 41 | } 42 | 43 | _fetchMissingUserBatch(args) { 44 | const userIds = args.filter(userId => !this.users[userId]) 45 | if (userIds.length > 0) { 46 | return this.fetchMissingUserReq(userIds) 47 | } else { 48 | return Promise.resolve() 49 | } 50 | } 51 | 52 | fetchMissingUserReq(userIds) { 53 | return this.instance 54 | .request({ 55 | method: "GET", 56 | path: appendQueryParamsAsArray("id", userIds, "/users_by_ids"), 57 | }) 58 | .then(res => { 59 | const basicUsers = JSON.parse(res).map(u => parseBasicUser(u)) 60 | basicUsers.forEach(user => { 61 | this.set(user) 62 | }) 63 | }) 64 | .catch(err => { 65 | this.logger.warn("error fetching missing users:", err) 66 | throw err 67 | }) 68 | } 69 | 70 | snapshot() { 71 | return this.users 72 | } 73 | 74 | getSync(userId) { 75 | return this.users[userId] 76 | } 77 | 78 | decorate(basicUser) { 79 | return basicUser ? new User(basicUser, this.presenceStore) : undefined 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/user-subscription.js: -------------------------------------------------------------------------------- 1 | import { parseBasicRoom, parseBasicUser, parseBasicCursor } from "./parsers" 2 | import { handleUserSubReconnection } from "./reconnection-handlers" 3 | 4 | export class UserSubscription { 5 | constructor(options) { 6 | this.userId = options.userId 7 | this.hooks = options.hooks 8 | this.instance = options.instance 9 | this.roomStore = options.roomStore 10 | this.cursorStore = options.cursorStore 11 | this.roomSubscriptions = options.roomSubscriptions 12 | this.logger = options.logger 13 | this.connectionTimeout = options.connectionTimeout 14 | this.currentUser = options.currentUser 15 | 16 | this.connect = this.connect.bind(this) 17 | this.cancel = this.cancel.bind(this) 18 | this.onEvent = this.onEvent.bind(this) 19 | this.onInitialState = this.onInitialState.bind(this) 20 | this.onAddedToRoom = this.onAddedToRoom.bind(this) 21 | this.onRemovedFromRoom = this.onRemovedFromRoom.bind(this) 22 | this.onRoomUpdated = this.onRoomUpdated.bind(this) 23 | this.onRoomDeleted = this.onRoomDeleted.bind(this) 24 | } 25 | 26 | connect() { 27 | return new Promise((resolve, reject) => { 28 | this.timeout = setTimeout(() => { 29 | reject(new Error("user subscription timed out")) 30 | }, this.connectionTimeout) 31 | this.onSubscriptionEstablished = initialState => { 32 | clearTimeout(this.timeout) 33 | resolve(initialState) 34 | } 35 | this.sub = this.instance.subscribeNonResuming({ 36 | path: "/users", 37 | listeners: { 38 | onError: err => { 39 | clearTimeout(this.timeout) 40 | reject(err) 41 | }, 42 | onEvent: this.onEvent, 43 | }, 44 | }) 45 | }) 46 | } 47 | 48 | cancel() { 49 | clearTimeout(this.timeout) 50 | try { 51 | this.sub && this.sub.unsubscribe() 52 | } catch (err) { 53 | this.logger.debug("error when cancelling user subscription", err) 54 | } 55 | } 56 | 57 | onEvent({ body }) { 58 | switch (body.event_name) { 59 | case "initial_state": 60 | this.onInitialState(body.data) 61 | break 62 | case "added_to_room": 63 | this.onAddedToRoom(body.data) 64 | break 65 | case "removed_from_room": 66 | this.onRemovedFromRoom(body.data) 67 | break 68 | case "room_updated": 69 | this.onRoomUpdated(body.data) 70 | break 71 | case "room_deleted": 72 | this.onRoomDeleted(body.data) 73 | break 74 | case "new_cursor": 75 | this.onNewCursor(body.data) 76 | break 77 | } 78 | } 79 | 80 | onInitialState({ 81 | current_user: userData, 82 | rooms: roomsData, 83 | cursors: cursorsData, 84 | }) { 85 | const basicUser = parseBasicUser(userData) 86 | const basicRooms = roomsData.map(d => parseBasicRoom(d)) 87 | const basicCursors = cursorsData.map(d => parseBasicCursor(d)) 88 | if (!this.established) { 89 | this.established = true 90 | this.onSubscriptionEstablished({ basicUser, basicRooms, basicCursors }) 91 | } else { 92 | handleUserSubReconnection({ 93 | basicUser, 94 | basicRooms, 95 | basicCursors, 96 | currentUser: this.currentUser, 97 | roomStore: this.roomStore, 98 | cursorStore: this.cursorStore, 99 | hooks: this.hooks, 100 | }) 101 | } 102 | } 103 | 104 | onAddedToRoom({ room: roomData }) { 105 | this.roomStore.set(parseBasicRoom(roomData)).then(room => { 106 | if (this.hooks.global.onAddedToRoom) { 107 | this.hooks.global.onAddedToRoom(room) 108 | } 109 | }) 110 | } 111 | 112 | onRemovedFromRoom({ room_id: roomId }) { 113 | this.roomStore.pop(roomId).then(room => { 114 | if (room && this.hooks.global.onRemovedFromRoom) { 115 | this.hooks.global.onRemovedFromRoom(room) 116 | } 117 | }) 118 | } 119 | 120 | onRoomUpdated({ room: roomData }) { 121 | const updates = parseBasicRoom(roomData) 122 | this.roomStore.update(updates.id, updates).then(room => { 123 | if (this.hooks.global.onRoomUpdated) { 124 | this.hooks.global.onRoomUpdated(room) 125 | } 126 | }) 127 | } 128 | 129 | onRoomDeleted({ room_id: roomId }) { 130 | this.roomStore.pop(roomId).then(room => { 131 | if (room && this.hooks.global.onRoomDeleted) { 132 | this.hooks.global.onRoomDeleted(room) 133 | } 134 | }) 135 | } 136 | 137 | onNewCursor(data) { 138 | return this.cursorStore.set(parseBasicCursor(data)).then(cursor => { 139 | if (this.hooks.global.onNewReadCursor && cursor.type === 0) { 140 | this.hooks.global.onNewReadCursor(cursor) 141 | } 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/user.js: -------------------------------------------------------------------------------- 1 | export class User { 2 | constructor(basicUser, presenceStore) { 3 | this.avatarURL = basicUser.avatarURL 4 | this.createdAt = basicUser.createdAt 5 | this.customData = basicUser.customData 6 | this.id = basicUser.id 7 | this.name = basicUser.name 8 | this.updatedAt = basicUser.updatedAt 9 | this.presenceStore = presenceStore 10 | } 11 | 12 | get presence() { 13 | return { 14 | state: this.presenceStore[this.id] || "unknown", 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | contains, 3 | filter, 4 | forEachObjIndexed, 5 | join, 6 | map, 7 | pipe, 8 | toPairs, 9 | } from "ramda" 10 | 11 | export const urlEncode = pipe( 12 | filter(x => x !== undefined), 13 | toPairs, 14 | map(([k, v]) => `${k}=${encodeURIComponent(v)}`), 15 | join("&"), 16 | ) 17 | 18 | export const appendQueryParams = (queryParams, url) => { 19 | const separator = contains("?", url) ? "&" : "?" 20 | return url + separator + urlEncode(queryParams) 21 | } 22 | 23 | export const appendQueryParamsAsArray = (key, values, url) => { 24 | const separator = contains("?", url) ? "" : "?" 25 | const encodedPairs = map( 26 | v => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`, 27 | values, 28 | ) 29 | return url + separator + join("&", encodedPairs) 30 | } 31 | 32 | export const typeCheck = (name, expectedType, value) => { 33 | const type = typeof value 34 | if (type !== expectedType) { 35 | throw new TypeError( 36 | `expected ${name} to be of type ${expectedType} but was of type ${type}`, 37 | ) 38 | } 39 | } 40 | 41 | // checks that value is a string or function 42 | export const typeCheckStringOrFunction = (name, value) => { 43 | const type = typeof value 44 | if (type !== "string" && type !== "function") { 45 | throw new TypeError( 46 | `expected ${name} to be a string or function but was of type ${type}`, 47 | ) 48 | } 49 | } 50 | 51 | // checks that value is an object or function 52 | export const typeCheckObjectOrFunction = (name, value) => { 53 | const type = typeof value 54 | if (type !== "object" && type !== "function") { 55 | throw new TypeError( 56 | `expected ${name} to be an object or function but was of type ${type}`, 57 | ) 58 | } 59 | } 60 | 61 | // checks that all of an arrays elements are of the given type 62 | export const typeCheckArr = (name, expectedType, arr) => { 63 | if (!Array.isArray(arr)) { 64 | throw new TypeError(`expected ${name} to be an array`) 65 | } 66 | arr.forEach((value, i) => typeCheck(`${name}[${i}]`, expectedType, value)) 67 | } 68 | 69 | // checks that all of an objects values are of the given type 70 | export const typeCheckObj = (name, expectedType, obj) => { 71 | typeCheck(name, "object", obj) 72 | forEachObjIndexed( 73 | (value, key) => typeCheck(`${name}.${key}`, expectedType, value), 74 | obj, 75 | ) 76 | } 77 | 78 | export const checkOneOf = (name, values, value) => { 79 | if (!contains(value, values)) { 80 | throw new TypeError( 81 | `expected ${name} to be one of ${values} but was ${value}`, 82 | ) 83 | } 84 | } 85 | 86 | export const unixSeconds = () => Math.floor(Date.now() / 1000) 87 | -------------------------------------------------------------------------------- /tests/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | 3 | ## Configure 4 | 5 | Copy `config/example.js` to `config/production.js`, `config/staging.js`, and 6 | `config/development.js`. Fill in instance credentials for each. 7 | 8 | By default the tests will run against production. To run against staging or development change 9 | 10 | ```js 11 | } from "./config/production" 12 | ``` 13 | 14 | in `main.js` to 15 | 16 | ```js 17 | } from "./config/staging" 18 | ``` 19 | 20 | or 21 | 22 | ```js 23 | } from "./config/development" 24 | ``` 25 | 26 | ## Run 27 | 28 | $ yarn lint:build:test 29 | 30 | ## WARNING 31 | 32 | The tests completely wipe the instance on teardown -- so obviously don't do 33 | this with an instance you're using for anything else! 34 | -------------------------------------------------------------------------------- /tests/integration/config/example.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | export const INSTANCE_KEY = "your:key" 4 | export const INSTANCE_LOCATOR = "your:instance:locator" 5 | export const TOKEN_PROVIDER_URL = "https://token.provider.url" 6 | -------------------------------------------------------------------------------- /tests/jest/chat-manager.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | 3 | describe("ChatManager", () => { 4 | test("can connect", async () => { 5 | const user = await helpers.makeUser("default") 6 | expect( 7 | await page.evaluate( 8 | user => 9 | makeChatManager(user) 10 | .connect() 11 | .then(res => ({ 12 | id: res.id, 13 | name: res.name, 14 | })), 15 | user, 16 | ), 17 | ).toMatchObject(user) 18 | }) 19 | 20 | beforeAll(async () => { 21 | await helpers.defaultBeforeAll() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/jest/cursors.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | 3 | // Each test in this group will have 4 | // * 2 clients for alice (alice and aliceMobile) 5 | // * 1 room with Alice as the only member (room) 6 | 7 | // Cursors point to the last read message (by the user) in a room. 8 | // Cursors are tightly coupled with unread counts. The unread count 9 | // for a room is the difference the number of messages sent in the 10 | // room after the current read cursor. 11 | 12 | describe("A read cursor", () => { 13 | test("invokes hook when set on other device", async () => { 14 | // cursor positions should be message ids, but any int is valid 15 | const expectedPos = 42 16 | 17 | await page.evaluate( 18 | async (roomId, expectedPos) => { 19 | window.actual = undefined 20 | 21 | // set up Alice's hook to set the global value 22 | await aliceChatManager.connect({ 23 | onNewReadCursor: cursor => { 24 | window.actual = cursor 25 | }, 26 | }) 27 | 28 | const aliceMobile = await aliceMobileChatManager.connect() 29 | await aliceMobile.setReadCursor({ 30 | roomId: roomId, 31 | position: expectedPos, 32 | }) 33 | }, 34 | room.id, 35 | expectedPos, 36 | ) 37 | 38 | // wait for hook to be invoked 39 | await helpers.withHook(actual => { 40 | expect(actual.position).toBe(expectedPos) 41 | }) 42 | }) 43 | 44 | test("sets unread count", async () => { 45 | res = await helpers.makeSimpleMessage({ 46 | roomId: room.id, 47 | userId: alice.id, 48 | text: "hi", 49 | }) 50 | 51 | const initial = await page.evaluate(async () => { 52 | const alice = await aliceChatManager.connect() 53 | return { 54 | unread: alice.rooms[0].unreadCount, 55 | messageAt: alice.rooms[0].lastMessageAt, 56 | } 57 | }) 58 | 59 | expect(initial.unread).toBe(1) 60 | expect(initial.messageAt).toBeDefined() 61 | 62 | // setting the cursor to the latest message sets the unread 63 | // count to 0 64 | await page.evaluate( 65 | async (roomId, messageId) => { 66 | window.actual = undefined 67 | const alice = await aliceChatManager.connect({ 68 | onRoomUpdated: room => 69 | (window.actual = { 70 | unread: alice.rooms[0].unreadCount, 71 | messageAt: alice.rooms[0].lastMessageAt, 72 | }), 73 | }) 74 | alice.setReadCursor({ 75 | roomId: roomId, 76 | position: messageId, 77 | }) 78 | }, 79 | room.id, 80 | res.id, 81 | ) 82 | 83 | // wait for hook to be invoked 84 | await helpers.withHook(actual => { 85 | expect(actual.unread).toBe(0) 86 | expect(actual.messageAt).toBeDefined() 87 | }) 88 | }) 89 | 90 | ///////////////////////// 91 | // Test setup 92 | const roleName = "cursorsRole" 93 | 94 | beforeAll(async () => { 95 | await helpers.defaultBeforeAll(roleName) 96 | }) 97 | 98 | afterAll(async () => { 99 | await helpers.defaultAfterAll(roleName) 100 | }) 101 | 102 | beforeEach(async () => { 103 | global.alice = await helpers.makeUser(roleName) 104 | global.room = await helpers.makeRoom({ members: [alice] }) 105 | 106 | await page.evaluate(async alice => { 107 | window.actual = undefined 108 | 109 | window.aliceChatManager = makeChatManager(alice) 110 | window.aliceMobileChatManager = makeChatManager(alice) 111 | }, alice) 112 | }) 113 | 114 | afterEach(async () => { 115 | await page.evaluate(async () => { 116 | aliceChatManager.disconnect() 117 | aliceMobileChatManager.disconnect() 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /tests/jest/helpers/config/example.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | module.exports = { 3 | INSTANCE_KEY: "your:key", 4 | INSTANCE_LOCATOR: "your:instance:locator", 5 | TOKEN_PROVIDER_URL: "https://token.provider.url" 6 | } 7 | -------------------------------------------------------------------------------- /tests/jest/helpers/main.js: -------------------------------------------------------------------------------- 1 | const ChatkitServer = require("@pusher/chatkit-server").default 2 | const uuid = require("uuid/v4") 3 | 4 | const config = require("./config/production") 5 | 6 | async function defaultBeforeAll(roleName) { 7 | page.on("console", async msg => { 8 | const argsWithRichErrors = await Promise.all( 9 | msg 10 | .args() 11 | .map(arg => 12 | arg 13 | .executionContext() 14 | .evaluate(arg => (arg instanceof Error ? arg.message : arg), arg), 15 | ), 16 | ) 17 | console.log(...argsWithRichErrors) 18 | }) 19 | 20 | page.on("pageerror", err => console.error("pageerror:", err)) 21 | page.on("error", err => console.error("error:", err)) 22 | 23 | await page.addScriptTag({ path: "./dist/web/chatkit.js" }) 24 | await page.evaluate(config => { 25 | window.ChatManager = Chatkit.ChatManager 26 | window.TokenProvider = Chatkit.TokenProvider 27 | window.config = config 28 | window.mockBeamsCalls = { 29 | startHasBeenCalled: false, 30 | stopHasBeenCalled: false, 31 | setUserIdHasBeenCalled: false, 32 | setUserIdHasBeenCalledWithUserId: null, 33 | setUserIdTokenProviderFetchedToken: false, 34 | } 35 | 36 | const mockBeamsClientSDK = { 37 | start: () => { 38 | mockBeamsCalls.startHasBeenCalled = true 39 | return Promise.resolve(mockBeamsClientSDK) 40 | }, 41 | setUserId: async (userId, tokenProvider) => { 42 | mockBeamsCalls.setUserIdHasBeenCalled = true 43 | mockBeamsCalls.setUserIdHasBeenCalledWithUserId = userId 44 | mockBeamsCalls.setUserIdTokenProviderFetchedToken = await tokenProvider.fetchToken( 45 | userId, 46 | ) 47 | }, 48 | stop: () => { 49 | mockBeamsCalls.stopHasBeenCalled = true 50 | }, 51 | } 52 | 53 | window.makeChatManager = user => 54 | new ChatManager({ 55 | instanceLocator: config.INSTANCE_LOCATOR, 56 | userId: user.id, 57 | logger: { 58 | verbose: () => {}, 59 | debug: () => {}, 60 | info: console.info, 61 | warn: console.warn, 62 | error: console.error, 63 | }, 64 | tokenProvider: new TokenProvider({ 65 | url: config.TOKEN_PROVIDER_URL, 66 | }), 67 | beamsInstanceInitFn: () => { 68 | return Promise.resolve(mockBeamsClientSDK) 69 | }, 70 | }) 71 | }, config) 72 | 73 | await makeGlobalRole(roleName) 74 | } 75 | 76 | async function defaultAfterAll(roleName) { 77 | await removeGlobalRole(roleName) 78 | } 79 | 80 | function makeUser(roleName) { 81 | const server = new ChatkitServer({ 82 | instanceLocator: config.INSTANCE_LOCATOR, 83 | key: config.INSTANCE_KEY, 84 | }) 85 | return server 86 | .createUser({ 87 | id: uuid(), 88 | name: uuid(), 89 | }) 90 | .then(res => { 91 | return server 92 | .assignGlobalRoleToUser({ 93 | userId: res.id, 94 | name: roleName, 95 | }) 96 | .then(() => res) 97 | }) 98 | .then(res => ({ 99 | id: res.id, 100 | name: res.name, 101 | })) 102 | } 103 | 104 | function makeRoom({ members, isPrivate }) { 105 | return new ChatkitServer({ 106 | instanceLocator: config.INSTANCE_LOCATOR, 107 | key: config.INSTANCE_KEY, 108 | }) 109 | .createRoom({ 110 | id: uuid(), 111 | name: uuid(), 112 | creatorId: members[0].id, 113 | userIds: members.map(m => m.id), 114 | isPrivate, 115 | }) 116 | .then(res => ({ 117 | id: res.id, 118 | name: res.name, 119 | })) 120 | } 121 | 122 | function makeGlobalRole(name) { 123 | return new ChatkitServer({ 124 | instanceLocator: config.INSTANCE_LOCATOR, 125 | key: config.INSTANCE_KEY, 126 | }) 127 | .createGlobalRole({ 128 | name: name, 129 | permissions: [ 130 | "message:create", 131 | "room:join", 132 | "room:leave", 133 | "room:members:add", 134 | "room:members:remove", 135 | "room:get", 136 | "room:create", 137 | "room:update", 138 | "room:delete", 139 | "room:messages:get", 140 | "room:typing_indicator:create", 141 | "presence:subscribe", 142 | "user:get", 143 | "user:rooms:get", 144 | "file:get", 145 | "file:create", 146 | "cursors:read:get", 147 | "cursors:read:set", 148 | ], 149 | }) 150 | .catch(err => { 151 | // role might already exist, which we'll ignore 152 | return 153 | }) 154 | } 155 | 156 | function removeGlobalRole(name) { 157 | return new ChatkitServer({ 158 | instanceLocator: config.INSTANCE_LOCATOR, 159 | key: config.INSTANCE_KEY, 160 | }).deleteGlobalRole({ 161 | name: name, 162 | }) 163 | } 164 | 165 | function makeSimpleMessage({ userId, roomId, text }) { 166 | return new ChatkitServer({ 167 | instanceLocator: config.INSTANCE_LOCATOR, 168 | key: config.INSTANCE_KEY, 169 | }) 170 | .sendSimpleMessage({ 171 | userId, 172 | roomId, 173 | text, 174 | }) 175 | .then(res => ({ 176 | id: res.message_id, 177 | })) 178 | } 179 | 180 | function deleteMessage({ roomId, messageId }) { 181 | return new ChatkitServer({ 182 | instanceLocator: config.INSTANCE_LOCATOR, 183 | key: config.INSTANCE_KEY, 184 | }) 185 | .deleteMessage({ 186 | roomId, 187 | messageId, 188 | }) 189 | .then(res => ({ 190 | id: res.message_id, 191 | })) 192 | } 193 | 194 | // withHook waits for window.actual to be set before calling the 195 | // supplied function 196 | async function withHook(fn) { 197 | while (true) { 198 | const actual = await page.evaluate(() => actual) 199 | if (actual != undefined) { 200 | await fn(actual) 201 | break 202 | } 203 | await sleep(100) 204 | } 205 | } 206 | 207 | function sleep(ms) { 208 | return new Promise(resolve => { 209 | setTimeout(resolve, ms) 210 | }) 211 | } 212 | 213 | module.exports = { 214 | defaultBeforeAll, 215 | defaultAfterAll, 216 | makeRoom, 217 | makeUser, 218 | makeSimpleMessage, 219 | deleteMessage, 220 | removeGlobalRole, 221 | withHook, 222 | } 223 | -------------------------------------------------------------------------------- /tests/jest/messages.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | const uuid = require("uuid/v4") 3 | const got = require("got") 4 | 5 | // Each test in this group will have 6 | // * a room 7 | // * bob and alice (in the room) 8 | 9 | describe("Messages", () => { 10 | test("can be created through simple method", async () => { 11 | const expectedText = "hello" 12 | const actual = await page.evaluate( 13 | async (room, expectedText) => { 14 | const alice = await aliceChatManager.connect() 15 | 16 | await alice.subscribeToRoomMultipart({ 17 | roomId: room.id, 18 | hooks: { 19 | onMessage: message => 20 | (window.actual = { 21 | message: message, 22 | }), 23 | }, 24 | }) 25 | 26 | await alice.sendSimpleMessage({ 27 | roomId: room.id, 28 | text: expectedText, 29 | }) 30 | }, 31 | room, 32 | expectedText, 33 | ) 34 | 35 | await helpers.withHook(async actual => { 36 | expect(actual.message.parts[0].payload.content).toBe(expectedText) 37 | }) 38 | }) 39 | 40 | test("can be created through generic method", async () => { 41 | const expectedText = "hello" 42 | const actual = await page.evaluate( 43 | async (room, expectedText) => { 44 | const alice = await aliceChatManager.connect() 45 | 46 | await alice.subscribeToRoomMultipart({ 47 | roomId: room.id, 48 | hooks: { 49 | onMessage: message => 50 | (window.actual = { 51 | message: message, 52 | }), 53 | }, 54 | }) 55 | 56 | await alice.sendMultipartMessage({ 57 | roomId: room.id, 58 | parts: [{ type: "text/plain", content: expectedText }], 59 | }) 60 | }, 61 | room, 62 | expectedText, 63 | ) 64 | 65 | await helpers.withHook(async actual => { 66 | expect(actual.message.parts[0].payload.content).toBe(expectedText) 67 | }) 68 | }) 69 | 70 | test("can be created with attachment", async () => { 71 | const payload = uuid() 72 | 73 | const actual = await page.evaluate( 74 | async (room, payload) => { 75 | const alice = await aliceChatManager.connect() 76 | 77 | await alice.subscribeToRoomMultipart({ 78 | roomId: room.id, 79 | hooks: { 80 | onMessage: async message => 81 | (window.actual = { 82 | url: await message.parts[0].payload.url(), 83 | }), 84 | }, 85 | }) 86 | 87 | await alice.sendMultipartMessage({ 88 | roomId: room.id, 89 | parts: [ 90 | { 91 | file: new File([payload], { type: "text/plain" }), 92 | type: "text/plain", 93 | }, 94 | ], 95 | }) 96 | }, 97 | room, 98 | payload, 99 | ) 100 | 101 | await helpers.withHook(async actual => { 102 | expect(actual.url).toBeDefined() 103 | const res = await got(actual.url) 104 | expect(res.body).toBe(payload) 105 | }) 106 | }) 107 | 108 | ///////////////////////// 109 | // Test setup 110 | const roleName = "messagesRole" 111 | 112 | beforeAll(async () => { 113 | await helpers.defaultBeforeAll(roleName) 114 | }) 115 | 116 | afterAll(async () => { 117 | await helpers.defaultAfterAll(roleName) 118 | }) 119 | 120 | beforeEach(async () => { 121 | global.alice = await helpers.makeUser(roleName) 122 | global.bob = await helpers.makeUser(roleName) 123 | global.room = await helpers.makeRoom({ 124 | members: [alice, bob], 125 | }) 126 | 127 | await page.evaluate( 128 | async (alice, bob) => { 129 | window.actual = undefined 130 | 131 | window.aliceChatManager = makeChatManager(alice) 132 | window.bobChatManager = makeChatManager(bob) 133 | }, 134 | alice, 135 | bob, 136 | ) 137 | }) 138 | 139 | afterEach(async () => { 140 | await page.evaluate(async () => { 141 | aliceChatManager.disconnect() 142 | bobChatManager.disconnect() 143 | }) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /tests/jest/presence.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | const uuid = require("uuid/v4") 3 | const got = require("got") 4 | 5 | // Each test in this group will have 6 | // * a room 7 | // * bob and alice (in the room) 8 | 9 | describe("Presence subscription", () => { 10 | test( 11 | "triggers hook when user comes online", 12 | async () => { 13 | await page.evaluate( 14 | async (bob, room) => { 15 | const alice = await aliceChatManager.connect() 16 | 17 | await alice.subscribeToRoomMultipart({ 18 | roomId: room.id, 19 | hooks: { 20 | onPresenceChanged: (state, user) => { 21 | if (state.current == "online" && user.id == bob.id) { 22 | window.actual = { 23 | state: state, 24 | user: user, 25 | } 26 | } 27 | }, 28 | }, 29 | }) 30 | 31 | await bobChatManager.connect() 32 | }, 33 | bob, 34 | room, 35 | ) 36 | 37 | await helpers.withHook(async actual => { 38 | expect(actual.user.id).toBe(bob.id) 39 | expect(actual.state.current).toBe("online") 40 | }) 41 | // we need extra time for the presence change to happen 42 | }, 43 | 10000, 44 | ) 45 | 46 | ///////////////////////// 47 | // Test setup 48 | const roleName = "messagesRole" 49 | 50 | beforeAll(async () => { 51 | await helpers.defaultBeforeAll(roleName) 52 | }) 53 | 54 | afterAll(async () => { 55 | await helpers.defaultAfterAll(roleName) 56 | }) 57 | 58 | beforeEach(async () => { 59 | global.alice = await helpers.makeUser(roleName) 60 | global.bob = await helpers.makeUser(roleName) 61 | global.room = await helpers.makeRoom({ 62 | members: [alice, bob], 63 | }) 64 | 65 | await page.evaluate( 66 | async (alice, bob) => { 67 | window.actual = undefined 68 | 69 | window.aliceChatManager = makeChatManager(alice) 70 | window.bobChatManager = makeChatManager(bob) 71 | }, 72 | alice, 73 | bob, 74 | ) 75 | }) 76 | 77 | afterEach(async () => { 78 | await page.evaluate(async () => { 79 | aliceChatManager.disconnect() 80 | bobChatManager.disconnect() 81 | }) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /tests/jest/rooms.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | const uuid = require("uuid/v4") 3 | 4 | // Each test in this group will have 5 | // * bob and alice 6 | 7 | describe("Rooms", () => { 8 | test("can be created with name parameter only", async () => { 9 | const expectedName = "mushroom" 10 | const actual = await page.evaluate(async roomName => { 11 | const alice = await aliceChatManager.connect() 12 | 13 | return await alice.createRoom({ 14 | name: roomName, 15 | }) 16 | }, expectedName) 17 | 18 | expect(actual.id).toBeDefined() 19 | expect(actual.isPrivate).toBeFalsy() 20 | expect(actual.name).toBe(expectedName) 21 | }) 22 | 23 | test("can be created with supplied id", async () => { 24 | const expectedId = uuid() 25 | const actual = await page.evaluate(async roomId => { 26 | const alice = await aliceChatManager.connect() 27 | 28 | return await alice.createRoom({ 29 | id: roomId, 30 | name: roomId, 31 | }) 32 | }, expectedId) 33 | 34 | expect(actual.id).toBe(expectedId) 35 | expect(actual.isPrivate).toBeFalsy() 36 | expect(actual.name).toBe(expectedId) 37 | }) 38 | 39 | test("can have users added", async () => { 40 | const room = await helpers.makeRoom({ members: [alice] }) 41 | 42 | await page.evaluate( 43 | async (bob, roomId) => { 44 | const alice = await aliceChatManager.connect() 45 | alice.subscribeToRoomMultipart({ 46 | roomId: roomId, 47 | hooks: { 48 | onUserJoined: newUser => 49 | (window.actual = { 50 | user: newUser, 51 | }), 52 | }, 53 | }) 54 | 55 | await alice.addUserToRoom({ 56 | userId: bob.id, 57 | roomId: roomId, 58 | }) 59 | }, 60 | bob, 61 | room.id, 62 | ) 63 | 64 | await helpers.withHook(actual => { 65 | expect(actual.user.id).toBe(bob.id) 66 | }) 67 | }) 68 | 69 | test("can have users removed", async () => { 70 | const room = await helpers.makeRoom({ members: [alice, bob] }) 71 | 72 | await page.evaluate( 73 | async (bob, roomId) => { 74 | const alice = await aliceChatManager.connect() 75 | alice.subscribeToRoomMultipart({ 76 | roomId: roomId, 77 | hooks: { 78 | onUserLeft: user => 79 | (window.actual = { 80 | user: user, 81 | }), 82 | }, 83 | }) 84 | 85 | await alice.removeUserFromRoom({ 86 | userId: bob.id, 87 | roomId: roomId, 88 | }) 89 | }, 90 | bob, 91 | room.id, 92 | ) 93 | 94 | await helpers.withHook(actual => { 95 | expect(actual.user.id).toBe(bob.id) 96 | }) 97 | }) 98 | 99 | test("can be joined with correct permission", async () => { 100 | const room = await helpers.makeRoom({ members: [alice] }) 101 | 102 | await page.evaluate(async roomId => { 103 | const alice = await aliceChatManager.connect() 104 | await alice.subscribeToRoomMultipart({ 105 | roomId: roomId, 106 | hooks: { 107 | onUserJoined: user => 108 | (window.actual = { 109 | user: user, 110 | }), 111 | }, 112 | }) 113 | 114 | const bob = await bobChatManager.connect() 115 | await bob.joinRoom({ 116 | roomId: roomId, 117 | }) 118 | }, room.id) 119 | 120 | await helpers.withHook(actual => { 121 | expect(actual.user.id).toBe(bob.id) 122 | }) 123 | }) 124 | 125 | test("can be updated", async () => { 126 | const room = await helpers.makeRoom({ members: [alice] }) 127 | 128 | const updatedName = "newName" 129 | const updatedData = "bar" 130 | expect(room.name).not.toBe(updatedName) 131 | expect(room.customData).toBeUndefined() 132 | expect(room.isPrivate).toBeFalsy() 133 | 134 | await page.evaluate( 135 | async (roomId, updatedName, updatedData) => { 136 | const alice = await aliceChatManager.connect({ 137 | onRoomUpdated: room => (window.actual = { room: room }), 138 | }) 139 | await alice.updateRoom({ 140 | roomId: roomId, 141 | name: updatedName, 142 | customData: { foo: updatedData }, 143 | private: true, 144 | }) 145 | }, 146 | room.id, 147 | updatedName, 148 | updatedData, 149 | ) 150 | 151 | await helpers.withHook(actual => { 152 | expect(actual.room.name).toBe(updatedName) 153 | expect(actual.room.customData.foo).toBe(updatedData) 154 | expect(actual.room.isPrivate).toBeTruthy() 155 | }) 156 | }) 157 | 158 | test("can be deleted", async () => { 159 | const room = await helpers.makeRoom({ members: [alice] }) 160 | 161 | await page.evaluate(async roomId => { 162 | const alice = await aliceChatManager.connect({ 163 | onRoomDeleted: room => (window.actual = { room: room }), 164 | }) 165 | await alice.deleteRoom({ 166 | roomId: roomId, 167 | }) 168 | }, room.id) 169 | 170 | await helpers.withHook(actual => { 171 | expect(actual.room.id).toBe(room.id) 172 | }) 173 | }) 174 | 175 | ///////////////////////// 176 | // Test setup 177 | const roleName = "roomsRole" 178 | 179 | beforeAll(async () => { 180 | await helpers.defaultBeforeAll(roleName) 181 | }) 182 | 183 | afterAll(async () => { 184 | await helpers.defaultAfterAll(roleName) 185 | }) 186 | 187 | beforeEach(async () => { 188 | global.alice = await helpers.makeUser(roleName) 189 | global.bob = await helpers.makeUser(roleName) 190 | 191 | await page.evaluate( 192 | async (alice, bob) => { 193 | window.actual = undefined 194 | 195 | window.aliceChatManager = makeChatManager(alice) 196 | window.bobChatManager = makeChatManager(bob) 197 | }, 198 | alice, 199 | bob, 200 | ) 201 | }) 202 | 203 | afterEach(async () => { 204 | await page.evaluate(async () => { 205 | aliceChatManager.disconnect() 206 | bobChatManager.disconnect() 207 | }) 208 | }) 209 | }) 210 | -------------------------------------------------------------------------------- /tests/jest/tab-open-notifications.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | const uuid = require("uuid/v4") 3 | const got = require("got") 4 | 5 | // Each test in this group will have 6 | // * a private room 7 | // * bob and alice (in the room) 8 | 9 | describe("Tab open notifications", () => { 10 | test( 11 | "are not received for messages in private rooms when the tab is visible", 12 | async () => { 13 | // Bring the test tab to the front so that it is visible 14 | await page.bringToFront() 15 | 16 | const messageText = "hello" 17 | 18 | const actual = await page.evaluate( 19 | (room, messageText) => 20 | new Promise(async resolve => { 21 | class MockNotification { 22 | constructor(title, options) { 23 | const mockEvent = { 24 | preventDefault: () => {}, 25 | target: { 26 | close: () => {}, 27 | data: { 28 | chatkit: { title, options }, // This gets passed to onClick 29 | }, 30 | }, 31 | } 32 | 33 | // Wait a moment for the click handler to be set, and then 34 | // click our notification. 35 | setTimeout(() => this.onclick(mockEvent), 100) 36 | } 37 | } 38 | 39 | const alice = await aliceChatManager.connect() 40 | 41 | await alice.enablePushNotifications({ 42 | onClick: resolve, 43 | showNotificationsTabClosed: false, 44 | _Notification: MockNotification, 45 | _visibilityStateOverride: "visible", 46 | }) 47 | 48 | const bob = await bobChatManager.connect() 49 | await bob.sendSimpleMessage({ 50 | roomId: room.id, 51 | text: messageText, 52 | }) 53 | 54 | setTimeout(() => resolve("timeout"), 6000) 55 | }), 56 | room, 57 | messageText, 58 | ) 59 | 60 | expect(actual).toBe("timeout") 61 | }, 62 | 10000, 63 | ) 64 | 65 | test( 66 | "are received for messages in private rooms when the tab is hidden", 67 | async () => { 68 | // Create a new tab so that the test tab is hidden 69 | await browser.newPage() 70 | 71 | const messageText = "hello" 72 | 73 | const actual = await page.evaluate( 74 | (room, messageText) => 75 | new Promise(async resolve => { 76 | class MockNotification { 77 | constructor(title, options) { 78 | const mockEvent = { 79 | preventDefault: () => {}, 80 | target: { 81 | close: () => {}, 82 | data: { 83 | chatkit: { title, options }, // This gets passed to onClick 84 | }, 85 | }, 86 | } 87 | 88 | // Wait a moment for the click handler to be set, and then 89 | // click our notification. 90 | setTimeout(() => this.onclick(mockEvent), 100) 91 | } 92 | } 93 | 94 | const alice = await aliceChatManager.connect() 95 | 96 | await alice.enablePushNotifications({ 97 | onClick: resolve, 98 | showNotificationsTabClosed: false, 99 | _Notification: MockNotification, 100 | _visibilityStateOverride: "hidden", 101 | }) 102 | 103 | const bob = await bobChatManager.connect() 104 | await bob.sendSimpleMessage({ 105 | roomId: room.id, 106 | text: messageText, 107 | }) 108 | }), 109 | room, 110 | messageText, 111 | ) 112 | 113 | expect(actual).toEqual({ 114 | title: bob.name, 115 | options: { 116 | body: messageText, 117 | data: { 118 | pusher: { deep_link: `https://pusher.com?ck_room_id=${room.id}` }, 119 | chatkit: { roomId: room.id }, 120 | }, 121 | icon: "https://pusher.com/favicon.ico", 122 | }, 123 | }) 124 | }, 125 | 10000, 126 | ) 127 | 128 | ///////////////////////// 129 | // Test setup 130 | const roleName = "notificationsRole" 131 | 132 | beforeAll(async () => { 133 | await helpers.defaultBeforeAll(roleName) 134 | }) 135 | 136 | afterAll(async () => { 137 | await helpers.defaultAfterAll(roleName) 138 | }) 139 | 140 | beforeEach(async () => { 141 | global.alice = await helpers.makeUser(roleName) 142 | global.bob = await helpers.makeUser(roleName) 143 | global.room = await helpers.makeRoom({ 144 | members: [alice, bob], 145 | isPrivate: true, 146 | }) 147 | 148 | await page.evaluate( 149 | async (alice, bob) => { 150 | window.actual = undefined 151 | 152 | window.aliceChatManager = makeChatManager(alice) 153 | window.bobChatManager = makeChatManager(bob) 154 | }, 155 | alice, 156 | bob, 157 | ) 158 | }) 159 | 160 | afterEach(async () => { 161 | await page.evaluate(async () => { 162 | aliceChatManager.disconnect() 163 | bobChatManager.disconnect() 164 | }) 165 | }) 166 | }) 167 | -------------------------------------------------------------------------------- /tests/jest/web-push-notifications.js: -------------------------------------------------------------------------------- 1 | const helpers = require("./helpers/main") 2 | /// beams ok with default options 3 | /// beams ok with explicit shownotificiationstabclosed 4 | /// no beams if shownotificationstabclosed is false 5 | /// disable notif works 6 | 7 | /// failing beams is caught by chatmanager on enable 8 | /// throwing beams is caught by chatmanager on enable 9 | /// rejecting beams is caught by chatmanager on enable 10 | /// failing chatmanager is caught (x2) 11 | 12 | // to mock out tabopennotifications 13 | 14 | describe("Web push notifications", () => { 15 | test("succeeds in registering with Beams with default ChatManager notification options", async () => { 16 | const user = await helpers.makeUser("default") 17 | const mockBeamsCalls = await page.evaluate( 18 | user => 19 | makeChatManager(user) 20 | .connect() 21 | .then(user => { 22 | return user.enablePushNotifications() 23 | }) 24 | .then(() => { 25 | return mockBeamsCalls 26 | }), 27 | user, 28 | ) 29 | expect(mockBeamsCalls.startHasBeenCalled).toBeTruthy() 30 | expect(mockBeamsCalls.stopHasBeenCalled).toEqual(false) 31 | expect(mockBeamsCalls.setUserIdHasBeenCalled).toBeTruthy() 32 | expect(mockBeamsCalls.setUserIdHasBeenCalledWithUserId).toEqual(user.id) 33 | expect(mockBeamsCalls.setUserIdTokenProviderFetchedToken.token).toBeTruthy() 34 | }) 35 | 36 | test("registers with Beams if `showNotificationsTabClosed` is set explicitly to true", async () => { 37 | const user = await helpers.makeUser("default") 38 | const mockBeamsCalls = await page.evaluate( 39 | user => 40 | makeChatManager(user) 41 | .connect() 42 | .then(user => { 43 | return user.enablePushNotifications({ 44 | showNotificationsTabClosed: true, 45 | }) 46 | }) 47 | .then(() => { 48 | return mockBeamsCalls 49 | }), 50 | user, 51 | ) 52 | expect(mockBeamsCalls.startHasBeenCalled).toBeTruthy() 53 | expect(mockBeamsCalls.stopHasBeenCalled).toEqual(false) 54 | expect(mockBeamsCalls.setUserIdHasBeenCalled).toBeTruthy() 55 | expect(mockBeamsCalls.setUserIdHasBeenCalledWithUserId).toEqual(user.id) 56 | expect(mockBeamsCalls.setUserIdTokenProviderFetchedToken.token).toBeTruthy() 57 | }) 58 | 59 | test("does NOT register with Beams if `showNotificationsTabClosed` is set to false", async () => { 60 | const user = await helpers.makeUser("default") 61 | const mockBeamsCalls = await page.evaluate( 62 | user => 63 | makeChatManager(user) 64 | .connect() 65 | .then(user => { 66 | return user.enablePushNotifications({ 67 | showNotificationsTabClosed: false, 68 | }) 69 | }) 70 | .then(() => { 71 | return mockBeamsCalls 72 | }), 73 | user, 74 | ) 75 | expect(mockBeamsCalls.startHasBeenCalled).toEqual(false) 76 | expect(mockBeamsCalls.stopHasBeenCalled).toBeTruthy() 77 | expect(mockBeamsCalls.setUserIdHasBeenCalled).toEqual(false) 78 | expect(mockBeamsCalls.setUserIdHasBeenCalledWithUserId).toBeNull() 79 | expect(mockBeamsCalls.setUserIdTokenProviderFetchedToken).toEqual(false) 80 | }) 81 | 82 | test("chat manager successfully disables Beams notifications", async () => { 83 | const user = await helpers.makeUser("default") 84 | const mockBeamsCalls = await page.evaluate( 85 | user => 86 | makeChatManager(user) 87 | .connect() 88 | .then(user => { 89 | user.enablePushNotifications({ showNotificationsTabClosed: false }) 90 | return user 91 | }) 92 | .then(user => { 93 | return user.disablePushNotifications() 94 | }) 95 | .then(() => { 96 | return mockBeamsCalls 97 | }), 98 | user, 99 | ) 100 | 101 | expect(mockBeamsCalls.stopHasBeenCalled).toBeTruthy() 102 | }) 103 | 104 | beforeEach(async () => { 105 | await helpers.defaultBeforeAll() 106 | }) 107 | 108 | afterEach(async () => { 109 | await helpers.defaultAfterAll() 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /tests/unit/batch.js: -------------------------------------------------------------------------------- 1 | import tape from "tape" 2 | 3 | import { batch } from "../../src/batch.js" 4 | 5 | const TEST_TIMEOUT = 200 6 | 7 | function test(name, f) { 8 | tape(name, t => { 9 | t.timeoutAfter(TEST_TIMEOUT) 10 | f(t) 11 | }) 12 | } 13 | 14 | test("batch fires after maxWait", t => { 15 | let interval 16 | const g = batch( 17 | args => { 18 | t.deepEqual(args, [0, 1, 2, 3, 4]) 19 | t.end() 20 | clearInterval(interval) 21 | return Promise.resolve() 22 | }, 23 | 45, 24 | 100, 25 | ) 26 | 27 | let n = 0 28 | interval = setInterval(() => { 29 | g(n) 30 | n++ 31 | }, 10) 32 | }) 33 | 34 | test("batch fires after maxPending", t => { 35 | const g = batch( 36 | args => { 37 | t.deepEqual(args, [0, 1, 2, 3, 4]) 38 | t.end() 39 | return Promise.resolve() 40 | }, 41 | 1000, 42 | 5, 43 | ) 44 | 45 | for (let n = 0; n < 8; n++) { 46 | g(n) 47 | } 48 | }) 49 | 50 | test("deduplication", t => { 51 | const g = batch( 52 | args => { 53 | t.deepEqual(args, [0, 1, 2, 3, 4]) 54 | t.end() 55 | return Promise.resolve() 56 | }, 57 | 1000, 58 | 5, 59 | ) 60 | 61 | for (let n = 0; n < 8; n++) { 62 | g(n) 63 | g(1) 64 | } 65 | }) 66 | 67 | test("firing concurrently", t => { 68 | let call = 0 69 | const g = batch( 70 | args => { 71 | switch (call) { 72 | case 0: 73 | call++ 74 | t.deepEqual(args, [0, 1, 2]) 75 | return resolveAfter(10).then(() => "slow") 76 | case 1: 77 | t.deepEqual(args, [3, 4, 5]) 78 | return Promise.resolve("fast") 79 | } 80 | }, 81 | 1000, 82 | 3, 83 | ) 84 | 85 | let slowResolved 86 | Promise.all([0, 1, 2].map(n => g(n))).then(results => { 87 | t.deepEqual(results, ["slow", "slow", "slow"]) 88 | slowResolved = true 89 | t.end() 90 | }) 91 | 92 | Promise.all([3, 4, 5].map(n => g(n))).then(results => { 93 | t.false(slowResolved) 94 | t.deepEqual(results, ["fast", "fast", "fast"]) 95 | }) 96 | }) 97 | 98 | test("deduplication of in progress requests", t => { 99 | let call = 0 100 | const g = batch( 101 | args => { 102 | switch (call) { 103 | case 0: 104 | call++ 105 | t.deepEqual(args, [0, 1, 2]) 106 | return resolveAfter(10).then(() => "slow") 107 | case 1: 108 | t.deepEqual(args, [3, 4, 5]) 109 | return Promise.resolve("fast") 110 | } 111 | }, 112 | 1000, 113 | 3, 114 | ) 115 | 116 | Promise.all([0, 1, 2, 3, 1, 4, 1, 5, 1].map(n => g(n))).then(results => { 117 | t.deepEqual(results, [ 118 | "slow", 119 | "slow", 120 | "slow", 121 | "fast", 122 | "slow", 123 | "fast", 124 | "slow", 125 | "fast", 126 | "slow", 127 | ]) 128 | t.end() 129 | }) 130 | }) 131 | 132 | function resolveAfter(time) { 133 | return new Promise(resolve => { 134 | setTimeout(() => resolve(), time) 135 | }) 136 | } 137 | -------------------------------------------------------------------------------- /tests/unit/chat-manager-constructor.js: -------------------------------------------------------------------------------- 1 | import tape from "tape" 2 | 3 | import { ChatManager } from "../../src/chat-manager.js" 4 | 5 | const TEST_TIMEOUT = 200 6 | const DUMMY_TOKEN_PROVIDER = { 7 | fetchToken: () => {}, 8 | } 9 | 10 | function test(name, f) { 11 | tape(name, t => { 12 | t.timeoutAfter(TEST_TIMEOUT) 13 | 14 | f(t) 15 | }) 16 | } 17 | 18 | test("chat manager constructor instanceLocator validation", t => { 19 | const otherRequiredValidProps = { 20 | tokenProvider: DUMMY_TOKEN_PROVIDER, 21 | userId: "luis", 22 | } 23 | 24 | t.throws( 25 | () => 26 | new ChatManager({ 27 | instanceLocator: undefined, 28 | ...otherRequiredValidProps, 29 | }), 30 | /TypeError/, 31 | "expected instanceLocator to be of the format x:y:z", 32 | ) 33 | t.throws( 34 | () => 35 | new ChatManager({ instanceLocator: "x:", ...otherRequiredValidProps }), 36 | /TypeError/, 37 | "expected instanceLocator to be of the format x:y:z", 38 | ) 39 | t.throws( 40 | () => 41 | new ChatManager({ instanceLocator: ":y:", ...otherRequiredValidProps }), 42 | /TypeError/, 43 | "expected instanceLocator to be of the format x:y:z", 44 | ) 45 | t.throws( 46 | () => 47 | new ChatManager({ instanceLocator: ":y:`", ...otherRequiredValidProps }), 48 | /TypeError/, 49 | "expected instanceLocator to be of the format x:y:z", 50 | ) 51 | 52 | t.doesNotThrow( 53 | () => 54 | new ChatManager({ 55 | instanceLocator: "v1:us1:b92da0bf-ec77-443c-8d9e-9ab4b2bcf811", 56 | ...otherRequiredValidProps, 57 | }), 58 | ) 59 | 60 | t.end() 61 | }) 62 | -------------------------------------------------------------------------------- /tests/unit/cursor-sub-reconnection.js: -------------------------------------------------------------------------------- 1 | import tape from "tape" 2 | 3 | import { CursorStore } from "../../src/cursor-store.js" 4 | import { UserStore } from "../../src/user-store.js" 5 | import { handleCursorSubReconnection } from "../../src/reconnection-handlers.js" 6 | import { parseBasicCursor } from "../../src/parsers.js" 7 | 8 | const TEST_TIMEOUT = 200 9 | 10 | const oldCursors = [ 11 | { 12 | cursor_type: 0, 13 | room_id: "1", 14 | user_id: "callum", 15 | position: 1, 16 | updated_at: "2017-11-29T16:59:58Z", 17 | }, 18 | { 19 | cursor_type: 0, 20 | room_id: "2", 21 | user_id: "callum", 22 | position: 2, 23 | updated_at: "2017-11-29T16:59:58Z", 24 | }, 25 | { 26 | cursor_type: 0, 27 | room_id: "1", 28 | user_id: "mike", 29 | position: 2, 30 | updated_at: "2017-11-29T16:59:58Z", 31 | }, 32 | ].map(c => parseBasicCursor(c)) 33 | 34 | const newUserCursors = [ 35 | { 36 | cursor_type: 0, 37 | room_id: "1", 38 | user_id: "callum", 39 | position: 1, 40 | updated_at: "2017-11-29T16:59:58Z", 41 | }, 42 | { 43 | cursor_type: 0, 44 | room_id: "2", 45 | user_id: "callum", 46 | position: 3, 47 | updated_at: "2017-11-29T16:59:58Z", 48 | }, 49 | { 50 | cursor_type: 0, 51 | room_id: "3", 52 | user_id: "callum", 53 | position: 4, 54 | updated_at: "2017-11-29T16:59:58Z", 55 | }, 56 | ].map(c => parseBasicCursor(c)) 57 | 58 | const newRoomCursors = [ 59 | { 60 | cursor_type: 0, 61 | room_id: "1", 62 | user_id: "callum", 63 | position: 1, 64 | updated_at: "2017-11-29T16:59:58Z", 65 | }, 66 | { 67 | cursor_type: 0, 68 | room_id: "1", 69 | user_id: "mike", 70 | position: 3, 71 | updated_at: "2017-11-29T16:59:58Z", 72 | }, 73 | { 74 | cursor_type: 0, 75 | room_id: "1", 76 | user_id: "viv", 77 | position: 3, 78 | updated_at: "2017-11-29T16:59:58Z", 79 | }, 80 | ].map(c => parseBasicCursor(c)) 81 | 82 | function test(name, f) { 83 | tape(name, t => { 84 | t.timeoutAfter(TEST_TIMEOUT) 85 | 86 | const userStore = new UserStore({}) 87 | const cursorStore = new CursorStore({ userStore }) 88 | Promise.all( 89 | ["callum", "mike", "viv"].map(id => 90 | userStore.set({ id, name: `user with id ${id}` }), 91 | ), 92 | ) 93 | .then(() => Promise.all(oldCursors.map(c => cursorStore.set(c)))) 94 | .then(() => f(t, cursorStore)) 95 | }) 96 | } 97 | 98 | test("updated (user)", (t, cursorStore) => { 99 | const onNewCursorHook = cursor => { 100 | if (cursor.roomId !== "2") { 101 | return 102 | } 103 | 104 | t.equal(cursor.type, 0) 105 | t.equal(cursor.userId, "callum") 106 | t.equal(cursor.position, 3) 107 | t.end() 108 | } 109 | 110 | handleCursorSubReconnection({ 111 | basicCursors: newUserCursors, 112 | cursorStore, 113 | onNewCursorHook, 114 | }) 115 | }) 116 | 117 | test("new (user)", (t, cursorStore) => { 118 | const onNewCursorHook = cursor => { 119 | if (cursor.roomId !== "3") { 120 | return 121 | } 122 | 123 | t.equal(cursor.type, 0) 124 | t.equal(cursor.userId, "callum") 125 | t.equal(cursor.position, 4) 126 | t.end() 127 | } 128 | 129 | handleCursorSubReconnection({ 130 | basicCursors: newUserCursors, 131 | cursorStore, 132 | onNewCursorHook, 133 | }) 134 | }) 135 | 136 | test("cursor store (user)", (t, cursorStore) => { 137 | handleCursorSubReconnection({ 138 | basicCursors: newUserCursors, 139 | cursorStore, 140 | onNewCursorHook: () => {}, 141 | }).then(() => { 142 | t.equal(cursorStore.getSync("callum", "1").position, 1) 143 | t.equal(cursorStore.getSync("callum", "2").position, 3) 144 | t.equal(cursorStore.getSync("callum", "3").position, 4) 145 | t.end() 146 | }) 147 | }) 148 | 149 | test("updated (room)", (t, cursorStore) => { 150 | const onNewCursorHook = cursor => { 151 | if (cursor.userId !== "mike") { 152 | return 153 | } 154 | 155 | t.equal(cursor.type, 0) 156 | t.equal(cursor.roomId, "1") 157 | t.equal(cursor.position, 3) 158 | t.end() 159 | } 160 | 161 | handleCursorSubReconnection({ 162 | basicCursors: newRoomCursors, 163 | cursorStore, 164 | onNewCursorHook, 165 | }) 166 | }) 167 | 168 | test("new (room)", (t, cursorStore) => { 169 | const onNewCursorHook = cursor => { 170 | if (cursor.userId !== "viv") { 171 | return 172 | } 173 | 174 | t.equal(cursor.type, 0) 175 | t.equal(cursor.roomId, "1") 176 | t.equal(cursor.position, 3) 177 | t.end() 178 | } 179 | 180 | handleCursorSubReconnection({ 181 | basicCursors: newRoomCursors, 182 | cursorStore, 183 | onNewCursorHook, 184 | }) 185 | }) 186 | 187 | test("cursor store (room)", (t, cursorStore) => { 188 | handleCursorSubReconnection({ 189 | basicCursors: newRoomCursors, 190 | cursorStore, 191 | onNewCursorHook: () => {}, 192 | }).then(() => { 193 | t.equal(cursorStore.getSync("callum", "1").position, 1) 194 | t.equal(cursorStore.getSync("mike", "1").position, 3) 195 | t.equal(cursorStore.getSync("viv", "1").position, 3) 196 | t.end() 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /tests/unit/membership-sub-reconnection.js: -------------------------------------------------------------------------------- 1 | import tape from "tape" 2 | 3 | import { RoomStore } from "../../src/room-store.js" 4 | import { UserStore } from "../../src/user-store.js" 5 | import { handleMembershipSubReconnection } from "../../src/reconnection-handlers.js" 6 | 7 | const TEST_TIMEOUT = 200 8 | 9 | const roomId = "42" 10 | 11 | const oldUserIds = ["callum", "mike", "alice"] 12 | const newUserIds = ["callum", "mike", "bob"] 13 | 14 | function test(name, f) { 15 | tape(name, t => { 16 | t.timeoutAfter(TEST_TIMEOUT) 17 | 18 | const userStore = new UserStore({}) 19 | oldUserIds.forEach(id => userStore.set({ id, name: `user with id ${id}` })) 20 | newUserIds.forEach(id => userStore.set({ id, name: `user with id ${id}` })) 21 | const roomStore = new RoomStore({ userStore }) 22 | roomStore 23 | .set({ id: roomId, name: "mushroom" }) 24 | .then(() => roomStore.update(roomId, { userIds: oldUserIds })) 25 | .then(() => f(t, userStore, roomStore)) 26 | }) 27 | } 28 | 29 | test("user joined (room level hook)", (t, userStore, roomStore) => { 30 | const onUserJoined = user => { 31 | t.equal(user.id, "bob") 32 | t.equal(user.name, "user with id bob") 33 | t.end() 34 | } 35 | 36 | handleMembershipSubReconnection({ 37 | userIds: newUserIds, 38 | roomId, 39 | roomStore, 40 | userStore, 41 | onUserJoinedRoomHook: (room, user) => { 42 | if (room.id === "42") { 43 | onUserJoined(user) 44 | } 45 | }, 46 | onUserLeftRoomHook: () => {}, 47 | }) 48 | }) 49 | 50 | test("user joined (global hook)", (t, userStore, roomStore) => { 51 | const onUserJoinedRoom = (room, user) => { 52 | t.equal(room.id, roomId) 53 | t.equal(room.name, "mushroom") 54 | t.equal(user.id, "bob") 55 | t.equal(user.name, "user with id bob") 56 | t.end() 57 | } 58 | 59 | handleMembershipSubReconnection({ 60 | userIds: newUserIds, 61 | roomId, 62 | roomStore, 63 | userStore, 64 | onUserJoinedRoomHook: (room, user) => onUserJoinedRoom(room, user), 65 | onUserLeftRoomHook: () => {}, 66 | }) 67 | }) 68 | 69 | test("user left (room level hook)", (t, userStore, roomStore) => { 70 | const onUserLeft = user => { 71 | t.equal(user.id, "alice") 72 | t.equal(user.name, "user with id alice") 73 | t.end() 74 | } 75 | 76 | handleMembershipSubReconnection({ 77 | userIds: newUserIds, 78 | roomId, 79 | roomStore, 80 | userStore, 81 | onUserJoinedRoomHook: () => {}, 82 | onUserLeftRoomHook: (room, user) => { 83 | if (room.id === "42") { 84 | onUserLeft(user) 85 | } 86 | }, 87 | }) 88 | }) 89 | 90 | test("user joined (global hook)", (t, userStore, roomStore) => { 91 | const onUserLeftRoom = (room, user) => { 92 | t.equal(room.id, roomId) 93 | t.equal(room.name, "mushroom") 94 | t.equal(user.id, "alice") 95 | t.equal(user.name, "user with id alice") 96 | t.end() 97 | } 98 | 99 | handleMembershipSubReconnection({ 100 | userIds: newUserIds, 101 | roomId, 102 | roomStore, 103 | userStore, 104 | onUserJoinedRoomHook: () => {}, 105 | onUserLeftRoomHook: (room, user) => onUserLeftRoom(room, user), 106 | }) 107 | }) 108 | 109 | test("room store memberships updated", (t, userStore, roomStore) => { 110 | handleMembershipSubReconnection({ 111 | userIds: newUserIds, 112 | roomId, 113 | roomStore, 114 | userStore, 115 | onUserJoinedRoomHook: () => {}, 116 | onUserLeftRoomHook: () => {}, 117 | }).then(() => { 118 | t.equal(roomStore.getSync(roomId).userIds, newUserIds) 119 | t.end() 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /tests/unit/user-sub-reconnection.js: -------------------------------------------------------------------------------- 1 | import tape from "tape" 2 | 3 | import { CurrentUser } from "../../src/current-user.js" 4 | import { RoomStore } from "../../src/room-store.js" 5 | import { CursorStore } from "../../src/cursor-store.js" 6 | import { handleUserSubReconnection } from "../../src/reconnection-handlers.js" 7 | import { parseBasicRoom, parseBasicUser } from "../../src/parsers" 8 | 9 | const TEST_TIMEOUT = 200 10 | 11 | function test(name, f) { 12 | tape(name, t => { 13 | t.timeoutAfter(TEST_TIMEOUT) 14 | 15 | const currentUser = { 16 | name: "Callum", 17 | avatarURL: "old-avatar-url", 18 | customData: { foo: "bar" }, 19 | createdAt: "old-created-at", 20 | updatedAt: "old-updated-at", 21 | setPropertiesFromBasicUser: 22 | CurrentUser.prototype.setPropertiesFromBasicUser, 23 | } 24 | 25 | const roomStore = new RoomStore({}) 26 | roomStoreRooms.forEach(room => roomStore.set(parseBasicRoom(room))) 27 | f(t, currentUser, roomStore, new CursorStore({})) 28 | }) 29 | } 30 | 31 | const basicUser = parseBasicUser({ 32 | name: "Callum", 33 | avatar_url: "new-avatar-url", 34 | custom_data: { baz: 42 }, 35 | created_at: "new-created-at", 36 | updated_at: "new-updated-at", 37 | }) 38 | 39 | const roomStoreRooms = [ 40 | { 41 | id: "1", 42 | created_by_id: "ham", 43 | name: "one", 44 | private: false, 45 | created_at: "2017-04-13T14:10:38Z", 46 | updated_at: "2017-04-13T14:10:38Z", 47 | }, 48 | { 49 | id: "2", 50 | created_by_id: "ham", 51 | name: "two", 52 | private: false, 53 | created_at: "2017-04-13T14:10:38Z", 54 | updated_at: "2017-04-13T14:10:38Z", 55 | }, 56 | { 57 | id: "3", 58 | created_by_id: "ham", 59 | name: "three", 60 | private: false, 61 | created_at: "2017-04-13T14:10:38Z", 62 | updated_at: "2017-04-13T14:10:38Z", 63 | }, 64 | { 65 | id: "4", 66 | created_by_id: "ham", 67 | name: "four", 68 | private: false, 69 | created_at: "2017-04-13T14:10:38Z", 70 | updated_at: "2017-04-13T14:10:38Z", 71 | }, 72 | { 73 | id: "5", 74 | created_by_id: "ham", 75 | name: "five", 76 | private: false, 77 | created_at: "2017-04-13T14:10:38Z", 78 | updated_at: "2017-04-13T14:10:38Z", 79 | }, 80 | { 81 | id: "7", 82 | created_by_id: "ham", 83 | name: "seven", 84 | custom_data: { pre: "set", custom: "data" }, 85 | private: false, 86 | created_at: "2017-04-13T14:10:38Z", 87 | updated_at: "2017-04-13T14:10:38Z", 88 | }, 89 | { 90 | id: "8", 91 | created_by_id: "ham", 92 | name: "eight", 93 | custom_data: { pre: "set" }, 94 | private: false, 95 | created_at: "2017-04-13T14:10:38Z", 96 | updated_at: "2017-04-13T14:10:38Z", 97 | }, 98 | { 99 | id: "9", 100 | created_by_id: "ham", 101 | name: "nine", 102 | custom_data: { pre: "set" }, 103 | private: false, 104 | created_at: "2017-04-13T14:10:38Z", 105 | updated_at: "2017-04-13T14:10:38Z", 106 | }, 107 | ] 108 | 109 | const roomsData = [ 110 | { 111 | id: "1", 112 | created_by_id: "ham", 113 | name: "one", 114 | private: false, 115 | created_at: "2017-04-13T14:10:38Z", 116 | updated_at: "2017-04-13T14:10:38Z", 117 | }, 118 | { 119 | id: "3", 120 | created_by_id: "ham", 121 | name: "three", 122 | private: true, 123 | created_at: "2017-04-13T14:10:38Z", 124 | updated_at: "2017-04-13T14:10:38Z", 125 | }, 126 | { 127 | id: "4", 128 | created_by_id: "ham", 129 | name: "four", 130 | private: false, 131 | custom_data: { set: "now" }, 132 | created_at: "2017-04-13T14:10:38Z", 133 | updated_at: "2017-04-13T14:10:38Z", 134 | }, 135 | { 136 | id: "5", 137 | created_by_id: "ham", 138 | name: "5ive", 139 | private: false, 140 | created_at: "2017-04-13T14:10:38Z", 141 | updated_at: "2017-04-13T14:10:38Z", 142 | }, 143 | { 144 | id: "6", 145 | created_by_id: "ham", 146 | name: "size", 147 | private: false, 148 | created_at: "2017-04-13T14:10:38Z", 149 | updated_at: "2017-04-13T14:10:38Z", 150 | }, 151 | { 152 | id: "7", 153 | created_by_id: "ham", 154 | name: "seven", 155 | custom_data: { pre: "set", custom: "data", third: "field" }, 156 | private: false, 157 | created_at: "2017-04-13T14:10:38Z", 158 | updated_at: "2017-04-13T14:10:38Z", 159 | }, 160 | { 161 | id: "8", 162 | created_by_id: "ham", 163 | name: "eight", 164 | private: false, 165 | created_at: "2017-04-13T14:10:38Z", 166 | updated_at: "2017-04-13T14:10:38Z", 167 | }, 168 | { 169 | id: "9", 170 | created_by_id: "ham", 171 | name: "9ine", 172 | custom_data: { pre: "set", and: "updated" }, 173 | private: true, 174 | created_at: "2017-04-13T14:10:38Z", 175 | updated_at: "2017-04-13T14:10:38Z", 176 | }, 177 | ] 178 | 179 | const basicRooms = roomsData.map(d => parseBasicRoom(d)) 180 | const basicCursors = [] 181 | 182 | test("room removed", (t, currentUser, roomStore, cursorStore) => { 183 | const onRemovedFromRoom = room => { 184 | if (room.id != "2") { 185 | return 186 | } 187 | t.equal(room.createdByUserId, "ham") 188 | t.equal(room.name, "two") 189 | t.equal(room.isPrivate, false) 190 | t.end() 191 | } 192 | 193 | handleUserSubReconnection({ 194 | basicUser, 195 | basicRooms, 196 | basicCursors, 197 | currentUser, 198 | roomStore, 199 | cursorStore, 200 | hooks: { global: { onRemovedFromRoom } }, 201 | }) 202 | }) 203 | 204 | test("privacy changed", (t, currentUser, roomStore, cursorStore) => { 205 | const onRoomUpdated = room => { 206 | if (room.id != "3") { 207 | return 208 | } 209 | t.equal(room.createdByUserId, "ham") 210 | t.equal(room.name, "three") 211 | t.equal(room.isPrivate, true) 212 | t.end() 213 | } 214 | 215 | handleUserSubReconnection({ 216 | basicUser, 217 | basicRooms, 218 | basicCursors, 219 | currentUser, 220 | roomStore, 221 | cursorStore, 222 | hooks: { global: { onRoomUpdated } }, 223 | }) 224 | }) 225 | 226 | test("custom data added", (t, currentUser, roomStore, cursorStore) => { 227 | const onRoomUpdated = room => { 228 | if (room.id != "4") { 229 | return 230 | } 231 | t.equal(room.createdByUserId, "ham") 232 | t.equal(room.name, "four") 233 | t.equal(room.isPrivate, false) 234 | t.deepEqual(room.customData, { set: "now" }) 235 | t.end() 236 | } 237 | 238 | handleUserSubReconnection({ 239 | basicUser, 240 | basicRooms, 241 | basicCursors, 242 | currentUser, 243 | roomStore, 244 | cursorStore, 245 | hooks: { global: { onRoomUpdated } }, 246 | }) 247 | }) 248 | 249 | test("custom data updated", (t, currentUser, roomStore, cursorStore) => { 250 | const onRoomUpdated = room => { 251 | if (room.id != "7") { 252 | return 253 | } 254 | t.equal(room.createdByUserId, "ham") 255 | t.equal(room.name, "seven") 256 | t.equal(room.isPrivate, false) 257 | t.deepEqual(room.customData, { pre: "set", custom: "data", third: "field" }) 258 | t.end() 259 | } 260 | 261 | handleUserSubReconnection({ 262 | basicUser, 263 | basicRooms, 264 | basicCursors, 265 | currentUser, 266 | roomStore, 267 | cursorStore, 268 | hooks: { global: { onRoomUpdated } }, 269 | }) 270 | }) 271 | 272 | test("custom data removed", (t, currentUser, roomStore, cursorStore) => { 273 | const onRoomUpdated = room => { 274 | if (room.id != "8") { 275 | return 276 | } 277 | t.equal(room.createdByUserId, "ham") 278 | t.equal(room.name, "eight") 279 | t.equal(room.isPrivate, false) 280 | t.equal(room.customData, undefined) 281 | t.end() 282 | } 283 | 284 | handleUserSubReconnection({ 285 | basicUser, 286 | basicRooms, 287 | basicCursors, 288 | currentUser, 289 | roomStore, 290 | cursorStore, 291 | hooks: { global: { onRoomUpdated } }, 292 | }) 293 | }) 294 | 295 | test("name changed", (t, currentUser, roomStore, cursorStore) => { 296 | const onRoomUpdated = room => { 297 | if (room.id != "5") { 298 | return 299 | } 300 | t.equal(room.createdByUserId, "ham") 301 | t.equal(room.name, "5ive") 302 | t.equal(room.isPrivate, false) 303 | t.end() 304 | } 305 | 306 | handleUserSubReconnection({ 307 | basicUser, 308 | basicRooms, 309 | basicCursors, 310 | currentUser, 311 | roomStore, 312 | cursorStore, 313 | hooks: { global: { onRoomUpdated } }, 314 | }) 315 | }) 316 | 317 | test( 318 | "multiple field changes (only one event!)", 319 | (t, currentUser, roomStore, cursorStore) => { 320 | let called = false 321 | 322 | const onRoomUpdated = room => { 323 | if (room.id != "9") { 324 | return 325 | } 326 | t.equal(room.createdByUserId, "ham") 327 | t.equal(room.name, "9ine") 328 | t.equal(room.isPrivate, true) 329 | t.deepEqual(room.customData, { pre: "set", and: "updated" }) 330 | if (called) { 331 | t.end("onRoomUpdated called more than once") 332 | return 333 | } 334 | called = true 335 | setTimeout(() => t.end(), 100) 336 | } 337 | 338 | handleUserSubReconnection({ 339 | basicUser, 340 | basicRooms, 341 | basicCursors, 342 | currentUser, 343 | roomStore, 344 | cursorStore, 345 | hooks: { global: { onRoomUpdated } }, 346 | }) 347 | }, 348 | ) 349 | 350 | test("room added", (t, currentUser, roomStore, cursorStore) => { 351 | const onAddedToRoom = room => { 352 | if (room.id != "6") { 353 | return 354 | } 355 | t.equal(room.createdByUserId, "ham") 356 | t.equal(room.name, "size") 357 | t.equal(room.isPrivate, false) 358 | t.end() 359 | } 360 | 361 | handleUserSubReconnection({ 362 | basicUser, 363 | basicRooms, 364 | basicCursors, 365 | currentUser, 366 | roomStore, 367 | cursorStore, 368 | hooks: { global: { onAddedToRoom } }, 369 | }) 370 | }) 371 | 372 | test("final state of room store", (t, currentUser, roomStore, cursorStore) => { 373 | handleUserSubReconnection({ 374 | basicUser, 375 | basicRooms, 376 | basicCursors, 377 | currentUser, 378 | roomStore, 379 | cursorStore, 380 | hooks: { global: {} }, 381 | }) 382 | 383 | t.equal(basicRooms.length, Object.keys(roomStore.snapshot()).length) 384 | for (const basicRoom of basicRooms) { 385 | t.true(roomStore.getSync(basicRoom.id)) 386 | t.true(roomStore.getSync(basicRoom.id).eq(basicRoom)) 387 | } 388 | t.end() 389 | }) 390 | 391 | test("current user changes", (t, currentUser, roomStore, cursorStore) => { 392 | handleUserSubReconnection({ 393 | basicUser, 394 | basicRooms, 395 | basicCursors, 396 | currentUser, 397 | roomStore, 398 | cursorStore, 399 | hooks: { global: {} }, 400 | }) 401 | 402 | t.equal(currentUser.name, "Callum") 403 | t.equal(currentUser.avatarURL, "new-avatar-url") 404 | t.deepEqual(currentUser.customData, { baz: 42 }) 405 | t.equal(currentUser.createdAt, "new-created-at") 406 | t.equal(currentUser.updatedAt, "new-updated-at") 407 | t.end() 408 | }) 409 | --------------------------------------------------------------------------------