├── .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 | [](https://docs.pusher.com/chatkit/reference/javascript)
17 | [](http://twitter.com/Pusher)
18 | [](https://github.com/pusher/chatkit-client-js/blob/master/LICENSE.md)
19 | [](https://badge.fury.io/js/%40pusher%2Fchatkit-client)
20 | [](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 |
--------------------------------------------------------------------------------