├── .babelrc
├── .gitattributes
├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .jsbeautifyrc
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── src
├── access-mode.js
├── cbuffer.js
├── cbuffer.test.js
├── comm-error.js
├── config.js
├── connection.js
├── db.js
├── drafty.js
├── drafty.test.js
├── fnd-topic.js
├── large-file.js
├── me-topic.js
├── meta-builder.js
├── tinode.js
├── topic.js
├── utils.js
└── utils.test.js
├── umd
├── tinode.dev.js
├── tinode.dev.js.map
├── tinode.prod.js
└── tinode.prod.js.map
├── version.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env"],
3 | "plugins": ["@babel/plugin-proposal-numeric-separator"],
4 | "comments": false
5 | }
6 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | tinode.dev.js binary
2 | tinode.prod.js binary
3 | tinode.*.map binary
4 | package-lock.json binary
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve Tinode Javascript SDK
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | If you ant to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
11 | ---
12 |
13 | **Describe the bug**
14 | A clear and concise description of what the bug is.
15 |
16 | **To Reproduce**
17 | Steps to reproduce the behavior:
18 | 1. Go to '...'
19 | 2. Click on '....'
20 | 3. Scroll down to '....'
21 | 4. See error
22 |
23 | **Expected behavior**
24 | A clear and concise description of what you expected to happen.
25 |
26 | **Screenshots**
27 | If applicable, add screenshots to help explain your problem.
28 |
29 | **Environment (please complete the following information):**
30 | - OS: [e.g. iOS 13.3, Windows 10 Home]
31 | - Browser [e.g. Chrome, Safari]
32 | - SDK Version [e.g. 0.16.6]
33 |
34 | **Console log**
35 | Please attach or insert console log which shows the problem
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | If you ant to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
11 | ---
12 |
13 | **Is your feature request related to a problem? Please describe.**
14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
15 |
16 | **Describe the solution you'd like**
17 | A clear and concise description of what you want to happen.
18 |
19 | **Describe alternatives you've considered**
20 | A clear and concise description of any alternative solutions or features you've considered.
21 |
22 | **Additional context**
23 | Add any other context or screenshots about the feature request here.
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | version.json
4 |
--------------------------------------------------------------------------------
/.jsbeautifyrc:
--------------------------------------------------------------------------------
1 | {
2 | "indent_size": 2,
3 | "indent_char": " ",
4 | "indent_with_tabs": false,
5 | "eol": "\n",
6 | "end_with_newline": true,
7 | "indent_level": 0,
8 | "preserve_newlines": true,
9 | "max_preserve_newlines": 10,
10 | "space_in_paren": false,
11 | "space_in_empty_paren": false,
12 | "jslint_happy": false,
13 | "space_after_anon_function": false,
14 | "brace_style": "collapse",
15 | "unindent_chained_methods": false,
16 | "break_chained_methods": false,
17 | "keep_array_indentation": false,
18 | "unescape_strings": false,
19 | "wrap_line_length": 0,
20 | "e4x": false,
21 | "comma_first": false,
22 | "operator_position": "before-newline"
23 | }
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Javascript bindings for Tinode
2 |
3 | This SDK implements [Tinode](https://github.com/tinode/chat) client-side protocol for the browser based applications. See it in action
4 | at https://web.tinode.co/ and https://sandbox.tinode.co/ ([full source](https://github.com/tinode/webapp)).
5 |
6 | This is **not** a standalone project. It can only be used in conjunction with the [Tinode server](https://github.com/tinode/chat).
7 |
8 | Regularly released NPM packages are at https://www.npmjs.com/package/tinode-sdk
9 |
10 | You may include the latest standalone minified SDK into your html file as
11 | ```html
12 |
15 | ```
16 | or while developing as
17 | ```html
18 |
21 | ```
22 |
23 | ## Getting support
24 |
25 | * Read [client-side](http://tinode.github.io/js-api/) and [server-side](https://github.com/tinode/chat/blob/master/docs/API.md) API documentation.
26 | * For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
27 | * For bugs and feature requests [open an issue](https://github.com/tinode/tinode-js/issues/new).
28 | * Use https://tinode.co/contact for commercial inquiries.
29 |
30 | ## Helping out
31 |
32 | * If you appreciate our work, please help spread the word! Sharing on Reddit, HN, and other communities helps more than you think.
33 | * Consider buying paid support: https://tinode.co/support.html
34 | * If you are a software developer, send us your pull requests with bug fixes and new features.
35 | * If you use the SDK and discover bugs or missing features, let us know by filing bug reports and feature requests. Vote for existing [feature requests](https://github.com/tinode/chat/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22feature+request%22) you find most valuable.
36 |
37 | ## Node JS compatibility
38 |
39 | This SDK is intended to be used in a browser. To use `tinode-sdk` in Node JS environment (such as on a server), you have to polyfill network providers, for example with [ws](https://www.npmjs.com/package/ws) and [xmlhttprequest](https://www.npmjs.com/package/xmlhttprequest) or [xhr](https://www.npmjs.com/package/xhr), as well as `indexedDB` with something like [fake-indexeddb](https://www.npmjs.com/package/fake-indexeddb):
40 |
41 | ```js
42 | Tinode.setNetworkProviders(require('ws'), require('xmlhttprequest'));
43 | Tinode.setDatabaseProvider(require('fake-indexeddb'));
44 | this.tinode = new Tinode(...);
45 | ```
46 |
47 | `URL.createObjectURL()` and related methods were added in Node v16.7.0. The SDK is unlikely to work correctly with earlier versions of Node.
48 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tinode-sdk",
3 | "description": "Tinode SDK",
4 | "version": "0.24.0",
5 | "scripts": {
6 | "format": "js-beautify -r src/*.js",
7 | "build": "npm run format && npm run vers && npm run build:prod && npm run build:dev",
8 | "clean": "rm umd/*",
9 | "build:dev": "webpack --mode development",
10 | "build:prod": "webpack --mode production",
11 | "build:docs": "jsdoc ./src -t ./node_modules/minami -d ../tinode.github.io/js-api",
12 | "vers": "echo \"export const PACKAGE_VERSION = \\\"`node -p -e \"require('./package.json').version\"`\\\";\" > version.js",
13 | "test": "jest"
14 | },
15 | "browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/tinode/tinode-js.git"
19 | },
20 | "files": [
21 | "umd/tinode.dev.js",
22 | "umd/tinode.dev.js.map",
23 | "umd/tinode.prod.js",
24 | "umd/tinode.prod.js.map",
25 | "version.json"
26 | ],
27 | "keywords": [
28 | "instant messenger",
29 | "messenger",
30 | "chat"
31 | ],
32 | "email": "info@tinode.co",
33 | "author": "Tinode Authors ",
34 | "license": "Apache-2.0",
35 | "bugs": {
36 | "url": "https://github.com/tinode/tinode-js/issues"
37 | },
38 | "homepage": "https://github.com/tinode/chat",
39 | "main": "./umd/tinode.prod.js",
40 | "devDependencies": {
41 | "@babel/core": "^7.22.8",
42 | "@babel/plugin-proposal-numeric-separator": "^7.18.6",
43 | "@babel/preset-env": "^7.22.7",
44 | "babel-loader": "^9.1.3",
45 | "browserslist": "^4.21.9",
46 | "copy-webpack-plugin": "^12.0.0",
47 | "jest": "^29.6.1",
48 | "js-beautify": "^1.14.8",
49 | "jsdoc": "^4.0.2",
50 | "minami": "^1.2.3",
51 | "webpack": "^5.88.1",
52 | "webpack-cli": "^6.0.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/access-mode.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Access control model.
3 | *
4 | * @copyright 2015-2022 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | // NOTE TO DEVELOPERS:
9 | // Localizable strings should be double quoted "строка на другом языке",
10 | // non-localizable strings should be single quoted 'non-localized'.
11 |
12 | /**
13 | * Helper class for handling access mode.
14 | *
15 | * @class AccessMode
16 | * @memberof Tinode
17 | *
18 | * @param {AccessMode|Object=} acs - AccessMode to copy or access mode object received from the server.
19 | */
20 | export default class AccessMode {
21 | constructor(acs) {
22 | if (acs) {
23 | this.given = typeof acs.given == 'number' ? acs.given : AccessMode.decode(acs.given);
24 | this.want = typeof acs.want == 'number' ? acs.want : AccessMode.decode(acs.want);
25 | this.mode = acs.mode ? (typeof acs.mode == 'number' ? acs.mode : AccessMode.decode(acs.mode)) :
26 | (this.given & this.want);
27 | }
28 | }
29 |
30 | static #checkFlag(val, side, flag) {
31 | side = side || 'mode';
32 | if (['given', 'want', 'mode'].includes(side)) {
33 | return ((val[side] & flag) != 0);
34 | }
35 | throw new Error(`Invalid AccessMode component '${side}'`);
36 | }
37 | /**
38 | * Parse string into an access mode value.
39 | * @memberof Tinode.AccessMode
40 | * @static
41 | *
42 | * @param {string | Number} mode - either a String representation of the access mode to parse or a set of bits to assign.
43 | * @returns {number} - Access mode as a numeric value.
44 | */
45 | static decode(str) {
46 | if (!str) {
47 | return null;
48 | } else if (typeof str == 'number') {
49 | return str & AccessMode._BITMASK;
50 | } else if (str === 'N' || str === 'n') {
51 | return AccessMode._NONE;
52 | }
53 |
54 | const bitmask = {
55 | 'J': AccessMode._JOIN,
56 | 'R': AccessMode._READ,
57 | 'W': AccessMode._WRITE,
58 | 'P': AccessMode._PRES,
59 | 'A': AccessMode._APPROVE,
60 | 'S': AccessMode._SHARE,
61 | 'D': AccessMode._DELETE,
62 | 'O': AccessMode._OWNER
63 | };
64 |
65 | let m0 = AccessMode._NONE;
66 |
67 | for (let i = 0; i < str.length; i++) {
68 | const bit = bitmask[str.charAt(i).toUpperCase()];
69 | if (!bit) {
70 | // Unrecognized bit, skip.
71 | continue;
72 | }
73 | m0 |= bit;
74 | }
75 | return m0;
76 | }
77 | /**
78 | * Convert numeric representation of the access mode into a string.
79 | *
80 | * @memberof Tinode.AccessMode
81 | * @static
82 | *
83 | * @param {number} val - access mode value to convert to a string.
84 | * @returns {string} - Access mode as a string.
85 | */
86 | static encode(val) {
87 | if (val === null || val === AccessMode._INVALID) {
88 | return null;
89 | } else if (val === AccessMode._NONE) {
90 | return 'N';
91 | }
92 |
93 | const bitmask = ['J', 'R', 'W', 'P', 'A', 'S', 'D', 'O'];
94 | let res = '';
95 | for (let i = 0; i < bitmask.length; i++) {
96 | if ((val & (1 << i)) != 0) {
97 | res = res + bitmask[i];
98 | }
99 | }
100 | return res;
101 | }
102 | /**
103 | * Update numeric representation of access mode with the new value. The value
104 | * is one of the following:
105 | * - a string starting with '+'
or '-'
then the bits to add or remove, e.g. '+R-W'
or '-PS'
.
106 | * - a new value of access mode
107 | *
108 | * @memberof Tinode.AccessMode
109 | * @static
110 | *
111 | * @param {number} val - access mode value to update.
112 | * @param {string} upd - update to apply to val.
113 | * @returns {number} - updated access mode.
114 | */
115 | static update(val, upd) {
116 | if (!upd || typeof upd != 'string') {
117 | return val;
118 | }
119 |
120 | let action = upd.charAt(0);
121 | if (action == '+' || action == '-') {
122 | let val0 = val;
123 | // Split delta-string like '+ABC-DEF+Z' into an array of parts including + and -.
124 | const parts = upd.split(/([-+])/);
125 | // Starting iteration from 1 because String.split() creates an array with the first empty element.
126 | // Iterating by 2 because we parse pairs +/- then data.
127 | for (let i = 1; i < parts.length - 1; i += 2) {
128 | action = parts[i];
129 | const m0 = AccessMode.decode(parts[i + 1]);
130 | if (m0 == AccessMode._INVALID) {
131 | return val;
132 | }
133 | if (m0 == null) {
134 | continue;
135 | }
136 | if (action === '+') {
137 | val0 |= m0;
138 | } else if (action === '-') {
139 | val0 &= ~m0;
140 | }
141 | }
142 | val = val0;
143 | } else {
144 | // The string is an explicit new value 'ABC' rather than delta.
145 | const val0 = AccessMode.decode(upd);
146 | if (val0 != AccessMode._INVALID) {
147 | val = val0;
148 | }
149 | }
150 |
151 | return val;
152 | }
153 | /**
154 | * Bits present in a1 but missing in a2.
155 | *
156 | * @static
157 | * @memberof Tinode
158 | *
159 | * @param {number | string} a1 - access mode to subtract from.
160 | * @param {number | string} a2 - access mode to subtract.
161 | * @returns {number} access mode with bits present in a1
but missing in a2
.
162 | */
163 | static diff(a1, a2) {
164 | a1 = AccessMode.decode(a1);
165 | a2 = AccessMode.decode(a2);
166 |
167 | if (a1 == AccessMode._INVALID || a2 == AccessMode._INVALID) {
168 | return AccessMode._INVALID;
169 | }
170 | return a1 & ~a2;
171 | }
172 | /**
173 | * AccessMode is a class representing topic access mode.
174 | *
175 | * @memberof Tinode
176 | * @class AccessMode
177 | */
178 | /**
179 | * Custom formatter
180 | */
181 | toString() {
182 | return '{"mode": "' + AccessMode.encode(this.mode) +
183 | '", "given": "' + AccessMode.encode(this.given) +
184 | '", "want": "' + AccessMode.encode(this.want) + '"}';
185 | }
186 | /**
187 | * AccessMode is a class representing topic access mode.
188 | *
189 | * @memberof Tinode
190 | * @class AccessMode
191 | */
192 | /**
193 | * Converts numeric values to strings.
194 | */
195 | jsonHelper() {
196 | return {
197 | mode: AccessMode.encode(this.mode),
198 | given: AccessMode.encode(this.given),
199 | want: AccessMode.encode(this.want)
200 | };
201 | }
202 | /**
203 | * AccessMode is a class representing topic access mode.
204 | *
205 | * @memberof Tinode
206 | * @class AccessMode
207 | */
208 | /**
209 | * Assign value to 'mode'.
210 | * @memberof Tinode.AccessMode
211 | *
212 | * @param {string | Number} m - either a string representation of the access mode or a set of bits.
213 | * @returns {AccessMode} - this
AccessMode.
214 | */
215 | setMode(m) {
216 | this.mode = AccessMode.decode(m);
217 | return this;
218 | }
219 | /**
220 | * AccessMode is a class representing topic access mode.
221 | *
222 | * @memberof Tinode
223 | * @class AccessMode
224 | */
225 | /**
226 | * Update mode
value.
227 | * @memberof Tinode.AccessMode
228 | *
229 | * @param {string} u - string representation of the changes to apply to access mode.
230 | * @returns {AccessMode} - this
AccessMode.
231 | */
232 | updateMode(u) {
233 | this.mode = AccessMode.update(this.mode, u);
234 | return this;
235 | }
236 | /**
237 | * AccessMode is a class representing topic access mode.
238 | *
239 | * @memberof Tinode
240 | * @class AccessMode
241 | */
242 | /**
243 | * Get mode
value as a string.
244 | * @memberof Tinode.AccessMode
245 | *
246 | * @returns {string} - mode
value.
247 | */
248 | getMode() {
249 | return AccessMode.encode(this.mode);
250 | }
251 | /**
252 | * AccessMode is a class representing topic access mode.
253 | *
254 | * @memberof Tinode
255 | * @class AccessMode
256 | */
257 | /**
258 | * Assign given
value.
259 | * @memberof Tinode.AccessMode
260 | *
261 | * @param {string | Number} g - either a string representation of the access mode or a set of bits.
262 | * @returns {AccessMode} - this
AccessMode.
263 | */
264 | setGiven(g) {
265 | this.given = AccessMode.decode(g);
266 | return this;
267 | }
268 | /**
269 | * AccessMode is a class representing topic access mode.
270 | *
271 | * @memberof Tinode
272 | * @class AccessMode
273 | */
274 | /**
275 | * Update 'given' value.
276 | * @memberof Tinode.AccessMode
277 | *
278 | * @param {string} u - string representation of the changes to apply to access mode.
279 | * @returns {AccessMode} - this
AccessMode.
280 | */
281 | updateGiven(u) {
282 | this.given = AccessMode.update(this.given, u);
283 | return this;
284 | }
285 | /**
286 | * AccessMode is a class representing topic access mode.
287 | *
288 | * @memberof Tinode
289 | * @class AccessMode
290 | */
291 | /**
292 | * Get 'given' value as a string.
293 | * @memberof Tinode.AccessMode
294 | *
295 | * @returns {string} - given value.
296 | */
297 | getGiven() {
298 | return AccessMode.encode(this.given);
299 | }
300 | /**
301 | * AccessMode is a class representing topic access mode.
302 | *
303 | * @memberof Tinode
304 | * @class AccessMode
305 | */
306 | /**
307 | * Assign 'want' value.
308 | * @memberof Tinode.AccessMode
309 | *
310 | * @param {string | Number} w - either a string representation of the access mode or a set of bits.
311 | * @returns {AccessMode} - this
AccessMode.
312 | */
313 | setWant(w) {
314 | this.want = AccessMode.decode(w);
315 | return this;
316 | }
317 | /**
318 | * AccessMode is a class representing topic access mode.
319 | *
320 | * @memberof Tinode
321 | * @class AccessMode
322 | */
323 | /**
324 | * Update 'want' value.
325 | * @memberof Tinode.AccessMode
326 | *
327 | * @param {string} u - string representation of the changes to apply to access mode.
328 | * @returns {AccessMode} - this
AccessMode.
329 | */
330 | updateWant(u) {
331 | this.want = AccessMode.update(this.want, u);
332 | return this;
333 | }
334 | /**
335 | * AccessMode is a class representing topic access mode.
336 | *
337 | * @memberof Tinode
338 | * @class AccessMode
339 | */
340 | /**
341 | * Get 'want' value as a string.
342 | * @memberof Tinode.AccessMode
343 | *
344 | * @returns {string} - want value.
345 | */
346 | getWant() {
347 | return AccessMode.encode(this.want);
348 | }
349 | /**
350 | * AccessMode is a class representing topic access mode.
351 | *
352 | * @memberof Tinode
353 | * @class AccessMode
354 | */
355 | /**
356 | * Get permissions present in 'want' but missing in 'given'.
357 | * Inverse of {@link Tinode.AccessMode#getExcessive}
358 | *
359 | * @memberof Tinode.AccessMode
360 | *
361 | * @returns {string} permissions present in want but missing in given.
362 | */
363 | getMissing() {
364 | return AccessMode.encode(this.want & ~this.given);
365 | }
366 | /**
367 | * AccessMode is a class representing topic access mode.
368 | *
369 | * @memberof Tinode
370 | * @class AccessMode
371 | */
372 | /**
373 | * Get permissions present in 'given' but missing in 'want'.
374 | * Inverse of {@link Tinode.AccessMode#getMissing}
375 | * @memberof Tinode.AccessMode
376 | *
377 | * @returns {string} permissions present in given but missing in want.
378 | */
379 | getExcessive() {
380 | return AccessMode.encode(this.given & ~this.want);
381 | }
382 | /**
383 | * AccessMode is a class representing topic access mode.
384 | *
385 | * @memberof Tinode
386 | * @class AccessMode
387 | */
388 | /**
389 | * Update 'want', 'give', and 'mode' values.
390 | * @memberof Tinode.AccessMode
391 | *
392 | * @param {AccessMode} val - new access mode value.
393 | * @returns {AccessMode} - this
AccessMode.
394 | */
395 | updateAll(val) {
396 | if (val) {
397 | this.updateGiven(val.given);
398 | this.updateWant(val.want);
399 | this.mode = this.given & this.want;
400 | }
401 | return this;
402 | }
403 | /**
404 | * AccessMode is a class representing topic access mode.
405 | *
406 | * @memberof Tinode
407 | * @class AccessMode
408 | */
409 | /**
410 | * Check if Owner (O) flag is set.
411 | * @memberof Tinode.AccessMode
412 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
413 | * @returns {boolean} - true
if flag is set.
414 | */
415 | isOwner(side) {
416 | return AccessMode.#checkFlag(this, side, AccessMode._OWNER);
417 | }
418 | /**
419 | * AccessMode is a class representing topic access mode.
420 | *
421 | * @memberof Tinode
422 | * @class AccessMode
423 | */
424 | /**
425 | * Check if Presence (P) flag is set.
426 | * @memberof Tinode.AccessMode
427 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
428 | * @returns {boolean} - true
if flag is set.
429 | */
430 | isPresencer(side) {
431 | return AccessMode.#checkFlag(this, side, AccessMode._PRES);
432 | }
433 | /**
434 | * AccessMode is a class representing topic access mode.
435 | *
436 | * @memberof Tinode
437 | * @class AccessMode
438 | */
439 | /**
440 | * Check if Presence (P) flag is NOT set.
441 | * @memberof Tinode.AccessMode
442 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
443 | * @returns {boolean} - true
if flag is set.
444 | */
445 | isMuted(side) {
446 | return !this.isPresencer(side);
447 | }
448 | /**
449 | * AccessMode is a class representing topic access mode.
450 | *
451 | * @memberof Tinode
452 | * @class AccessMode
453 | */
454 | /**
455 | * Check if Join (J) flag is set.
456 | * @memberof Tinode.AccessMode
457 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
458 | * @returns {boolean} - true
if flag is set.
459 | */
460 | isJoiner(side) {
461 | return AccessMode.#checkFlag(this, side, AccessMode._JOIN);
462 | }
463 | /**
464 | * AccessMode is a class representing topic access mode.
465 | *
466 | * @memberof Tinode
467 | * @class AccessMode
468 | */
469 | /**
470 | * Check if Reader (R) flag is set.
471 | * @memberof Tinode.AccessMode
472 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
473 | * @returns {boolean} - true
if flag is set.
474 | */
475 | isReader(side) {
476 | return AccessMode.#checkFlag(this, side, AccessMode._READ);
477 | }
478 | /**
479 | * AccessMode is a class representing topic access mode.
480 | *
481 | * @memberof Tinode
482 | * @class AccessMode
483 | */
484 | /**
485 | * Check if Writer (W) flag is set.
486 | * @memberof Tinode.AccessMode
487 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
488 | * @returns {boolean} - true
if flag is set.
489 | */
490 | isWriter(side) {
491 | return AccessMode.#checkFlag(this, side, AccessMode._WRITE);
492 | }
493 | /**
494 | * AccessMode is a class representing topic access mode.
495 | *
496 | * @memberof Tinode
497 | * @class AccessMode
498 | */
499 | /**
500 | * Check if Approver (A) flag is set.
501 | * @memberof Tinode.AccessMode
502 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
503 | * @returns {boolean} - true
if flag is set.
504 | */
505 | isApprover(side) {
506 | return AccessMode.#checkFlag(this, side, AccessMode._APPROVE);
507 | }
508 | /**
509 | * AccessMode is a class representing topic access mode.
510 | *
511 | * @memberof Tinode
512 | * @class AccessMode
513 | */
514 | /**
515 | * Check if either one of Owner (O) or Approver (A) flags is set.
516 | * @memberof Tinode.AccessMode
517 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
518 | * @returns {boolean} - true
if flag is set.
519 | */
520 | isAdmin(side) {
521 | return this.isOwner(side) || this.isApprover(side);
522 | }
523 | /**
524 | * AccessMode is a class representing topic access mode.
525 | *
526 | * @memberof Tinode
527 | * @class AccessMode
528 | */
529 | /**
530 | * Check if either one of Owner (O), Approver (A), or Sharer (S) flags is set.
531 | * @memberof Tinode.AccessMode
532 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
533 | * @returns {boolean} - true
if flag is set.
534 | */
535 | isSharer(side) {
536 | return this.isAdmin(side) || AccessMode.#checkFlag(this, side, AccessMode._SHARE);
537 | }
538 | /**
539 | * AccessMode is a class representing topic access mode.
540 | *
541 | * @memberof Tinode
542 | * @class AccessMode
543 | */
544 | /**
545 | * Check if Deleter (D) flag is set.
546 | * @memberof Tinode.AccessMode
547 | * @param {string=} side - which permission to check: given, want, mode; default: mode.
548 | * @returns {boolean} - true
if flag is set.
549 | */
550 | isDeleter(side) {
551 | return AccessMode.#checkFlag(this, side, AccessMode._DELETE);
552 | }
553 | }
554 |
555 | AccessMode._NONE = 0x00;
556 | AccessMode._JOIN = 0x01;
557 | AccessMode._READ = 0x02;
558 | AccessMode._WRITE = 0x04;
559 | AccessMode._PRES = 0x08;
560 | AccessMode._APPROVE = 0x10;
561 | AccessMode._SHARE = 0x20;
562 | AccessMode._DELETE = 0x40;
563 | AccessMode._OWNER = 0x80;
564 |
565 | AccessMode._BITMASK = AccessMode._JOIN | AccessMode._READ | AccessMode._WRITE | AccessMode._PRES |
566 | AccessMode._APPROVE | AccessMode._SHARE | AccessMode._DELETE | AccessMode._OWNER;
567 | AccessMode._INVALID = 0x100000;
568 |
--------------------------------------------------------------------------------
/src/cbuffer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file In-memory sorted cache of objects.
3 | *
4 | * @copyright 2015-2025 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | /**
9 | * In-memory sorted cache of objects.
10 | *
11 | * @class CBuffer
12 | * @memberof Tinode
13 | * @protected
14 | *
15 | * @param {function} compare custom comparator of objects. Takes two parameters a
and b
;
16 | * returns -1
if a < b
, 0
if a == b
, 1
otherwise.
17 | * @param {boolean} unique enforce element uniqueness: when true
replace existing element with a new
18 | * one on conflict; when false
keep both elements.
19 | */
20 | export default class CBuffer {
21 | #comparator = undefined;
22 | #unique = false;
23 | buffer = [];
24 |
25 | constructor(compare_, unique_) {
26 | this.#comparator = compare_ || ((a, b) => {
27 | return a === b ? 0 : a < b ? -1 : 1;
28 | });
29 | this.#unique = unique_;
30 | }
31 |
32 | #findNearest(elem, arr, exact) {
33 | let start = 0;
34 | let end = arr.length - 1;
35 | let pivot = 0;
36 | let diff = 0;
37 | let found = false;
38 |
39 | while (start <= end) {
40 | pivot = (start + end) / 2 | 0;
41 | diff = this.#comparator(arr[pivot], elem);
42 | if (diff < 0) {
43 | start = pivot + 1;
44 | } else if (diff > 0) {
45 | end = pivot - 1;
46 | } else {
47 | found = true;
48 | break;
49 | }
50 | }
51 | if (found) {
52 | return {
53 | idx: pivot,
54 | exact: true
55 | };
56 | }
57 | if (exact) {
58 | return {
59 | idx: -1
60 | };
61 | }
62 | // Not exact - insertion point
63 | return {
64 | idx: diff < 0 ? pivot + 1 : pivot
65 | };
66 | }
67 |
68 | // Insert element into a sorted array.
69 | #insertSorted(elem, arr) {
70 | const found = this.#findNearest(elem, arr, false);
71 | const count = (found.exact && this.#unique) ? 1 : 0;
72 | arr.splice(found.idx, count, elem);
73 | return arr;
74 | }
75 |
76 | /**
77 | * Get an element at the given position.
78 | * @memberof Tinode.CBuffer#
79 | * @param {number} at - Position to fetch from.
80 | * @returns {Object} Element at the given position or undefined
.
81 | */
82 | getAt(at) {
83 | return this.buffer[at];
84 | }
85 |
86 | /**
87 | * Convenience method for getting the last element from the buffer.
88 | * @memberof Tinode.CBuffer#
89 | * @param {function} filter - optional filter to apply to elements. If filter is provided, the search
90 | * for the last element starts from the end of the buffer and goes backwards until the filter returns true.
91 | * @returns {Object} The last element in the buffer or undefined
if buffer is empty.
92 | */
93 | getLast(filter) {
94 | return filter ?
95 | this.buffer.findLast(filter) :
96 | this.buffer[this.buffer.length - 1];
97 | }
98 |
99 | /**
100 | * Insert new element(s) to the buffer at the correct position according to the sort method.
101 | * Variadic: takes one or more arguments. If an array is passed as a single argument, its
102 | * elements are inserted individually.
103 | * @memberof Tinode.CBuffer#
104 | *
105 | * @param {...Object|Array} - One or more objects to insert.
106 | */
107 | put() {
108 | let insert;
109 | // inspect arguments: if array, insert its elements, if one or more non-array arguments, insert them one by one
110 | if (arguments.length == 1 && Array.isArray(arguments[0])) {
111 | insert = arguments[0];
112 | } else {
113 | insert = arguments;
114 | }
115 | for (let idx in insert) {
116 | this.#insertSorted(insert[idx], this.buffer);
117 | }
118 | }
119 |
120 | /**
121 | * Remove element at the given position.
122 | * @memberof Tinode.CBuffer#
123 | * @param {number} at - Position to delete at.
124 | * @returns {Object} Element at the given position or undefined
.
125 | */
126 | delAt(at) {
127 | at |= 0;
128 | let r = this.buffer.splice(at, 1);
129 | if (r && r.length > 0) {
130 | return r[0];
131 | }
132 | return undefined;
133 | }
134 |
135 | /**
136 | * Remove elements between two positions.
137 | * @memberof Tinode.CBuffer#
138 | * @param {number} since - Position to delete from (inclusive).
139 | * @param {number} before - Position to delete to (exclusive).
140 | *
141 | * @returns {Array} array of removed elements (could be zero length).
142 | */
143 | delRange(since, before) {
144 | return this.buffer.splice(since, before - since);
145 | }
146 |
147 | /**
148 | * Return the number of elements the buffer holds.
149 | * @memberof Tinode.CBuffer#
150 | * @return {number} Number of elements in the buffer.
151 | */
152 | length() {
153 | return this.buffer.length;
154 | }
155 |
156 | /**
157 | * Reset the buffer discarding all elements
158 | * @memberof Tinode.CBuffer#
159 | */
160 | reset() {
161 | this.buffer = [];
162 | }
163 |
164 | /**
165 | * Callback for iterating contents of buffer. See {@link Tinode.CBuffer#forEach}.
166 | * @callback ForEachCallbackType
167 | * @memberof Tinode.CBuffer#
168 | * @param {Object} elem - Current element of the buffer.
169 | * @param {Object} prev - Previous element of the buffer.
170 | * @param {Object} next - Next element of the buffer.
171 | * @param {number} index - Index of the current element.
172 | */
173 |
174 | /**
175 | * Apply given callback
to all elements of the buffer.
176 | * @memberof Tinode.CBuffer#
177 | *
178 | * @param {Tinode.ForEachCallbackType} callback - Function to call for each element.
179 | * @param {number} startIdx - Optional index to start iterating from (inclusive), default: 0.
180 | * @param {number} beforeIdx - Optional index to stop iterating before (exclusive), default: length of the buffer.
181 | * @param {Object} context - calling context (i.e. value of this
in callback)
182 | */
183 | forEach(callback, startIdx, beforeIdx, context) {
184 | startIdx = Math.max(0, startIdx | 0);
185 | beforeIdx = Math.min(beforeIdx || this.buffer.length, this.buffer.length);
186 |
187 | for (let i = startIdx; i < beforeIdx; i++) {
188 | callback.call(context, this.buffer[i],
189 | (i > startIdx ? this.buffer[i - 1] : undefined),
190 | (i < beforeIdx - 1 ? this.buffer[i + 1] : undefined), i);
191 | }
192 | }
193 |
194 | /**
195 | * Find element in buffer using buffer's comparison function.
196 | * @memberof Tinode.CBuffer#
197 | *
198 | * @param {Object} elem - element to find.
199 | * @param {boolean=} nearest - when true and exact match is not found, return the nearest element (insertion point).
200 | * @returns {number} index of the element in the buffer or -1.
201 | */
202 | find(elem, nearest) {
203 | const {
204 | idx
205 | } = this.#findNearest(elem, this.buffer, !nearest);
206 | return idx;
207 | }
208 |
209 | /**
210 | * Callback for filtering the buffer. See {@link Tinode.CBuffer#filter}.
211 | * @callback FilterCallbackType
212 | * @memberof Tinode.CBuffer#
213 | * @param {Object} elem - Current element of the buffer.
214 | * @param {number} index - Index of the current element.
215 | * @returns {boolen} true
to keep the element, false
to remove.
216 | */
217 |
218 | /**
219 | * Remove all elements that do not pass the test implemented by the provided callback function.
220 | * @memberof Tinode.CBuffer#
221 | *
222 | * @param {Tinode.FilterCallbackType} callback - Function to call for each element.
223 | * @param {Object} context - calling context (i.e. value of this
in the callback)
224 | */
225 | filter(callback, context) {
226 | let count = 0;
227 | for (let i = 0; i < this.buffer.length; i++) {
228 | if (callback.call(context, this.buffer[i], i)) {
229 | this.buffer[count] = this.buffer[i];
230 | count++;
231 | }
232 | }
233 |
234 | this.buffer.splice(count);
235 | }
236 |
237 | /**
238 | * Check if buffer is empty.
239 | * @returns {boolean} true
if the buffer is empty, false
otherwise.
240 | */
241 | isEmpty() {
242 | return this.buffer.length == 0;
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/src/cbuffer.test.js:
--------------------------------------------------------------------------------
1 | import CBuffer from './cbuffer';
2 |
3 | describe('CBuffer', () => {
4 | let buffer;
5 |
6 | beforeEach(() => {
7 | buffer = new CBuffer((a, b) => a - b, true);
8 | });
9 |
10 | test('should insert elements in sorted order', () => {
11 | buffer.put(3, 1, 2);
12 | expect(buffer.buffer).toEqual([1, 2, 3]);
13 | });
14 |
15 | test('should insert array elements in sorted order', () => {
16 | buffer.put([3, 1, 2]);
17 | expect(buffer.buffer).toEqual([1, 2, 3]);
18 | });
19 |
20 | test('should get element at given position', () => {
21 | buffer.put(1, 2, 3);
22 | expect(buffer.getAt(1)).toBe(2);
23 | });
24 |
25 | test('should get the last element', () => {
26 | buffer.put(1, 2, 3);
27 | expect(buffer.getLast()).toBe(3);
28 | });
29 |
30 | test('should get the last element with filter', () => {
31 | buffer.put(1, 2, 3);
32 | expect(buffer.getLast(x => x < 3)).toBe(2);
33 | });
34 |
35 | test('should delete element at given position', () => {
36 | buffer.put(1, 2, 3);
37 | expect(buffer.delAt(1)).toBe(2);
38 | expect(buffer.buffer).toEqual([1, 3]);
39 | });
40 |
41 | test('should delete elements in range', () => {
42 | buffer.put(1, 2, 3, 4, 5);
43 | expect(buffer.delRange(1, 4)).toEqual([2, 3, 4]);
44 | expect(buffer.buffer).toEqual([1, 5]);
45 | });
46 |
47 | test('should return the length of the buffer', () => {
48 | buffer.put(1, 2, 3);
49 | expect(buffer.length()).toBe(3);
50 | });
51 |
52 | test('should reset the buffer', () => {
53 | buffer.put(1, 2, 3);
54 | buffer.reset();
55 | expect(buffer.buffer).toEqual([]);
56 | });
57 |
58 | test('should iterate over elements with forEach', () => {
59 | buffer.put(1, 2, 3);
60 | const result = [];
61 | buffer.forEach((elem, prev, next, index) => {
62 | result.push({
63 | elem,
64 | prev,
65 | next,
66 | index
67 | });
68 | });
69 | expect(result).toEqual([{
70 | elem: 1,
71 | prev: undefined,
72 | next: 2,
73 | index: 0
74 | },
75 | {
76 | elem: 2,
77 | prev: 1,
78 | next: 3,
79 | index: 1
80 | },
81 | {
82 | elem: 3,
83 | prev: 2,
84 | next: undefined,
85 | index: 2
86 | },
87 | ]);
88 | });
89 |
90 | test('should find element in buffer', () => {
91 | buffer.put(1, 2, 3);
92 | expect(buffer.find(2)).toBe(1);
93 | expect(buffer.find(4)).toBe(-1);
94 | });
95 |
96 | test('should filter elements in buffer', () => {
97 | buffer.put(1, 2, 3, 4, 5);
98 | buffer.filter(x => x % 2 === 0);
99 | expect(buffer.buffer).toEqual([2, 4]);
100 | });
101 |
102 | test('should check if buffer is empty', () => {
103 | expect(buffer.isEmpty()).toBe(true);
104 | buffer.put(1);
105 | expect(buffer.isEmpty()).toBe(false);
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/src/comm-error.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Throwable error with numeric error code.
3 | *
4 | * @copyright 2015-2023 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | export default class CommError extends Error {
9 | constructor(message, code) {
10 | super(`${message} (${code})`);
11 | this.name = 'CommError';
12 | this.code = code;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Global constants and configuration parameters.
3 | *
4 | * @copyright 2015-2025 Tinode LLC
5 | */
6 | 'use strict';
7 |
8 | import {
9 | PACKAGE_VERSION
10 | } from '../version.js';
11 |
12 | // Global constants
13 | export const PROTOCOL_VERSION = '0'; // Major component of the version, e.g. '0' in '0.17.1'.
14 | export const VERSION = PACKAGE_VERSION || '0.24';
15 | export const LIBRARY = 'tinodejs/' + VERSION;
16 |
17 | // Topic name prefixes.
18 | export const TOPIC_NEW = 'new';
19 | export const TOPIC_NEW_CHAN = 'nch';
20 | export const TOPIC_ME = 'me';
21 | export const TOPIC_FND = 'fnd';
22 | export const TOPIC_SYS = 'sys';
23 | export const TOPIC_SLF = 'slf';
24 | export const TOPIC_CHAN = 'chn';
25 | export const TOPIC_GRP = 'grp';
26 | export const TOPIC_P2P = 'p2p';
27 | export const USER_NEW = 'new';
28 |
29 | // Starting value of a locally-generated seqId used for pending messages.
30 | export const LOCAL_SEQID = 0xFFFFFFF;
31 |
32 | // Status codes.
33 | export const MESSAGE_STATUS_NONE = 0; // Status not assigned.
34 | export const MESSAGE_STATUS_QUEUED = 10; // Local ID assigned, in progress to be sent.
35 | export const MESSAGE_STATUS_SENDING = 20; // Transmission started.
36 | export const MESSAGE_STATUS_FAILED = 30; // At least one attempt was made to send the message.
37 | export const MESSAGE_STATUS_FATAL = 40; // Message sending failed and it should not be retried.
38 | export const MESSAGE_STATUS_SENT = 50; // Delivered to the server.
39 | export const MESSAGE_STATUS_RECEIVED = 60; // Received by the client.
40 | export const MESSAGE_STATUS_READ = 70; // Read by the user.
41 | export const MESSAGE_STATUS_TO_ME = 80; // The message is received from another user.
42 |
43 | // Reject unresolved futures after this many milliseconds.
44 | export const EXPIRE_PROMISES_TIMEOUT = 5_000;
45 | // Periodicity of garbage collection of unresolved futures.
46 | export const EXPIRE_PROMISES_PERIOD = 1_000;
47 |
48 | // Delay before acknowledging that a message was recived.
49 | export const RECV_TIMEOUT = 100;
50 |
51 | // Default number of messages to pull into memory from persistent cache.
52 | export const DEFAULT_MESSAGES_PAGE = 24;
53 |
54 | // Unicode DEL character indicating data was deleted.
55 | export const DEL_CHAR = '\u2421';
56 |
57 | // Maximum number of pinnned messages;
58 | export const MAX_PINNED_COUNT = 5;
59 |
60 | // Tag prefixes for alias, email, phone.
61 | export const TAG_ALIAS = 'alias:';
62 | export const TAG_EMAIL = 'email:';
63 | export const TAG_PHONE = 'tel:';
64 |
--------------------------------------------------------------------------------
/src/connection.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Abstraction layer for websocket and long polling connections.
3 | *
4 | * @copyright 2015-2022 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | import CommError from './comm-error.js';
9 | import {
10 | jsonParseHelper
11 | } from './utils.js';
12 |
13 | let WebSocketProvider;
14 | let XHRProvider;
15 |
16 | // Error code to return in case of a network problem.
17 | const NETWORK_ERROR = 503;
18 | const NETWORK_ERROR_TEXT = "Connection failed";
19 |
20 | // Error code to return when user disconnected from server.
21 | const NETWORK_USER = 418;
22 | const NETWORK_USER_TEXT = "Disconnected by client";
23 |
24 | // Settings for exponential backoff
25 | const _BOFF_BASE = 2000; // 2000 milliseconds, minimum delay between reconnects
26 | const _BOFF_MAX_ITER = 10; // Maximum delay between reconnects 2^10 * 2000 ~ 34 minutes
27 | const _BOFF_JITTER = 0.3; // Add random delay
28 |
29 | // Helper function for creating an endpoint URL.
30 | function makeBaseUrl(host, protocol, version, apiKey) {
31 | let url = null;
32 |
33 | if (['http', 'https', 'ws', 'wss'].includes(protocol)) {
34 | url = `${protocol}://${host}`;
35 | if (url.charAt(url.length - 1) !== '/') {
36 | url += '/';
37 | }
38 | url += 'v' + version + '/channels';
39 | if (['http', 'https'].includes(protocol)) {
40 | // Long polling endpoint ends with "lp", i.e.
41 | // '/v0/channels/lp' vs just '/v0/channels' for ws
42 | url += '/lp';
43 | }
44 | url += '?apikey=' + apiKey;
45 | }
46 | return url;
47 | }
48 |
49 | /**
50 | * An abstraction for a websocket or a long polling connection.
51 | *
52 | * @class Connection
53 | * @memberof Tinode
54 |
55 | * @param {Object} config - configuration parameters.
56 | * @param {string} config.host - Host name and optional port number to connect to.
57 | * @param {string} config.apiKey - API key generated by keygen
.
58 | * @param {string} config.transport - Network transport to use, either "ws"/"wss"
for websocket or
59 | * lp
for long polling.
60 | * @param {boolean} config.secure - Use Secure WebSocket if true
.
61 | * @param {string} version_ - Major value of the protocol version, e.g. '0' in '0.17.1'.
62 | * @param {boolean} autoreconnect_ - If connection is lost, try to reconnect automatically.
63 | */
64 | export default class Connection {
65 | // Logger, does nothing by default.
66 | static #log = _ => {};
67 |
68 | #boffTimer = null;
69 | #boffIteration = 0;
70 | #boffClosed = false; // Indicator if the socket was manually closed - don't autoreconnect if true.
71 |
72 | // Websocket.
73 | #socket = null;
74 |
75 | host;
76 | secure;
77 | apiKey;
78 |
79 | version;
80 | autoreconnect;
81 |
82 | initialized;
83 |
84 | // (config.host, config.apiKey, config.transport, config.secure), PROTOCOL_VERSION, true
85 | constructor(config, version_, autoreconnect_) {
86 | this.host = config.host;
87 | this.secure = config.secure;
88 | this.apiKey = config.apiKey;
89 |
90 | this.version = version_;
91 | this.autoreconnect = autoreconnect_;
92 |
93 | if (config.transport === 'lp') {
94 | // explicit request to use long polling
95 | this.#init_lp();
96 | this.initialized = 'lp';
97 | } else if (config.transport === 'ws') {
98 | // explicit request to use web socket
99 | // if websockets are not available, horrible things will happen
100 | this.#init_ws();
101 | this.initialized = 'ws';
102 | }
103 |
104 | if (!this.initialized) {
105 | // Invalid or undefined network transport.
106 | Connection.#log("Unknown or invalid network transport. Running under Node? Call 'Tinode.setNetworkProviders()'.");
107 | throw new Error("Unknown or invalid network transport. Running under Node? Call 'Tinode.setNetworkProviders()'.");
108 | }
109 | }
110 |
111 | /**
112 | * To use Connection in a non browser context, supply WebSocket and XMLHttpRequest providers.
113 | * @static
114 | * @memberof Connection
115 | * @param wsProvider WebSocket provider, e.g. for nodeJS , require('ws')
.
116 | * @param xhrProvider XMLHttpRequest provider, e.g. for node require('xhr')
.
117 | */
118 | static setNetworkProviders(wsProvider, xhrProvider) {
119 | WebSocketProvider = wsProvider;
120 | XHRProvider = xhrProvider;
121 | }
122 |
123 | /**
124 | * Assign a non-default logger.
125 | * @static
126 | * @memberof Connection
127 | * @param {function} l variadic logging function.
128 | */
129 | static set logger(l) {
130 | Connection.#log = l;
131 | }
132 |
133 | /**
134 | * Initiate a new connection
135 | * @memberof Tinode.Connection#
136 | * @param {string} host_ Host name to connect to; if null
the old host name will be used.
137 | * @param {boolean} force Force new connection even if one already exists.
138 | * @return {Promise} Promise resolved/rejected when the connection call completes, resolution is called without
139 | * parameters, rejection passes the {Error} as parameter.
140 | */
141 | connect(host_, force) {
142 | return Promise.reject(null);
143 | }
144 |
145 | /**
146 | * Try to restore a network connection, also reset backoff.
147 | * @memberof Tinode.Connection#
148 | *
149 | * @param {boolean} force - reconnect even if there is a live connection already.
150 | */
151 | reconnect(force) {}
152 |
153 | /**
154 | * Terminate the network connection
155 | * @memberof Tinode.Connection#
156 | */
157 | disconnect() {}
158 |
159 | /**
160 | * Send a string to the server.
161 | * @memberof Tinode.Connection#
162 | *
163 | * @param {string} msg - String to send.
164 | * @throws Throws an exception if the underlying connection is not live.
165 | */
166 | sendText(msg) {}
167 |
168 | /**
169 | * Check if connection is alive.
170 | * @memberof Tinode.Connection#
171 | * @returns {boolean} true
if connection is live, false
otherwise.
172 | */
173 | isConnected() {
174 | return false;
175 | }
176 |
177 | /**
178 | * Get the name of the current network transport.
179 | * @memberof Tinode.Connection#
180 | * @returns {string} name of the transport such as "ws"
or "lp"
.
181 | */
182 | transport() {
183 | return this.initialized;
184 | }
185 |
186 | /**
187 | * Send network probe to check if connection is indeed live.
188 | * @memberof Tinode.Connection#
189 | */
190 | probe() {
191 | this.sendText('1');
192 | }
193 |
194 | /**
195 | * Reset autoreconnect counter to zero.
196 | * @memberof Tinode.Connection#
197 | */
198 | backoffReset() {
199 | this.#boffReset();
200 | }
201 |
202 | // Backoff implementation - reconnect after a timeout.
203 | #boffReconnect() {
204 | // Clear timer
205 | clearTimeout(this.#boffTimer);
206 | // Calculate when to fire the reconnect attempt
207 | const timeout = _BOFF_BASE * (Math.pow(2, this.#boffIteration) * (1.0 + _BOFF_JITTER * Math.random()));
208 | // Update iteration counter for future use
209 | this.#boffIteration = (this.#boffIteration >= _BOFF_MAX_ITER ? this.#boffIteration : this.#boffIteration + 1);
210 | if (this.onAutoreconnectIteration) {
211 | this.onAutoreconnectIteration(timeout);
212 | }
213 |
214 | this.#boffTimer = setTimeout(_ => {
215 | Connection.#log(`Reconnecting, iter=${this.#boffIteration}, timeout=${timeout}`);
216 | // Maybe the socket was closed while we waited for the timer?
217 | if (!this.#boffClosed) {
218 | const prom = this.connect();
219 | if (this.onAutoreconnectIteration) {
220 | this.onAutoreconnectIteration(0, prom);
221 | } else {
222 | // Suppress error if it's not used.
223 | prom.catch(_ => {
224 | /* do nothing */
225 | });
226 | }
227 | } else if (this.onAutoreconnectIteration) {
228 | this.onAutoreconnectIteration(-1);
229 | }
230 | }, timeout);
231 | }
232 |
233 | // Terminate auto-reconnect process.
234 | #boffStop() {
235 | clearTimeout(this.#boffTimer);
236 | this.#boffTimer = null;
237 | }
238 |
239 | // Reset auto-reconnect iteration counter.
240 | #boffReset() {
241 | this.#boffIteration = 0;
242 | }
243 |
244 | // Initialization for long polling.
245 | #init_lp() {
246 | const XDR_UNSENT = 0; // Client has been created. open() not called yet.
247 | const XDR_OPENED = 1; // open() has been called.
248 | const XDR_HEADERS_RECEIVED = 2; // send() has been called, and headers and status are available.
249 | const XDR_LOADING = 3; // Downloading; responseText holds partial data.
250 | const XDR_DONE = 4; // The operation is complete.
251 |
252 | // Fully composed endpoint URL, with API key & SID
253 | let _lpURL = null;
254 |
255 | let _poller = null;
256 | let _sender = null;
257 |
258 | let lp_sender = (url_) => {
259 | const sender = new XHRProvider();
260 | sender.onreadystatechange = (evt) => {
261 | if (sender.readyState == XDR_DONE && sender.status >= 400) {
262 | // Some sort of error response
263 | throw new CommError("LP sender failed", sender.status);
264 | }
265 | };
266 |
267 | sender.open('POST', url_, true);
268 | return sender;
269 | }
270 |
271 | let lp_poller = (url_, resolve, reject) => {
272 | let poller = new XHRProvider();
273 | let promiseCompleted = false;
274 |
275 | poller.onreadystatechange = evt => {
276 | if (poller.readyState == XDR_DONE) {
277 | if (poller.status == 201) { // 201 == HTTP.Created, get SID
278 | let pkt = JSON.parse(poller.responseText, jsonParseHelper);
279 | _lpURL = url_ + '&sid=' + pkt.ctrl.params.sid;
280 | poller = lp_poller(_lpURL);
281 | poller.send(null);
282 | if (this.onOpen) {
283 | this.onOpen();
284 | }
285 |
286 | if (resolve) {
287 | promiseCompleted = true;
288 | resolve();
289 | }
290 |
291 | if (this.autoreconnect) {
292 | this.#boffStop();
293 | }
294 | } else if (poller.status > 0 && poller.status < 400) { // 0 = network error; 400 = HTTP.BadRequest
295 | if (this.onMessage) {
296 | this.onMessage(poller.responseText);
297 | }
298 | poller = lp_poller(_lpURL);
299 | poller.send(null);
300 | } else {
301 | // Don't throw an error here, gracefully handle server errors
302 | if (reject && !promiseCompleted) {
303 | promiseCompleted = true;
304 | reject(poller.responseText);
305 | }
306 | if (this.onMessage && poller.responseText) {
307 | this.onMessage(poller.responseText);
308 | }
309 | if (this.onDisconnect) {
310 | const code = poller.status || (this.#boffClosed ? NETWORK_USER : NETWORK_ERROR);
311 | const text = poller.responseText || (this.#boffClosed ? NETWORK_USER_TEXT : NETWORK_ERROR_TEXT);
312 | this.onDisconnect(new CommError(text, code), code);
313 | }
314 |
315 | // Polling has stopped. Indicate it by setting poller to null.
316 | poller = null;
317 | if (!this.#boffClosed && this.autoreconnect) {
318 | this.#boffReconnect();
319 | }
320 | }
321 | }
322 | };
323 | // Using POST to avoid caching response by service worker.
324 | poller.open('POST', url_, true);
325 | return poller;
326 | }
327 |
328 | this.connect = (host_, force) => {
329 | this.#boffClosed = false;
330 |
331 | if (_poller) {
332 | if (!force) {
333 | return Promise.resolve();
334 | }
335 | _poller.onreadystatechange = undefined;
336 | _poller.abort();
337 | _poller = null;
338 | }
339 |
340 | if (host_) {
341 | this.host = host_;
342 | }
343 |
344 | return new Promise((resolve, reject) => {
345 | const url = makeBaseUrl(this.host, this.secure ? 'https' : 'http', this.version, this.apiKey);
346 | Connection.#log("LP connecting to:", url);
347 | _poller = lp_poller(url, resolve, reject);
348 | _poller.send(null);
349 | }).catch(err => {
350 | Connection.#log("LP connection failed:", err);
351 | });
352 | };
353 |
354 | this.reconnect = force => {
355 | this.#boffStop();
356 | this.connect(null, force);
357 | };
358 |
359 | this.disconnect = _ => {
360 | this.#boffClosed = true;
361 | this.#boffStop();
362 |
363 | if (_sender) {
364 | _sender.onreadystatechange = undefined;
365 | _sender.abort();
366 | _sender = null;
367 | }
368 | if (_poller) {
369 | _poller.onreadystatechange = undefined;
370 | _poller.abort();
371 | _poller = null;
372 | }
373 |
374 | if (this.onDisconnect) {
375 | this.onDisconnect(new CommError(NETWORK_USER_TEXT, NETWORK_USER), NETWORK_USER);
376 | }
377 | // Ensure it's reconstructed
378 | _lpURL = null;
379 | };
380 |
381 | this.sendText = (msg) => {
382 | _sender = lp_sender(_lpURL);
383 | if (_sender && (_sender.readyState == XDR_OPENED)) {
384 | _sender.send(msg);
385 | } else {
386 | throw new Error("Long poller failed to connect");
387 | }
388 | };
389 |
390 | this.isConnected = _ => {
391 | return (_poller && true);
392 | };
393 | }
394 |
395 | // Initialization for Websocket
396 | #init_ws() {
397 | this.connect = (host_, force) => {
398 | this.#boffClosed = false;
399 |
400 | if (this.#socket) {
401 | if (!force && this.#socket.readyState == this.#socket.OPEN) {
402 | // Issue a probe request to be sure the connection is live.
403 | // This is a non-blocking call.
404 | this.probe();
405 | return Promise.resolve();
406 | }
407 | this.#socket.close();
408 | this.#socket = null;
409 | }
410 |
411 | if (host_) {
412 | this.host = host_;
413 | }
414 |
415 | return new Promise((resolve, reject) => {
416 | const url = makeBaseUrl(this.host, this.secure ? 'wss' : 'ws', this.version, this.apiKey);
417 |
418 | Connection.#log("WS connecting to: ", url);
419 |
420 | // It throws when the server is not accessible but the exception cannot be caught:
421 | // https://stackoverflow.com/questions/31002592/javascript-doesnt-catch-error-in-websocket-instantiation/31003057
422 | const conn = new WebSocketProvider(url);
423 |
424 | conn.onerror = err => {
425 | reject(err);
426 | };
427 |
428 | conn.onopen = _ => {
429 | if (this.autoreconnect) {
430 | this.#boffStop();
431 | }
432 |
433 | if (this.onOpen) {
434 | this.onOpen();
435 | }
436 |
437 | resolve();
438 | };
439 |
440 | conn.onclose = _ => {
441 | this.#socket = null;
442 |
443 | if (this.onDisconnect) {
444 | const code = this.#boffClosed ? NETWORK_USER : NETWORK_ERROR;
445 | this.onDisconnect(new CommError(this.#boffClosed ? NETWORK_USER_TEXT : NETWORK_ERROR_TEXT, code), code);
446 | }
447 |
448 | if (!this.#boffClosed && this.autoreconnect) {
449 | this.#boffReconnect();
450 | }
451 | };
452 |
453 | conn.onmessage = evt => {
454 | if (this.onMessage) {
455 | this.onMessage(evt.data);
456 | }
457 | };
458 |
459 | this.#socket = conn;
460 | });
461 | }
462 |
463 | this.reconnect = force => {
464 | this.#boffStop();
465 | this.connect(null, force);
466 | };
467 |
468 | this.disconnect = _ => {
469 | this.#boffClosed = true;
470 | this.#boffStop();
471 |
472 | if (!this.#socket) {
473 | return;
474 | }
475 | this.#socket.close();
476 | this.#socket = null;
477 | };
478 |
479 | this.sendText = msg => {
480 | if (this.#socket && (this.#socket.readyState == this.#socket.OPEN)) {
481 | this.#socket.send(msg);
482 | } else {
483 | throw new Error("Websocket is not connected");
484 | }
485 | };
486 |
487 | this.isConnected = _ => {
488 | return (this.#socket && (this.#socket.readyState == this.#socket.OPEN));
489 | };
490 | }
491 |
492 | // Callbacks:
493 |
494 | /**
495 | * A callback to pass incoming messages to. See {@link Tinode.Connection#onMessage}.
496 | * @callback Tinode.Connection.OnMessage
497 | * @memberof Tinode.Connection
498 | * @param {string} message - Message to process.
499 | */
500 | onMessage = undefined;
501 |
502 | /**
503 | * A callback for reporting a dropped connection.
504 | * @type {function}
505 | * @memberof Tinode.Connection#
506 | */
507 | onDisconnect = undefined;
508 |
509 | /**
510 | * A callback called when the connection is ready to be used for sending. For websockets it's socket open,
511 | * for long polling it's readyState=1
(OPENED)
512 | * @type {function}
513 | * @memberof Tinode.Connection#
514 | */
515 | onOpen = undefined;
516 |
517 | /**
518 | * A callback to notify of reconnection attempts. See {@link Tinode.Connection#onAutoreconnectIteration}.
519 | * @memberof Tinode.Connection
520 | * @callback AutoreconnectIterationType
521 | * @param {string} timeout - time till the next reconnect attempt in milliseconds. -1
means reconnect was skipped.
522 | * @param {Promise} promise resolved or rejected when the reconnect attemp completes.
523 | *
524 | */
525 | /**
526 | * A callback to inform when the next attampt to reconnect will happen and to receive connection promise.
527 | * @memberof Tinode.Connection#
528 | * @type {Tinode.Connection.AutoreconnectIterationType}
529 | */
530 | onAutoreconnectIteration = undefined;
531 | }
532 |
533 | Connection.NETWORK_ERROR = NETWORK_ERROR;
534 | Connection.NETWORK_ERROR_TEXT = NETWORK_ERROR_TEXT;
535 | Connection.NETWORK_USER = NETWORK_USER;
536 | Connection.NETWORK_USER_TEXT = NETWORK_USER_TEXT;
537 |
--------------------------------------------------------------------------------
/src/db.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Helper methods for dealing with IndexedDB cache of messages, users, and topics.
3 | *
4 | * @copyright 2015-2025 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | // NOTE TO DEVELOPERS:
9 | // Localizable strings should be double quoted "строка на другом языке",
10 | // non-localizable strings should be single quoted 'non-localized'.
11 |
12 | const DB_VERSION = 3;
13 | const DB_NAME = 'tinode-web';
14 |
15 | let IDBProvider;
16 |
17 | export default class DB {
18 | #onError = _ => {};
19 | #logger = _ => {};
20 |
21 | // Instance of IndexDB.
22 | db = null;
23 | // Indicator that the cache is disabled.
24 | disabled = true;
25 |
26 | constructor(onError, logger) {
27 | this.#onError = onError || this.#onError;
28 | this.#logger = logger || this.#logger;
29 | }
30 |
31 | #mapObjects(source, callback, context) {
32 | if (!this.db) {
33 | return disabled ?
34 | Promise.resolve([]) :
35 | Promise.reject(new Error("not initialized"));
36 | }
37 |
38 | return new Promise((resolve, reject) => {
39 | const trx = this.db.transaction([source]);
40 | trx.onerror = event => {
41 | this.#logger('PCache', 'mapObjects', source, event.target.error);
42 | reject(event.target.error);
43 | };
44 | trx.objectStore(source).getAll().onsuccess = event => {
45 | if (callback) {
46 | event.target.result.forEach(topic => {
47 | callback.call(context, topic);
48 | });
49 | }
50 | resolve(event.target.result);
51 | };
52 | });
53 | }
54 |
55 | /**
56 | * Initialize persistent cache: open or create/upgrade if needed.
57 | * @returns {Promise} promise to be resolved/rejected when the DB is initialized.
58 | */
59 | initDatabase() {
60 | return new Promise((resolve, reject) => {
61 | // Open the database and initialize callbacks.
62 | const req = IDBProvider.open(DB_NAME, DB_VERSION);
63 | req.onsuccess = event => {
64 | this.db = event.target.result;
65 | this.disabled = false;
66 | resolve(this.db);
67 | };
68 | req.onerror = event => {
69 | this.#logger('PCache', "failed to initialize", event);
70 | reject(event.target.error);
71 | this.#onError(event.target.error);
72 | };
73 | req.onupgradeneeded = event => {
74 | this.db = event.target.result;
75 |
76 | this.db.onerror = event => {
77 | this.#logger('PCache', "failed to create storage", event);
78 | this.#onError(event.target.error);
79 | };
80 |
81 | // Individual object stores.
82 | // Object store (table) for topics. The primary key is topic name.
83 | this.db.createObjectStore('topic', {
84 | keyPath: 'name'
85 | });
86 |
87 | // Users object store. UID is the primary key.
88 | this.db.createObjectStore('user', {
89 | keyPath: 'uid'
90 | });
91 |
92 | // Subscriptions object store topic <-> user. Topic name + UID is the primary key.
93 | this.db.createObjectStore('subscription', {
94 | keyPath: ['topic', 'uid']
95 | });
96 |
97 | // Messages object store. The primary key is topic name + seq.
98 | this.db.createObjectStore('message', {
99 | keyPath: ['topic', 'seq']
100 | });
101 |
102 | // Records of deleted message ranges. The primary key is topic name + low seq.
103 | const dellog = this.db.createObjectStore('dellog', {
104 | keyPath: ['topic', 'low', 'hi']
105 | });
106 | dellog.createIndex('topic_clear', ['topic', 'clear'], {
107 | unique: false
108 | });
109 | };
110 | });
111 | }
112 |
113 | /**
114 | * Delete persistent cache.
115 | */
116 | deleteDatabase() {
117 | // Close connection, otherwise operations will fail with 'onblocked'.
118 | if (this.db) {
119 | this.db.close();
120 | this.db = null;
121 | }
122 | return new Promise((resolve, reject) => {
123 | const req = IDBProvider.deleteDatabase(DB_NAME);
124 | req.onblocked = _ => {
125 | if (this.db) {
126 | this.db.close();
127 | }
128 | const err = new Error("blocked");
129 | this.#logger('PCache', 'deleteDatabase', err);
130 | reject(err);
131 | };
132 | req.onsuccess = _ => {
133 | this.db = null;
134 | this.disabled = true;
135 | resolve(true);
136 | };
137 | req.onerror = event => {
138 | this.#logger('PCache', 'deleteDatabase', event.target.error);
139 | reject(event.target.error);
140 | };
141 | });
142 | }
143 |
144 | /**
145 | * Check if persistent cache is ready for use.
146 | * @memberOf DB
147 | * @returns {boolean} true
if cache is ready, false
otherwise.
148 | */
149 | isReady() {
150 | return !!this.db;
151 | }
152 |
153 | // Topics.
154 |
155 | /**
156 | * Save to cache or update topic in persistent cache.
157 | * @memberOf DB
158 | * @param {Topic} topic - topic to be added or updated.
159 | * @returns {Promise} promise resolved/rejected on operation completion.
160 | */
161 | updTopic(topic) {
162 | if (!this.isReady()) {
163 | return this.disabled ?
164 | Promise.resolve() :
165 | Promise.reject(new Error("not initialized"));
166 | }
167 | return new Promise((resolve, reject) => {
168 | const trx = this.db.transaction(['topic'], 'readwrite');
169 | trx.oncomplete = event => {
170 | resolve(event.target.result);
171 | };
172 | trx.onerror = event => {
173 | this.#logger('PCache', 'updTopic', event.target.error);
174 | reject(event.target.error);
175 | };
176 | const req = trx.objectStore('topic').get(topic.name);
177 | req.onsuccess = _ => {
178 | trx.objectStore('topic').put(DB.#serializeTopic(req.result, topic));
179 | trx.commit();
180 | };
181 | });
182 | }
183 |
184 | /**
185 | * Mark or unmark topic as deleted.
186 | * @memberOf DB
187 | * @param {string} name - name of the topic to mark or unmark.
188 | * @param {boolean} deleted - status
189 | * @return {Promise} promise resolved/rejected on operation completion.
190 | */
191 | markTopicAsDeleted(name, deleted) {
192 | if (!this.isReady()) {
193 | return this.disabled ?
194 | Promise.resolve() :
195 | Promise.reject(new Error("not initialized"));
196 | }
197 | return new Promise((resolve, reject) => {
198 | const trx = this.db.transaction(['topic'], 'readwrite');
199 | trx.oncomplete = event => {
200 | resolve(event.target.result);
201 | };
202 | trx.onerror = event => {
203 | this.#logger('PCache', 'markTopicAsDeleted', event.target.error);
204 | reject(event.target.error);
205 | };
206 | const req = trx.objectStore('topic').get(name);
207 | req.onsuccess = event => {
208 | const topic = event.target.result;
209 | if (topic && topic._deleted != deleted) {
210 | topic._deleted = deleted;
211 | trx.objectStore('topic').put(topic);
212 | }
213 | trx.commit();
214 | };
215 | });
216 | }
217 |
218 | /**
219 | * Remove topic from persistent cache.
220 | * @memberOf DB
221 | * @param {string} name - name of the topic to remove from database.
222 | * @return {Promise} promise resolved/rejected on operation completion.
223 | */
224 | remTopic(name) {
225 | if (!this.isReady()) {
226 | return this.disabled ?
227 | Promise.resolve() :
228 | Promise.reject(new Error("not initialized"));
229 | }
230 | return new Promise((resolve, reject) => {
231 | const trx = this.db.transaction(['topic', 'subscription', 'message'], 'readwrite');
232 | trx.oncomplete = event => {
233 | resolve(event.target.result);
234 | };
235 | trx.onerror = event => {
236 | this.#logger('PCache', 'remTopic', event.target.error);
237 | reject(event.target.error);
238 | };
239 | trx.objectStore('topic').delete(IDBKeyRange.only(name));
240 | trx.objectStore('subscription').delete(IDBKeyRange.bound([name, '-'], [name, '~']));
241 | trx.objectStore('message').delete(IDBKeyRange.bound([name, 0], [name, Number.MAX_SAFE_INTEGER]));
242 | trx.commit();
243 | });
244 | }
245 |
246 | /**
247 | * Execute a callback for each stored topic.
248 | * @memberOf DB
249 | * @param {function} callback - function to call for each topic.
250 | * @param {Object} context - the value or this
inside the callback.
251 | * @return {Promise} promise resolved/rejected on operation completion.
252 | */
253 | mapTopics(callback, context) {
254 | return this.#mapObjects('topic', callback, context);
255 | }
256 |
257 | /**
258 | * Copy data from serialized object to topic.
259 | * @memberOf DB
260 | * @param {Topic} topic - target to deserialize to.
261 | * @param {Object} src - serialized data to copy from.
262 | */
263 | deserializeTopic(topic, src) {
264 | DB.#deserializeTopic(topic, src);
265 | }
266 |
267 | // Users.
268 | /**
269 | * Add or update user object in the persistent cache.
270 | * @memberOf DB
271 | * @param {string} uid - ID of the user to save or update.
272 | * @param {Object} pub - user's public
information.
273 | * @returns {Promise} promise resolved/rejected on operation completion.
274 | */
275 | updUser(uid, pub) {
276 | if (arguments.length < 2 || pub === undefined) {
277 | // No point inupdating user with invalid data.
278 | return;
279 | }
280 | if (!this.isReady()) {
281 | return this.disabled ?
282 | Promise.resolve() :
283 | Promise.reject(new Error("not initialized"));
284 | }
285 | return new Promise((resolve, reject) => {
286 | const trx = this.db.transaction(['user'], 'readwrite');
287 | trx.oncomplete = event => {
288 | resolve(event.target.result);
289 | };
290 | trx.onerror = event => {
291 | this.#logger('PCache', 'updUser', event.target.error);
292 | reject(event.target.error);
293 | };
294 | trx.objectStore('user').put({
295 | uid: uid,
296 | public: pub
297 | });
298 | trx.commit();
299 | });
300 | }
301 |
302 | /**
303 | * Remove user from persistent cache.
304 | * @memberOf DB
305 | * @param {string} uid - ID of the user to remove from the cache.
306 | * @return {Promise} promise resolved/rejected on operation completion.
307 | */
308 | remUser(uid) {
309 | if (!this.isReady()) {
310 | return this.disabled ?
311 | Promise.resolve() :
312 | Promise.reject(new Error("not initialized"));
313 | }
314 | return new Promise((resolve, reject) => {
315 | const trx = this.db.transaction(['user'], 'readwrite');
316 | trx.oncomplete = event => {
317 | resolve(event.target.result);
318 | };
319 | trx.onerror = event => {
320 | this.#logger('PCache', 'remUser', event.target.error);
321 | reject(event.target.error);
322 | };
323 | trx.objectStore('user').delete(IDBKeyRange.only(uid));
324 | trx.commit();
325 | });
326 | }
327 |
328 | /**
329 | * Execute a callback for each stored user.
330 | * @memberOf DB
331 | * @param {function} callback - function to call for each topic.
332 | * @param {Object} context - the value or this
inside the callback.
333 | * @return {Promise} promise resolved/rejected on operation completion.
334 | */
335 | mapUsers(callback, context) {
336 | return this.#mapObjects('user', callback, context);
337 | }
338 |
339 | /**
340 | * Read a single user from persistent cache.
341 | * @memberOf DB
342 | * @param {string} uid - ID of the user to fetch from cache.
343 | * @return {Promise} promise resolved/rejected on operation completion.
344 | */
345 | getUser(uid) {
346 | if (!this.isReady()) {
347 | return this.disabled ?
348 | Promise.resolve() :
349 | Promise.reject(new Error("not initialized"));
350 | }
351 | return new Promise((resolve, reject) => {
352 | const trx = this.db.transaction(['user']);
353 | trx.oncomplete = event => {
354 | const user = event.target.result;
355 | resolve({
356 | user: user.uid,
357 | public: user.public
358 | });
359 | };
360 | trx.onerror = event => {
361 | this.#logger('PCache', 'getUser', event.target.error);
362 | reject(event.target.error);
363 | };
364 | trx.objectStore('user').get(uid);
365 | });
366 | }
367 |
368 | // Subscriptions.
369 | /**
370 | * Add or update subscription in persistent cache.
371 | * @memberOf DB
372 | * @param {string} topicName - name of the topic which owns the message.
373 | * @param {string} uid - ID of the subscribed user.
374 | * @param {Object} sub - subscription to save.
375 | * @return {Promise} promise resolved/rejected on operation completion.
376 | */
377 | updSubscription(topicName, uid, sub) {
378 | if (!this.isReady()) {
379 | return this.disabled ?
380 | Promise.resolve() :
381 | Promise.reject(new Error("not initialized"));
382 | }
383 | return new Promise((resolve, reject) => {
384 | const trx = this.db.transaction(['subscription'], 'readwrite');
385 | trx.oncomplete = event => {
386 | resolve(event.target.result);
387 | };
388 | trx.onerror = event => {
389 | this.#logger('PCache', 'updSubscription', event.target.error);
390 | reject(event.target.error);
391 | };
392 | trx.objectStore('subscription').get([topicName, uid]).onsuccess = (event) => {
393 | trx.objectStore('subscription').put(DB.#serializeSubscription(event.target.result, topicName, uid, sub));
394 | trx.commit();
395 | };
396 | });
397 | }
398 |
399 | /**
400 | * Execute a callback for each cached subscription in a given topic.
401 | * @memberOf DB
402 | * @param {string} topicName - name of the topic which owns the subscriptions.
403 | * @param {function} callback - function to call for each subscription.
404 | * @param {Object} context - the value or this
inside the callback.
405 | * @return {Promise} promise resolved/rejected on operation completion.
406 | */
407 | mapSubscriptions(topicName, callback, context) {
408 | if (!this.isReady()) {
409 | return this.disabled ?
410 | Promise.resolve([]) :
411 | Promise.reject(new Error("not initialized"));
412 | }
413 | return new Promise((resolve, reject) => {
414 | const trx = this.db.transaction(['subscription']);
415 | trx.onerror = (event) => {
416 | this.#logger('PCache', 'mapSubscriptions', event.target.error);
417 | reject(event.target.error);
418 | };
419 | trx.objectStore('subscription').getAll(IDBKeyRange.bound([topicName, '-'], [topicName, '~'])).onsuccess = (event) => {
420 | if (callback) {
421 | event.target.result.forEach((topic) => {
422 | callback.call(context, topic);
423 | });
424 | }
425 | resolve(event.target.result);
426 | };
427 | });
428 | }
429 |
430 | // Messages.
431 |
432 | /**
433 | * Save message to persistent cache.
434 | * @memberOf DB
435 | * @param {Object} msg - message to save.
436 | * @return {Promise} promise resolved/rejected on operation completion.
437 | */
438 | addMessage(msg) {
439 | if (!this.isReady()) {
440 | return this.disabled ?
441 | Promise.resolve() :
442 | Promise.reject(new Error("not initialized"));
443 | }
444 | return new Promise((resolve, reject) => {
445 | const trx = this.db.transaction(['message'], 'readwrite');
446 | trx.onsuccess = event => {
447 | resolve(event.target.result);
448 | };
449 | trx.onerror = event => {
450 | this.#logger('PCache', 'addMessage', event.target.error);
451 | reject(event.target.error);
452 | };
453 | trx.objectStore('message').add(DB.#serializeMessage(null, msg));
454 | trx.commit();
455 | });
456 | }
457 |
458 | /**
459 | * Update delivery status of a message stored in persistent cache.
460 | * @memberOf DB
461 | * @param {string} topicName - name of the topic which owns the message.
462 | * @param {number} seq - ID of the message to update
463 | * @param {number} status - new delivery status of the message.
464 | * @return {Promise} promise resolved/rejected on operation completion.
465 | */
466 | updMessageStatus(topicName, seq, status) {
467 | if (!this.isReady()) {
468 | return this.disabled ?
469 | Promise.resolve() :
470 | Promise.reject(new Error("not initialized"));
471 | }
472 | return new Promise((resolve, reject) => {
473 | const trx = this.db.transaction(['message'], 'readwrite');
474 | trx.onsuccess = event => {
475 | resolve(event.target.result);
476 | };
477 | trx.onerror = event => {
478 | this.#logger('PCache', 'updMessageStatus', event.target.error);
479 | reject(event.target.error);
480 | };
481 | const req = trx.objectStore('message').get(IDBKeyRange.only([topicName, seq]));
482 | req.onsuccess = event => {
483 | const src = req.result || event.target.result;
484 | if (!src || src._status == status) {
485 | trx.commit();
486 | return;
487 | }
488 | trx.objectStore('message').put(DB.#serializeMessage(src, {
489 | topic: topicName,
490 | seq: seq,
491 | _status: status
492 | }));
493 | trx.commit();
494 | };
495 | });
496 | }
497 |
498 | /**
499 | * Remove one or more messages from persistent cache.
500 | * @memberOf DB
501 | * @param {string} topicName - name of the topic which owns the message.
502 | * @param {number} from - id of the message to remove or lower boundary when removing range (inclusive).
503 | * @param {number=} to - upper boundary (exclusive) when removing a range of messages.
504 | * @return {Promise} promise resolved/rejected on operation completion.
505 | */
506 | remMessages(topicName, from, to) {
507 | if (!this.isReady()) {
508 | return this.disabled ?
509 | Promise.resolve() :
510 | Promise.reject(new Error("not initialized"));
511 | }
512 | return new Promise((resolve, reject) => {
513 | if (!from && !to) {
514 | from = 0;
515 | to = Number.MAX_SAFE_INTEGER;
516 | }
517 | const range = to > 0 ? IDBKeyRange.bound([topicName, from], [topicName, to], false, true) :
518 | IDBKeyRange.only([topicName, from]);
519 | const trx = this.db.transaction(['message'], 'readwrite');
520 | trx.onsuccess = event => {
521 | resolve(event.target.result);
522 | };
523 | trx.onerror = event => {
524 | this.#logger('PCache', 'remMessages', event.target.error);
525 | reject(event.target.error);
526 | };
527 | trx.objectStore('message').delete(range);
528 | trx.commit();
529 | });
530 | }
531 |
532 | /**
533 | * Retrieve messages from persistent store.
534 | * @memberOf DB
535 | * @param {string} topicName - name of the topic to retrieve messages from.
536 | * @param {function} callback to call for each retrieved message.
537 | * @param {GetDataType} query - parameters of the message range to retrieve.
538 | *
539 | * @return {Promise} promise resolved/rejected on operation completion.
540 | */
541 | readMessages(topicName, query, callback, context) {
542 | query = query || {};
543 |
544 | if (!this.isReady()) {
545 | return this.disabled ?
546 | Promise.resolve([]) :
547 | Promise.reject(new Error("not initialized"));
548 | }
549 |
550 | const trx = this.db.transaction(['message']);
551 | let result = [];
552 |
553 | // Handle individual message ranges.
554 | if (Array.isArray(query.ranges)) {
555 | return new Promise((resolve, reject) => {
556 | trx.onerror = event => {
557 | this.#logger('PCache', 'readMessages', event.target.error);
558 | reject(event.target.error);
559 | };
560 |
561 | let count = 0;
562 | query.ranges.forEach(range => {
563 | const key = range.hi ? IDBKeyRange.bound([topicName, range.low], [topicName, range.hi], false, true) :
564 | IDBKeyRange.only([topicName, range.low]);
565 | trx.objectStore('message').getAll(key).onsuccess = event => {
566 | const msgs = event.target.result;
567 | if (msgs) {
568 | if (callback) {
569 | callback.call(context, msgs);
570 | }
571 | if (Array.isArray(msgs)) {
572 | result = result.concat(msgs);
573 | } else {
574 | result.push(msgs);
575 | }
576 | }
577 | count++;
578 | if (count == query.ranges.length) {
579 | resolve(result);
580 | }
581 | };
582 | });
583 | });
584 | }
585 |
586 | // Handle single range.
587 | return new Promise((resolve, reject) => {
588 | const since = query.since > 0 ? query.since : 0;
589 | const before = query.before > 0 ? query.before : Number.MAX_SAFE_INTEGER;
590 | const limit = query.limit | 0;
591 |
592 | trx.onerror = event => {
593 | this.#logger('PCache', 'readMessages', event.target.error);
594 | reject(event.target.error);
595 | };
596 |
597 | const range = IDBKeyRange.bound([topicName, since], [topicName, before], false, true);
598 | // Iterate in descending order.
599 | trx.objectStore('message').openCursor(range, 'prev')
600 | .onsuccess = event => {
601 | const cursor = event.target.result;
602 | if (cursor) {
603 | if (callback) {
604 | callback.call(context, cursor.value);
605 | }
606 | result.push(cursor.value);
607 | if (limit <= 0 || result.length < limit) {
608 | cursor.continue();
609 | } else {
610 | resolve(result);
611 | }
612 | } else {
613 | resolve(result);
614 | }
615 | };
616 | });
617 | }
618 |
619 | // Delete log
620 |
621 | /**
622 | * Add records of deleted messages.
623 | * @memberOf DB
624 | * @param {string} topicName - name of the topic which owns the message.
625 | * @param {number} delId - id of the deletion transaction.
626 | * @param {Array.} ranges - message to save.
627 | * @return {Promise} promise resolved/rejected on operation completion.
628 | */
629 | addDelLog(topicName, delId, ranges) {
630 | if (!this.isReady()) {
631 | return this.disabled ?
632 | Promise.resolve() :
633 | Promise.reject(new Error("not initialized"));
634 | }
635 | return new Promise((resolve, reject) => {
636 | const trx = this.db.transaction(['dellog'], 'readwrite');
637 | trx.onsuccess = event => {
638 | resolve(event.target.result);
639 | };
640 | trx.onerror = event => {
641 | this.#logger('PCache', 'addDelLog', event.target.error);
642 | reject(event.target.error);
643 | };
644 | ranges.forEach(r => trx.objectStore('dellog').add({
645 | topic: topicName,
646 | clear: delId,
647 | low: r.low,
648 | hi: r.hi || (r.low + 1)
649 | }));
650 | trx.commit();
651 | });
652 | }
653 |
654 | /**
655 | * Retrieve deleted message records from persistent store.
656 | * @memberOf DB
657 | * @param {string} topicName - name of the topic to retrieve records for.
658 | * @param {GetDataType} query - parameters of the message range to retrieve.
659 | * @return {Promise} promise resolved/rejected on operation completion.
660 | */
661 | readDelLog(topicName, query) {
662 | query = query || {};
663 |
664 | if (!this.isReady()) {
665 | return this.disabled ?
666 | Promise.resolve([]) :
667 | Promise.reject(new Error("not initialized"));
668 | }
669 |
670 | const trx = this.db.transaction(['dellog']);
671 | let result = [];
672 |
673 | // Handle individual message ranges.
674 | if (Array.isArray(query.ranges)) {
675 | return new Promise((resolve, reject) => {
676 | trx.onerror = event => {
677 | this.#logger('PCache', 'readDelLog', event.target.error);
678 | reject(event.target.error);
679 | };
680 |
681 | let count = 0;
682 | query.ranges.forEach(range => {
683 | const hi = range.hi || (range.low + 1);
684 | const key = IDBKeyRange.bound([topicName, 0, range.low], [topicName, hi, Number.MAX_SAFE_INTEGER], false, true);
685 | trx.objectStore('dellog').getAll(key).onsuccess = event => {
686 | const entries = event.target.result;
687 | if (entries) {
688 | if (Array.isArray(entries)) {
689 | result = result.concat(entries.map(entry => {
690 | return {
691 | low: entry.low,
692 | hi: entry.hi
693 | };
694 | }));
695 | } else {
696 | result.push({
697 | low: entries.low,
698 | hi: entries.hi
699 | });
700 | }
701 | }
702 | count++;
703 | if (count == query.ranges.length) {
704 | resolve(result);
705 | }
706 | };
707 | });
708 | });
709 | }
710 |
711 | return new Promise((resolve, reject) => {
712 | const since = query.since > 0 ? query.since : 0;
713 | const before = query.before > 0 ? query.before : Number.MAX_SAFE_INTEGER;
714 | const limit = query.limit | 0;
715 |
716 | trx.onerror = event => {
717 | this.#logger('PCache', 'readDelLog', event.target.error);
718 | reject(event.target.error);
719 | };
720 |
721 | let count = 0;
722 | const result = [];
723 | const range = IDBKeyRange.bound([topicName, 0, since], [topicName, before, Number.MAX_SAFE_INTEGER], false, true);
724 | trx.objectStore('dellog').openCursor(range, 'prev')
725 | .onsuccess = event => {
726 | const cursor = event.target.result;
727 | if (cursor) {
728 | result.push({
729 | low: cursor.value.low,
730 | hi: cursor.value.hi
731 | });
732 | count += cursor.value.hi - cursor.value.low;
733 | if (limit <= 0 || count < limit) {
734 | cursor.continue();
735 | } else {
736 | resolve(result);
737 | }
738 | } else {
739 | resolve(result);
740 | }
741 | };
742 | });
743 | }
744 |
745 | /**
746 | * Retrieve the latest 'clear' ID for the given topic.
747 | * @param {string} topicName
748 | * @return {Promise} promise resolved/rejected on operation completion.
749 | */
750 | maxDelId(topicName) {
751 | if (!this.isReady()) {
752 | return this.disabled ?
753 | Promise.resolve(0) :
754 | Promise.reject(new Error("not initialized"));
755 | }
756 |
757 | return new Promise((resolve, reject) => {
758 | const trx = this.db.transaction(['dellog']);
759 | trx.onerror = event => {
760 | this.#logger('PCache', 'maxDelId', event.target.error);
761 | reject(event.target.error);
762 | };
763 |
764 | const index = trx.objectStore('dellog').index('topic_clear');
765 | index.openCursor(IDBKeyRange.bound([topicName, 0], [topicName, Number.MAX_SAFE_INTEGER]), 'prev')
766 | .onsuccess = event => {
767 | if (event.target.result) {
768 | resolve(event.target.result.value);
769 | }
770 | };
771 | });
772 | }
773 |
774 | // Private methods.
775 |
776 | // Serializable topic fields.
777 | static #topic_fields = ['created', 'updated', 'deleted', 'touched', 'read', 'recv', 'seq',
778 | 'clear', 'defacs', 'creds', 'public', 'trusted', 'private', '_aux', '_deleted'
779 | ];
780 |
781 | // Copy data from src to Topic object.
782 | static #deserializeTopic(topic, src) {
783 | DB.#topic_fields.forEach((f) => {
784 | if (src.hasOwnProperty(f)) {
785 | topic[f] = src[f];
786 | }
787 | });
788 | if (Array.isArray(src.tags)) {
789 | topic._tags = src.tags;
790 | }
791 | if (src.acs) {
792 | topic.setAccessMode(src.acs);
793 | }
794 | topic.seq |= 0;
795 | topic.read |= 0;
796 | topic.unread = Math.max(0, topic.seq - topic.read);
797 | }
798 |
799 | // Copy values from 'src' to 'dst'. Allocate dst if it's null or undefined.
800 | static #serializeTopic(dst, src) {
801 | const res = dst || {
802 | name: src.name
803 | };
804 | DB.#topic_fields.forEach(f => {
805 | if (src.hasOwnProperty(f)) {
806 | res[f] = src[f];
807 | }
808 | });
809 | if (Array.isArray(src._tags)) {
810 | res.tags = src._tags;
811 | }
812 | if (src.acs) {
813 | res.acs = src.getAccessMode().jsonHelper();
814 | }
815 | return res;
816 | }
817 |
818 | static #serializeSubscription(dst, topicName, uid, sub) {
819 | const fields = ['updated', 'mode', 'read', 'recv', 'clear', 'lastSeen', 'userAgent'];
820 | const res = dst || {
821 | topic: topicName,
822 | uid: uid
823 | };
824 |
825 | fields.forEach((f) => {
826 | if (sub.hasOwnProperty(f)) {
827 | res[f] = sub[f];
828 | }
829 | });
830 |
831 | return res;
832 | }
833 |
834 | static #serializeMessage(dst, msg) {
835 | // Serializable fields.
836 | const fields = ['topic', 'seq', 'ts', '_status', 'from', 'head', 'content'];
837 | const res = dst || {};
838 | fields.forEach((f) => {
839 | if (msg.hasOwnProperty(f)) {
840 | res[f] = msg[f];
841 | }
842 | });
843 | return res;
844 | }
845 |
846 | /**
847 | * To use DB in a non browser context, supply indexedDB provider.
848 | * @static
849 | * @memberof DB
850 | * @param idbProvider indexedDB provider, e.g. for node require('fake-indexeddb')
.
851 | */
852 | static setDatabaseProvider(idbProvider) {
853 | IDBProvider = idbProvider;
854 | }
855 | }
856 |
--------------------------------------------------------------------------------
/src/drafty.test.js:
--------------------------------------------------------------------------------
1 | import * as Drafty from './drafty';
2 |
3 | // Drafty.parse test data.
4 | const parse_this = [
5 | [
6 | 'This is *bold*, `code` and _italic_, ~strike~',
7 | {
8 | "fmt": [{
9 | "at": 8,
10 | "len": 4,
11 | "tp": "ST"
12 | },
13 | {
14 | "at": 14,
15 | "len": 4,
16 | "tp": "CO"
17 | },
18 | {
19 | "at": 23,
20 | "len": 6,
21 | "tp": "EM"
22 | },
23 | {
24 | "at": 31,
25 | "len": 6,
26 | "tp": "DL"
27 | },
28 | ],
29 | "txt": "This is bold, code and italic, strike",
30 | }
31 | ],
32 | [
33 | 'Это *жЫрный*, `код` и _наклонный_, ~зачеркнутый~',
34 | {
35 | "fmt": [{
36 | "at": 4,
37 | "len": 6,
38 | "tp": "ST"
39 | },
40 | {
41 | "at": 12,
42 | "len": 3,
43 | "tp": "CO"
44 | },
45 | {
46 | "at": 18,
47 | "len": 9,
48 | "tp": "EM"
49 | },
50 | {
51 | "at": 29,
52 | "len": 11,
53 | "tp": "DL"
54 | },
55 | ],
56 | "txt": "Это жЫрный, код и наклонный, зачеркнутый",
57 | }
58 | ],
59 | [
60 | 'combined *bold and _italic_*',
61 | {
62 | "fmt": [{
63 | "at": 18,
64 | "len": 6,
65 | "tp": "EM"
66 | },
67 | {
68 | "at": 9,
69 | "len": 15,
70 | "tp": "ST"
71 | },
72 | ],
73 | "txt": "combined bold and italic",
74 | }
75 | ],
76 | // FIXME: this test is inconsistent between golang, Android, and Javascript.
77 | [
78 | 'This *text _has* staggered_ formats',
79 | {
80 | "fmt": [{
81 | "at": 5,
82 | "len": 9,
83 | "tp": "ST"
84 | }, ],
85 | "txt": "This text _has staggered_ formats",
86 | },
87 | ],
88 | [
89 | 'an url: https://www.example.com/abc#fragment and another _www.tinode.co_',
90 | {
91 | "ent": [{
92 | "data": {
93 | "url": "https://www.example.com/abc#fragment"
94 | },
95 | "tp": "LN"
96 | },
97 | {
98 | "data": {
99 | "url": "http://www.tinode.co"
100 | },
101 | "tp": "LN"
102 | },
103 | ],
104 | "fmt": [{
105 | "at": 57,
106 | "len": 13,
107 | "tp": "EM"
108 | },
109 | {
110 | "at": 8,
111 | "len": 36,
112 | "key": 0
113 | },
114 | {
115 | "at": 57,
116 | "len": 13,
117 | "key": 1
118 | },
119 | ],
120 | "txt": "an url: https://www.example.com/abc#fragment and another www.tinode.co"
121 | },
122 | ],
123 | [
124 | 'this is a @mention and a #hashtag in a string',
125 | {
126 | "ent": [{
127 | "data": {
128 | "val": "mention"
129 | },
130 | "tp": "MN"
131 | },
132 | {
133 | "data": {
134 | "val": "hashtag"
135 | },
136 | "tp": "HT"
137 | },
138 | ],
139 | "fmt": [{
140 | "at": 10,
141 | "key": 0,
142 | "len": 8
143 | },
144 | {
145 | "at": 25,
146 | "key": 1,
147 | "len": 8
148 | },
149 | ],
150 | "txt": "this is a @mention and a #hashtag in a string"
151 | },
152 | ],
153 | [
154 | 'second #юникод',
155 | {
156 | "ent": [{
157 | "data": {
158 | "val": "юникод"
159 | },
160 | "tp": "HT"
161 | }, ],
162 | "fmt": [{
163 | "at": 7,
164 | "key": 0,
165 | "len": 7
166 | }, ],
167 | "txt": "second #юникод",
168 | },
169 | ],
170 | [
171 | '😀 *b1👩🏽✈️b2* smile',
172 | {
173 | "txt": "😀 b1👩🏽✈️b2 smile",
174 | "fmt": [{
175 | "tp": "ST",
176 | "at": 2,
177 | "len": 5
178 | }, ],
179 | }
180 | ],
181 | [
182 | 'first 😀 line\nsecond *line*',
183 | {
184 | "txt": "first 😀 line second line",
185 | "fmt": [{
186 | "tp": "BR",
187 | "at": 12,
188 | "len": 1
189 | }, {
190 | "tp": "ST",
191 | "at": 20,
192 | "len": 4
193 | }, ],
194 | }
195 | ],
196 | [
197 | '🕯️ *bold* https://google.com',
198 | {
199 | txt: '🕯️ bold https://google.com',
200 | fmt: [{
201 | at: 2,
202 | len: 4,
203 | tp: 'ST',
204 | },
205 | {
206 | at: 7,
207 | key: 0,
208 | len: 18,
209 | },
210 | ],
211 | ent: [{
212 | tp: 'LN',
213 | data: {
214 | url: 'https://google.com',
215 | },
216 | }, ]
217 | }
218 | ],
219 | [
220 | 'Hi 👋🏼 Visit http://localhost:6060\n*New* *line*🫡 Visit http://localhost:8080',
221 | {
222 | txt: 'Hi 👋🏼 Visit http://localhost:6060 New line🫡 Visit http://localhost:8080',
223 | fmt: [{
224 | at: 11,
225 | len: 21,
226 | key: 0,
227 | },
228 | {
229 | at: 32,
230 | len: 1,
231 | tp: 'BR',
232 | },
233 | {
234 | at: 33,
235 | len: 3,
236 | tp: 'ST',
237 | },
238 | {
239 | at: 37,
240 | len: 4,
241 | tp: 'ST',
242 | },
243 | {
244 | at: 49,
245 | len: 21,
246 | key: 1,
247 | },
248 | ],
249 | ent: [{
250 | tp: 'LN',
251 | data: {
252 | url: 'http://localhost:6060',
253 | },
254 | },
255 | {
256 | tp: 'LN',
257 | data: {
258 | url: 'http://localhost:8080',
259 | },
260 | },
261 | ],
262 | },
263 | ],
264 | [
265 | '🔴Hello🔴\n🟠Hello🟠\n🟡Hello🟡',
266 | {
267 | "txt": "🔴Hello🔴 🟠Hello🟠 🟡Hello🟡",
268 | "fmt": [{
269 | "tp": "BR",
270 | "at": 7,
271 | "len": 1
272 | }, {
273 | "tp": "BR",
274 | "at": 15,
275 | "len": 1
276 | }, ],
277 | }
278 | ]
279 | ];
280 |
281 | test.each(parse_this)('Drafty.parse %s', (src, exp) => {
282 | expect(Drafty.parse(src)).toEqual(exp);
283 | });
284 |
285 | // Drafty docs for testing Drafty.preview and Drafty.normalize.
286 | const shorten_this = [
287 | [
288 | "This is a plain text string.",
289 | {
290 | "txt": "This is a plai…"
291 | },
292 | ],
293 | [{
294 | "txt": "This is a string.", // Maybe remove extra space.
295 | "fmt": [{
296 | "at": 9,
297 | "tp": "BR"
298 | }]
299 | },
300 | {
301 | "txt": "This is a stri…",
302 | "fmt": [{
303 | "at": 9,
304 | len: 0,
305 | "tp": "BR"
306 | }]
307 | },
308 | ],
309 | [{
310 | "txt": "This is a string.",
311 | "fmt": [{
312 | "at": true,
313 | "tp": "XX",
314 | "len": {}
315 | }]
316 | },
317 | {
318 | "txt": "This is a stri…"
319 | },
320 | ],
321 | [{
322 | "txt": "This is a string.",
323 | "fmt": [{
324 | "at": {},
325 | "tp": 123,
326 | "len": null
327 | }]
328 | },
329 | {
330 | "txt": "This is a stri…"
331 | },
332 | ],
333 | [{
334 | "txt": "This is a string.",
335 | "fmt": [{
336 | "test": 123
337 | }, {
338 | "at": NaN,
339 | "tp": 123,
340 | "len": -12
341 | }]
342 | },
343 | {
344 | "txt": "This is a stri…"
345 | },
346 | ],
347 | [{
348 | "fmt": [{
349 | "at": -1
350 | }],
351 | "ent": [{
352 | "data": {
353 | "mime": "image/jpeg",
354 | "name": "hello.jpg",
355 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
356 | "width": 100,
357 | "height": 80
358 | },
359 | "tp": "EX"
360 | }]
361 | },
362 | {
363 | "txt": "",
364 | "fmt": [{
365 | "at": -1,
366 | "key": 0,
367 | "len": 0
368 | }],
369 | "ent": [{
370 | "tp": "EX",
371 | "data": {
372 | "height": 80,
373 | "mime": "image/jpeg",
374 | "name": "hello.jpg",
375 | "width": 100
376 | }
377 | }]
378 | },
379 | ],
380 | [{
381 | "fmt": [{
382 | "at": -100,
383 | "len": 99
384 | }],
385 | "ent": [{
386 | "data": {
387 | "mime": "image/jpeg",
388 | "name": "hello.jpg",
389 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
390 | "width": 100,
391 | "height": 80
392 | },
393 | "tp": "EX"
394 | }]
395 | },
396 | {
397 | "txt": "",
398 | "fmt": [{
399 | "at": -1,
400 | "key": 0,
401 | "len": 0
402 | }],
403 | "ent": [{
404 | "tp": "EX",
405 | "data": {
406 | "height": 80,
407 | "mime": "image/jpeg",
408 | "name": "hello.jpg",
409 | "width": 100
410 | }
411 | }]
412 | },
413 | ],
414 | [{
415 | "fmt": [{
416 | "at": -1,
417 | "key": "fake"
418 | }],
419 | "ent": [{
420 | "data": {
421 | "width": 100
422 | },
423 | "tp": "EX"
424 | }]
425 | },
426 | {
427 | "txt": ""
428 | },
429 | ],
430 | [{
431 | "txt": "Message with attachment",
432 | "fmt": [{
433 | "at": -1,
434 | "len": 0,
435 | "key": 0
436 | }, {
437 | "at": 8,
438 | "len": 4,
439 | "tp": "ST"
440 | }],
441 | "ent": [{
442 | "data": {
443 | "mime": "image/jpeg",
444 | "name": "hello.jpg",
445 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
446 | "width": 100,
447 | "height": 80
448 | },
449 | "tp": "EX"
450 | }]
451 | },
452 | {
453 | "txt": "Message with a…",
454 | "fmt": [{
455 | "at": 8,
456 | "len": 4,
457 | "tp": "ST"
458 | }, {
459 | "at": -1,
460 | "len": 0,
461 | "key": 0
462 | }],
463 | "ent": [{
464 | "tp": "EX",
465 | "data": {
466 | "height": 80,
467 | "mime": "image/jpeg",
468 | "name": "hello.jpg",
469 | "width": 100
470 | }
471 | }]
472 | },
473 | ],
474 | [{
475 | "txt": "https://api.tinode.co/",
476 | "fmt": [{
477 | "len": 22
478 | }],
479 | "ent": [{
480 | "data": {
481 | "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
482 | },
483 | "tp": "LN"
484 | }]
485 | },
486 | {
487 | "txt": "https://api.ti…",
488 | "fmt": [{
489 | "at": 0,
490 | "len": 15,
491 | "key": 0
492 | }],
493 | "ent": [{
494 | "tp": "LN",
495 | "data": {
496 | "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
497 | }
498 | }]
499 | },
500 | ],
501 | [{
502 | "txt": "https://api.tinode.co/",
503 | "fmt": [{
504 | "len": 22
505 | }],
506 | "ent": [{
507 | "data": {
508 | "url": "https://api.tinode.co/"
509 | },
510 | "tp": "LN"
511 | }]
512 | },
513 | {
514 | "txt": "https://api.ti…",
515 | "fmt": [{
516 | "at": 0,
517 | "len": 15,
518 | "key": 0
519 | }],
520 | "ent": [{
521 | "tp": "LN",
522 | "data": {
523 | "url": "https://api.tinode.co/"
524 | }
525 | }]
526 | },
527 | ],
528 | [{
529 | "txt": "Url one, two",
530 | "fmt": [{
531 | "at": 9,
532 | "len": 3
533 | }, {
534 | "at": 4,
535 | "len": 3
536 | }],
537 | "ent": [{
538 | "data": {
539 | "url": "http://tinode.co"
540 | },
541 | "tp": "LN"
542 | }]
543 | },
544 | {
545 | "txt": "Url one, two",
546 | "fmt": [{
547 | "at": 4,
548 | "len": 3,
549 | "key": 0
550 | }, {
551 | "at": 9,
552 | "len": 3,
553 | "key": 0
554 | }],
555 | "ent": [{
556 | "tp": "LN",
557 | "data": {
558 | "url": "http://tinode.co"
559 | }
560 | }]
561 | },
562 | ],
563 | [{
564 | "txt": "Url one, two",
565 | "fmt": [{
566 | "at": 9,
567 | "len": 3,
568 | "key": 0
569 | }, {
570 | "at": 4,
571 | "len": 3,
572 | "key": 1
573 | }],
574 | "ent": [{
575 | "data": {
576 | "url": "http://tinode.co"
577 | },
578 | "tp": "LN"
579 | }, {
580 | "data": {
581 | "url": "http://example.com"
582 | },
583 | "tp": "LN"
584 | }]
585 | },
586 | {
587 | "txt": "Url one, two",
588 | "fmt": [{
589 | "at": 4,
590 | "len": 3,
591 | "key": 0
592 | }, {
593 | "at": 9,
594 | "len": 3,
595 | "key": 1
596 | }],
597 | "ent": [{
598 | "data": {
599 | "url": "http://example.com"
600 | },
601 | "tp": "LN"
602 | }, {
603 | "data": {
604 | "url": "http://tinode.co"
605 | },
606 | "tp": "LN"
607 | }]
608 | },
609 | ],
610 | [{
611 | "txt": " ",
612 | "fmt": [{
613 | "len": 1
614 | }],
615 | "ent": [{
616 | "data": {
617 | "height": 213,
618 | "mime": "image/jpeg",
619 | "name": "roses.jpg",
620 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
621 | "width": 638
622 | },
623 | "tp": "IM"
624 | }]
625 | },
626 | {
627 | "txt": " ",
628 | "fmt": [{
629 | "at": 0,
630 | "len": 1,
631 | "key": 0
632 | }],
633 | "ent": [{
634 | "tp": "IM",
635 | "data": {
636 | "height": 213,
637 | "mime": "image/jpeg",
638 | "name": "roses.jpg",
639 | "width": 638
640 | }
641 | }]
642 | },
643 | ],
644 | [{
645 | "txt": "This text has staggered formats",
646 | "fmt": [{
647 | "at": 5,
648 | "len": 8,
649 | "tp": "EM"
650 | }, {
651 | "at": 10,
652 | "len": 13,
653 | "tp": "ST"
654 | }]
655 | },
656 | {
657 | "txt": "This text has …",
658 | "fmt": [{
659 | "tp": "EM",
660 | "at": 5,
661 | "len": 8
662 | }]
663 | },
664 | ],
665 | [{
666 | "txt": "This text is formatted and deleted too",
667 | "fmt": [{
668 | "at": 5,
669 | "len": 4,
670 | "tp": "ST"
671 | }, {
672 | "at": 13,
673 | "len": 9,
674 | "tp": "EM"
675 | }, {
676 | "at": 35,
677 | "len": 3,
678 | "tp": "ST"
679 | }, {
680 | "at": 27,
681 | "len": 11,
682 | "tp": "DL"
683 | }]
684 | },
685 | {
686 | "txt": "This text is f…",
687 | "fmt": [{
688 | "tp": "ST",
689 | "at": 5,
690 | "len": 4
691 | }, {
692 | "tp": "EM",
693 | "at": 13,
694 | "len": 2
695 | }]
696 | },
697 | ],
698 | [{
699 | "txt": "мультибайтовый юникод",
700 | "fmt": [{
701 | "len": 14,
702 | "tp": "ST"
703 | }, {
704 | "at": 15,
705 | "len": 6,
706 | "tp": "EM"
707 | }]
708 | },
709 | {
710 | "txt": "мультибайтовый…",
711 | "fmt": [{
712 | "at": 0,
713 | "tp": "ST",
714 | "len": 14
715 | }]
716 | },
717 | ],
718 | [{
719 | "txt": "Alice Johnson This is a test",
720 | "fmt": [{
721 | "at": 13,
722 | "len": 1,
723 | "tp": "BR"
724 | }, {
725 | "at": 15,
726 | "len": 1
727 | }, {
728 | "len": 13,
729 | "key": 1
730 | }, {
731 | "len": 16,
732 | "tp": "QQ"
733 | }, {
734 | "at": 16,
735 | "len": 1,
736 | "tp": "BR"
737 | }],
738 | "ent": [{
739 | "tp": "IM",
740 | "data": {
741 | "mime": "image/jpeg",
742 | "val": "<1292, bytes: /9j/4AAQSkZJ123456789012345678901234567890123456789012345678901234567890rehH5o6D/9k=>",
743 | "width": 25,
744 | "height": 14,
745 | "size": 968
746 | }
747 | }, {
748 | "tp": "MN",
749 | "data": {
750 | "val": "usr123abcDE"
751 | }
752 | }]
753 | },
754 | {
755 | "txt": "Alice Johnson …",
756 | "fmt": [{
757 | "at": 0,
758 | "len": 13,
759 | "key": 0
760 | }, {
761 | "at": 13,
762 | "len": 1,
763 | "tp": "BR"
764 | }, {
765 | "at": 0,
766 | "len": 15,
767 | "tp": "QQ"
768 | }],
769 | "ent": [{
770 | "tp": "MN",
771 | "data": {
772 | "val": "usr123abcDE"
773 | }
774 | }, ]
775 | },
776 | ],
777 | [{
778 | "txt": "a😀c😀d😀e😀f😀g😀h😀i😀j😀k😀l😀m"
779 | },
780 | {
781 | "txt": "a😀c😀d😀e😀f😀g😀h😀…",
782 | }
783 | ],
784 | [{
785 | "txt": "😀 b1👩🏽✈️b2 smile 123 123 123 123",
786 | "fmt": [{
787 | "tp": "ST",
788 | "at": 2,
789 | "len": 8
790 | }, {
791 | "tp": "EM",
792 | "at": 0,
793 | "len": 20
794 | }]
795 | },
796 | {
797 | "txt": "😀 b1👩🏽✈️b2 smile …",
798 | "fmt": [{
799 | "tp": "ST",
800 | "at": 2,
801 | "len": 8
802 | }, {
803 | "tp": "EM",
804 | "at": 0,
805 | "len": 15
806 | }]
807 | },
808 | ]
809 | ];
810 |
811 | test.each(shorten_this)('Drafty.shorten %j', (src, exp) => {
812 | expect(Drafty.shorten(src, 15, true)).toEqual(exp);
813 | });
814 |
815 | // Drafty docs for testing Drafty.forwardedContent.
816 | const forward_this = [
817 | [{
818 | "txt": " ",
819 | "fmt": [{
820 | "len": 1
821 | }],
822 | "ent": [{
823 | "data": {
824 | "height": 213,
825 | "mime": "image/jpeg",
826 | "name": "roses.jpg",
827 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
828 | "width": 638
829 | },
830 | "tp": "IM"
831 | }]
832 | },
833 | {
834 | "txt": " ",
835 | "fmt": [{
836 | "at": 0,
837 | "len": 1,
838 | "key": 0
839 | }],
840 | "ent": [{
841 | "tp": "IM",
842 | "data": {
843 | "height": 213,
844 | "mime": "image/jpeg",
845 | "name": "roses.jpg",
846 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
847 | "width": 638
848 | }
849 | }]
850 | },
851 | ],
852 | [{
853 | "ent": [{
854 | "data": {
855 | "val": "usrCPvFc6lpAsw"
856 | },
857 | "tp": "MN"
858 | }],
859 | "fmt": [{
860 | "len": 13
861 | }, {
862 | "at": 13,
863 | "len": 1,
864 | "tp": "BR"
865 | }, {
866 | "len": 38,
867 | "tp": "QQ"
868 | }],
869 | "txt": "Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply."
870 | },
871 | {
872 | "ent": [{
873 | "data": {
874 | "val": "usrCPvFc6lpAsw"
875 | },
876 | "tp": "MN"
877 | }],
878 | "fmt": [{
879 | "at": 0,
880 | "len": 13,
881 | "key": 0
882 | }, {
883 | "at": 13,
884 | "len": 1,
885 | "tp": "BR"
886 | }, {
887 | "at": 0,
888 | "len": 38,
889 | "tp": "QQ"
890 | }],
891 | "txt": "Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply."
892 | },
893 | ],
894 | [{
895 | "ent": [{
896 | "data": {
897 | "val": "usrCPvFc6lpAsw"
898 | },
899 | "tp": "MN"
900 | }, {
901 | "data": {
902 | "val": "usrCPvFc6lpAsw"
903 | },
904 | "tp": "MN"
905 | }],
906 | "fmt": [{
907 | "len": 15
908 | }, {
909 | "at": 15,
910 | "len": 1,
911 | "tp": "BR"
912 | }, {
913 | "at": 16,
914 | "key": 1,
915 | "len": 13
916 | }, {
917 | "at": 29,
918 | "len": 1,
919 | "tp": "BR"
920 | }, {
921 | "at": 16,
922 | "len": 36,
923 | "tp": "QQ"
924 | }],
925 | "txt": "➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply"
926 | },
927 | {
928 | "ent": [{
929 | "data": {
930 | "val": "usrCPvFc6lpAsw"
931 | },
932 | "tp": "MN"
933 | }],
934 | "fmt": [{
935 | "at": 0,
936 | "key": 0,
937 | "len": 13
938 | }, {
939 | "at": 13,
940 | "len": 1,
941 | "tp": "BR"
942 | }, {
943 | "at": 0,
944 | "len": 36,
945 | "tp": "QQ"
946 | }],
947 | "txt": "Alice Johnson This is a simple replyThis is a reply to reply"
948 | }
949 | ],
950 | [{
951 | "txt": "➦ tinodeu 🔴Hello🔴 🟠Hello🟠 🟡Hello🟡 🟢Hello🟢",
952 | "fmt": [{
953 | "len": 9
954 | },
955 | {
956 | "at": 9,
957 | "len": 1,
958 | "tp": "BR"
959 | },
960 | {
961 | "at": 19,
962 | "len": 1,
963 | "tp": "BR"
964 | },
965 | {
966 | "at": 29,
967 | "len": 1,
968 | "tp": "BR"
969 | },
970 | {
971 | "at": 39,
972 | "len": 1,
973 | "tp": "BR"
974 | }
975 | ],
976 | "ent": [{
977 | "tp": "MN",
978 | "data": {
979 | "val": "usrfv76ZZoJQJc"
980 | }
981 | }]
982 | },
983 | {
984 | "txt": "🔴Hello🔴 🟠Hello🟠 🟡Hello🟡 🟢Hello🟢",
985 | "fmt": [{
986 | "tp": "BR",
987 | "at": 9,
988 | "len": 1
989 | },
990 | {
991 | "tp": "BR",
992 | "at": 19,
993 | "len": 1
994 | },
995 | {
996 | "tp": "BR",
997 | "at": 29,
998 | "len": 1
999 | }
1000 | ]
1001 | }
1002 | ]
1003 | ];
1004 |
1005 | test.each(forward_this)('Drafty.forwardedContent %j', (src, exp) => {
1006 | expect(Drafty.forwardedContent(src)).toEqual(exp);
1007 | });
1008 |
1009 | // Drafty docs for testing Drafty.preview.
1010 | const preview_this = [
1011 | [{
1012 | "ent": [{
1013 | "data": {
1014 | "val": "usrCPvFc6lpAsw"
1015 | },
1016 | "tp": "MN"
1017 | }],
1018 | "fmt": [{
1019 | "len": 13
1020 | }, {
1021 | "at": 13,
1022 | "len": 1,
1023 | "tp": "BR"
1024 | }, {
1025 | "len": 38,
1026 | "tp": "QQ"
1027 | }],
1028 | "txt": "Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply."
1029 | },
1030 | {
1031 | "fmt": [{
1032 | "at": 0,
1033 | "len": 1,
1034 | "tp": "QQ"
1035 | }],
1036 | "txt": " This is a Reply -> Forw…"
1037 | },
1038 | ],
1039 | [{
1040 | "ent": [{
1041 | "data": {
1042 | "val": "usrCPvFc6lpAsw"
1043 | },
1044 | "tp": "MN"
1045 | }, {
1046 | "data": {
1047 | "val": "usrCPvFc6lpAsw"
1048 | },
1049 | "tp": "MN"
1050 | }],
1051 | "fmt": [{
1052 | "len": 15
1053 | }, {
1054 | "at": 15,
1055 | "len": 1,
1056 | "tp": "BR"
1057 | }, {
1058 | "at": 16,
1059 | "key": 1,
1060 | "len": 13
1061 | }, {
1062 | "at": 29,
1063 | "len": 1,
1064 | "tp": "BR"
1065 | }, {
1066 | "at": 16,
1067 | "len": 36,
1068 | "tp": "QQ"
1069 | }],
1070 | "txt": "➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply"
1071 | },
1072 | {
1073 | "ent": [{
1074 | "data": {
1075 | "val": "usrCPvFc6lpAsw"
1076 | },
1077 | "tp": "MN"
1078 | }],
1079 | "fmt": [{
1080 | "at": 0,
1081 | "key": 0,
1082 | "len": 1
1083 | }, {
1084 | "at": 2,
1085 | "len": 1,
1086 | "tp": "QQ"
1087 | }],
1088 | "txt": "➦ This is a reply to re…"
1089 | }
1090 | ],
1091 | [{
1092 | "txt": 'Hi 👋🏼 Visit http://localhost:6060 New line🫡 Visit http://localhost:8080',
1093 | "fmt": [{
1094 | "at": 11,
1095 | "len": 21,
1096 | "key": 0,
1097 | },
1098 | {
1099 | "at": 32,
1100 | "len": 1,
1101 | "tp": 'BR',
1102 | },
1103 | {
1104 | "at": 33,
1105 | "len": 3,
1106 | "tp": 'ST',
1107 | },
1108 | {
1109 | "at": 37,
1110 | "len": 4,
1111 | "tp": 'ST',
1112 | },
1113 | {
1114 | "at": 49,
1115 | "len": 21,
1116 | "key": 1,
1117 | },
1118 | ],
1119 | "ent": [{
1120 | "tp": 'LN',
1121 | "data": {
1122 | "url": 'http://localhost:6060',
1123 | },
1124 | },
1125 | {
1126 | "tp": 'LN',
1127 | "data": {
1128 | "url": 'http://localhost:8080',
1129 | },
1130 | },
1131 | ],
1132 | },
1133 | {
1134 | "txt": "Hi 👋🏼 Visit http://localh…",
1135 | "fmt": [{
1136 | "at": 11,
1137 | "len": 14,
1138 | "key": 0
1139 | }],
1140 | "ent": [{
1141 | "tp": "LN",
1142 | "data": {
1143 | "url": "http://localhost:6060"
1144 | }
1145 | }]
1146 | }
1147 | ]
1148 | ];
1149 |
1150 | test.each(preview_this)('Drafty.preview %j', (src, exp) => {
1151 | expect(Drafty.preview(src, 25)).toEqual(exp);
1152 | });
1153 |
1154 | // Drafty docs for testing Drafty.replyContent.
1155 | const reply_this = [
1156 | [{
1157 | "ent": [{
1158 | "data": {
1159 | "val": "usrCPvFc6lpAsw"
1160 | },
1161 | "tp": "MN"
1162 | }],
1163 | "fmt": [{
1164 | "len": 13
1165 | }, {
1166 | "at": 13,
1167 | "len": 1,
1168 | "tp": "BR"
1169 | }, {
1170 | "len": 38,
1171 | "tp": "QQ"
1172 | }],
1173 | "txt": "Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply."
1174 | },
1175 | {
1176 | "txt": "This is a Reply -> Forwa…"
1177 | },
1178 | ],
1179 | [{
1180 | "ent": [{
1181 | "data": {
1182 | "val": "usrCPvFc6lpAsw"
1183 | },
1184 | "tp": "MN"
1185 | }, {
1186 | "data": {
1187 | "val": "usrCPvFc6lpAsw"
1188 | },
1189 | "tp": "MN"
1190 | }],
1191 | "fmt": [{
1192 | "len": 15
1193 | }, {
1194 | "at": 15,
1195 | "len": 1,
1196 | "tp": "BR"
1197 | }, {
1198 | "at": 16,
1199 | "key": 1,
1200 | "len": 13
1201 | }, {
1202 | "at": 29,
1203 | "len": 1,
1204 | "tp": "BR"
1205 | }, {
1206 | "at": 16,
1207 | "len": 36,
1208 | "tp": "QQ"
1209 | }],
1210 | "txt": "➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply"
1211 | },
1212 | {
1213 | "fmt": [{
1214 | "at": 0,
1215 | "tp": "MN",
1216 | "len": 1
1217 | }],
1218 | "txt": "➦ This is a reply to rep…"
1219 | }
1220 | ],
1221 | [{
1222 | "txt": "Message with attachment",
1223 | "fmt": [{
1224 | "at": -1,
1225 | "len": 0,
1226 | "key": 0
1227 | }, {
1228 | "at": 8,
1229 | "len": 4,
1230 | "tp": "ST"
1231 | }],
1232 | "ent": [{
1233 | "data": {
1234 | "mime": "image/jpeg",
1235 | "name": "hello.jpg",
1236 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
1237 | "width": 100,
1238 | "height": 80
1239 | },
1240 | "tp": "EX"
1241 | }]
1242 | },
1243 | {
1244 | "txt": "Message with attachment ",
1245 | "fmt": [{
1246 | "at": 8,
1247 | "len": 4,
1248 | "tp": "ST"
1249 | }, {
1250 | "at": 23,
1251 | "len": 1,
1252 | "key": 0
1253 | }],
1254 | "ent": [{
1255 | "data": {
1256 | "mime": "image/jpeg",
1257 | "name": "hello.jpg",
1258 | "width": 100,
1259 | "height": 80
1260 | },
1261 | "tp": "EX"
1262 | }]
1263 | }
1264 | ],
1265 | [{
1266 | "fmt": [{
1267 | "at": -1
1268 | }],
1269 | "ent": [{
1270 | "data": {
1271 | "mime": "image/jpeg",
1272 | "name": "hello.jpg",
1273 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
1274 | "width": 100,
1275 | "height": 80
1276 | },
1277 | "tp": "EX"
1278 | }]
1279 | },
1280 | {
1281 | "txt": " ",
1282 | "fmt": [{
1283 | "at": 0,
1284 | "key": 0,
1285 | "len": 1
1286 | }],
1287 | "ent": [{
1288 | "tp": "EX",
1289 | "data": {
1290 | "height": 80,
1291 | "mime": "image/jpeg",
1292 | "name": "hello.jpg",
1293 | "width": 100
1294 | }
1295 | }]
1296 | },
1297 | ],
1298 | [{
1299 | "txt": " ",
1300 | "fmt": [{
1301 | "len": 1
1302 | }],
1303 | "ent": [{
1304 | "data": {
1305 | "height": 213,
1306 | "mime": "image/jpeg",
1307 | "name": "roses.jpg",
1308 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
1309 | "width": 638
1310 | },
1311 | "tp": "IM"
1312 | }]
1313 | },
1314 | {
1315 | "txt": " ",
1316 | "fmt": [{
1317 | "at": 0,
1318 | "len": 1,
1319 | "key": 0
1320 | }],
1321 | "ent": [{
1322 | "tp": "IM",
1323 | "data": {
1324 | "height": 213,
1325 | "mime": "image/jpeg",
1326 | "name": "roses.jpg",
1327 | "val": "<38992, bytes: 123456789012345678901234567890123456789012345678901234567890>",
1328 | "width": 638
1329 | }
1330 | }]
1331 | },
1332 | ],
1333 | ];
1334 |
1335 | test.each(reply_this)('Drafty.replyContent %j', (src, exp) => {
1336 | expect(Drafty.replyContent(src, 25)).toEqual(exp);
1337 | });
1338 |
1339 | // Drafty docs for testing Drafty.UNSAFE_toHTML and Drafty.toMarkdown.
1340 | const html_this = [
1341 | [{
1342 | "ent": [{
1343 | "data": {
1344 | "val": "usrCPvFc6lpAsw"
1345 | },
1346 | "tp": "MN"
1347 | }],
1348 | "fmt": [{
1349 | "len": 13
1350 | }, {
1351 | "at": 13,
1352 | "len": 1,
1353 | "tp": "BR"
1354 | }, {
1355 | "len": 38,
1356 | "tp": "QQ"
1357 | }],
1358 | "txt": "Alice Johnson This is a reply to replyThis is a Reply -> Forward -> Reply."
1359 | },
1360 | "This is a Reply -> Forward -> Reply.",
1361 | ],
1362 | [{
1363 | "ent": [{
1364 | "data": {
1365 | "val": "usrCPvFc6lpAsw"
1366 | },
1367 | "tp": "MN"
1368 | }, {
1369 | "data": {
1370 | "val": "usrCPvFc6lpAsw"
1371 | },
1372 | "tp": "MN"
1373 | }],
1374 | "fmt": [{
1375 | "len": 15
1376 | }, {
1377 | "at": 15,
1378 | "len": 1,
1379 | "tp": "BR"
1380 | }, {
1381 | "at": 16,
1382 | "key": 1,
1383 | "len": 13
1384 | }, {
1385 | "at": 29,
1386 | "len": 1,
1387 | "tp": "BR"
1388 | }, {
1389 | "at": 16,
1390 | "len": 36,
1391 | "tp": "QQ"
1392 | }],
1393 | "txt": "➦ Alice Johnson Alice Johnson This is a simple replyThis is a reply to reply"
1394 | },
1395 | "➦ Alice Johnson
This is a reply to reply"
1396 | ],
1397 | ];
1398 |
1399 | test.each(html_this)('Drafty.UNSAFE_toHTML %j', (src, exp) => {
1400 | expect(Drafty.UNSAFE_toHTML(src)).toEqual(exp);
1401 | });
1402 |
1403 | // Drafty docs for testing Drafty.toMarkdown.
1404 | const md_this = [
1405 |
1406 | [{
1407 | "fmt": [{
1408 | "at": 8,
1409 | "len": 4,
1410 | "tp": "ST"
1411 | }, {
1412 | "at": 14,
1413 | "len": 4,
1414 | "tp": "CO"
1415 | }, {
1416 | "at": 23,
1417 | "len": 6,
1418 | "tp": "EM"
1419 | }, {
1420 | "at": 31,
1421 | "len": 6,
1422 | "tp": "DL"
1423 | }],
1424 | "txt": "This is bold, code and italic, strike"
1425 | },
1426 | 'This is *bold*, `code` and _italic_, ~strike~'
1427 | ],
1428 | [{
1429 | "fmt": [{
1430 | "at": 14,
1431 | "len": 0,
1432 | "tp": "BR"
1433 | }, {
1434 | "at": 23,
1435 | "len": 15,
1436 | "tp": "ST"
1437 | }, {
1438 | "at": 32,
1439 | "len": 6,
1440 | "tp": "EM"
1441 | }],
1442 | "txt": "two lines withcombined bold and italic"
1443 | },
1444 | "two lines with\ncombined *bold and _italic_*",
1445 | ],
1446 | [{
1447 | "ent": [{
1448 | "data": {
1449 | "val": "mention"
1450 | },
1451 | "tp": "MN"
1452 | },
1453 | {
1454 | "data": {
1455 | "val": "hashtag"
1456 | },
1457 | "tp": "HT"
1458 | },
1459 | ],
1460 | "fmt": [{
1461 | "at": 10,
1462 | "key": 0,
1463 | "len": 8
1464 | },
1465 | {
1466 | "at": 25,
1467 | "key": 1,
1468 | "len": 8
1469 | },
1470 | ],
1471 | "txt": "this is a @mention and a #hashtag in a string"
1472 | },
1473 | "this is a @mention and a #hashtag in a string"
1474 | ],
1475 | ];
1476 |
1477 |
1478 | test.each(md_this)('Drafty.toMarkdown %j', (src, exp) => {
1479 | expect(Drafty.toMarkdown(src)).toEqual(exp);
1480 | });
1481 |
1482 | // Test for handling invalid Drafty.
1483 | const invalid_this = [
1484 | [{
1485 | "fmt": [null, {
1486 | "at": 5,
1487 | "len": 5,
1488 | "tp": "EM"
1489 | }],
1490 | "txt": "Null style in the middle"
1491 | },
1492 | "Null style in the middle",
1493 | ],
1494 | [{
1495 | "ent": [null, {
1496 | "data": {
1497 | "val": "usrCPvFc6lpAsw"
1498 | },
1499 | "tp": "MN"
1500 | }],
1501 | "fmt": [{
1502 | "len": 4
1503 | }, {
1504 | "at": 5,
1505 | "key": 1,
1506 | "len": 6
1507 | }],
1508 | "txt": "Null entity with reference"
1509 | },
1510 | "Null entity with reference"
1511 | ],
1512 | ];
1513 |
1514 | test.each(invalid_this)('Invalid Drafty %j', (src, exp) => {
1515 | expect(Drafty.UNSAFE_toHTML(src)).toEqual(exp);
1516 | });
1517 |
1518 | const quote_this = [
1519 | [{
1520 | "txt": "😀 b1👩🏽✈️b2 smile 123 123 123 123",
1521 | "fmt": [{
1522 | "tp": "ST",
1523 | "at": 8,
1524 | "len": 5
1525 | },
1526 | {
1527 | "tp": "EM",
1528 | "at": 22,
1529 | "len": 3
1530 | }
1531 | ]
1532 | }, {
1533 | "ent": [{
1534 | "data": {
1535 | "val": "usrbzV_721mIW0"
1536 | },
1537 | "tp": "MN",
1538 | }, ],
1539 | "fmt": [{
1540 | "at": 0,
1541 | "key": 0,
1542 | "len": 11,
1543 | }, {
1544 | "at": 11,
1545 | "len": 1,
1546 | "tp": "BR",
1547 | }, {
1548 | "at": 20,
1549 | "len": 5,
1550 | "tp": "ST",
1551 | }, {
1552 | "at": 34,
1553 | "len": 3,
1554 | "tp": "EM",
1555 | }, {
1556 | "at": 0,
1557 | "len": 41,
1558 | "tp": "QQ"
1559 | }, ],
1560 | "txt": "tinode-user 😀 b1👩🏽✈️b2 smile 123 123 123 123"
1561 | }],
1562 | ]
1563 |
1564 | test.each(quote_this)('Drafty.quote %j', (src, exp) => {
1565 | expect(Drafty.quote("tinode-user", "usrbzV_721mIW0", src)).toEqual(exp);
1566 | })
1567 |
--------------------------------------------------------------------------------
/src/fnd-topic.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Definition of 'fnd' topic.
3 | *
4 | * @copyright 2015-2023 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | import * as Const from './config.js';
9 | import Topic from './topic.js';
10 | import {
11 | mergeToCache
12 | } from './utils.js';
13 |
14 |
15 | /**
16 | * Special case of {@link Tinode.Topic} for searching for contacts and group topics
17 | * @extends Tinode.Topic
18 | *
19 | */
20 | export default class TopicFnd extends Topic {
21 | // List of users and topics uid or topic_name -> Contact object)
22 | _contacts = {};
23 |
24 | /**
25 | * Create TopicFnd.
26 | *
27 | * @param {TopicFnd.Callbacks} callbacks - Callbacks to receive various events.
28 | */
29 | constructor(callbacks) {
30 | super(Const.TOPIC_FND, callbacks);
31 | }
32 |
33 | // Override the original Topic._processMetaSubs
34 | _processMetaSubs(subs) {
35 | let updateCount = Object.getOwnPropertyNames(this._contacts).length;
36 | // Reset contact list.
37 | this._contacts = {};
38 | for (let idx in subs) {
39 | let sub = subs[idx];
40 | const indexBy = sub.topic ? sub.topic : sub.user;
41 |
42 | sub = mergeToCache(this._contacts, indexBy, sub);
43 | updateCount++;
44 |
45 | if (this.onMetaSub) {
46 | this.onMetaSub(sub);
47 | }
48 | }
49 |
50 | if (updateCount > 0 && this.onSubsUpdated) {
51 | this.onSubsUpdated(Object.keys(this._contacts));
52 | }
53 | }
54 |
55 | /**
56 | * Publishing to TopicFnd is not supported. {@link Topic#publish} is overriden and thows an {Error} if called.
57 | * @memberof Tinode.TopicFnd#
58 | * @throws {Error} Always throws an error.
59 | */
60 | publish() {
61 | return Promise.reject(new Error("Publishing to 'fnd' is not supported"));
62 | }
63 |
64 | /**
65 | * setMeta to TopicFnd resets contact list in addition to sending the message.
66 | * @memberof Tinode.TopicFnd#
67 | * @param {Tinode.SetParams} params parameters to update.
68 | * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
69 | */
70 | setMeta(params) {
71 | return Object.getPrototypeOf(TopicFnd.prototype).setMeta.call(this, params).then(_ => {
72 | if (Object.keys(this._contacts).length > 0) {
73 | this._contacts = {};
74 | if (this.onSubsUpdated) {
75 | this.onSubsUpdated([]);
76 | }
77 | }
78 | });
79 | }
80 |
81 | /**
82 | * Check if the given tag is unique by asking the server.
83 | * @param tag tag to check.
84 | * @return promise to be resolved with true if the tag is unique, false otherwise.
85 | */
86 | checkTagUniqueness(tag, caller) {
87 | return new Promise((resolve, reject) => {
88 | this.subscribe()
89 | .then(_ => this.setMeta({
90 | desc: {
91 | public: tag
92 | }
93 | }))
94 | .then(_ => this.getMeta(this.startMetaQuery().withTags().build()))
95 | .then(meta => {
96 | if (!meta || !Array.isArray(meta.tags) || meta.tags.length == 0) {
97 | resolve(true);
98 | }
99 | const tags = meta.tags.filter(t => t !== caller);
100 | resolve(tags.length == 0);
101 | })
102 | .catch(err => {
103 | reject(err);
104 | });
105 | });
106 | }
107 |
108 | /**
109 | * Iterate over found contacts. If callback is undefined, use {@link this.onMetaSub}.
110 | * @function
111 | * @memberof Tinode.TopicFnd#
112 | * @param {TopicFnd.ContactCallback} callback - Callback to call for each contact.
113 | * @param {Object} context - Context to use for calling the `callback`, i.e. the value of `this` inside the callback.
114 | */
115 | contacts(callback, context) {
116 | const cb = (callback || this.onMetaSub);
117 | if (cb) {
118 | for (let idx in this._contacts) {
119 | cb.call(context, this._contacts[idx], idx, this._contacts);
120 | }
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/large-file.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Utilities for uploading and downloading files.
3 | *
4 | * @copyright 2015-2023 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | import CommError from './comm-error.js';
9 | import {
10 | isUrlRelative,
11 | jsonParseHelper
12 | } from './utils.js';
13 |
14 | let XHRProvider;
15 |
16 | function addURLParam(relUrl, key, value) {
17 | const url = new URL(relUrl, window.location.origin);
18 | url.searchParams.append(key, value);
19 | return url.toString().substring(window.location.origin.length);
20 | }
21 |
22 | /**
23 | * @class LargeFileHelper - utilities for uploading and downloading files out of band.
24 | * Don't instantiate this class directly. Use {Tinode.getLargeFileHelper} instead.
25 | * @memberof Tinode
26 | *
27 | * @param {Tinode} tinode - the main Tinode object.
28 | * @param {string} version - protocol version, i.e. '0'.
29 | */
30 | export default class LargeFileHelper {
31 | constructor(tinode, version) {
32 | this._tinode = tinode;
33 | this._version = version;
34 |
35 | this._apiKey = tinode._apiKey;
36 | this._authToken = tinode.getAuthToken();
37 |
38 | // Ongoing requests.
39 | this.xhr = [];
40 | }
41 |
42 | /**
43 | * Start uploading the file to an endpoint at baseUrl.
44 | *
45 | * @memberof Tinode.LargeFileHelper#
46 | *
47 | * @param {string} baseUrl base URL of upload server.
48 | * @param {File|Blob} data data to upload.
49 | * @param {string} avatarFor topic name if the upload represents an avatar.
50 | * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
51 | * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
52 | * @param {Callback} onFailure callback. Called in case of a failure.
53 | *
54 | * @returns {Promise} resolved/rejected when the upload is completed/failed.
55 | */
56 | uploadWithBaseUrl(baseUrl, data, avatarFor, onProgress, onSuccess, onFailure) {
57 | let url = `/v${this._version}/file/u/`;
58 | if (baseUrl) {
59 | let base = baseUrl;
60 | if (base.endsWith('/')) {
61 | // Removing trailing slash.
62 | base = base.slice(0, -1);
63 | }
64 | if (base.startsWith('http://') || base.startsWith('https://')) {
65 | url = base + url;
66 | } else {
67 | throw new Error(`Invalid base URL '${baseUrl}'`);
68 | }
69 | }
70 |
71 | const instance = this;
72 | const xhr = new XHRProvider();
73 | this.xhr.push(xhr);
74 |
75 | xhr.open('POST', url, true);
76 | xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
77 | if (this._authToken) {
78 | xhr.setRequestHeader('X-Tinode-Auth', `Token ${this._authToken.token}`);
79 | }
80 |
81 | let toResolve = null;
82 | let toReject = null;
83 |
84 | const result = new Promise((resolve, reject) => {
85 | toResolve = resolve;
86 | toReject = reject;
87 | });
88 |
89 | xhr.upload.onprogress = e => {
90 | if (e.lengthComputable) {
91 | if (onProgress) {
92 | onProgress(e.loaded / e.total);
93 | }
94 | if (this.onProgress) {
95 | this.onProgress(e.loaded / e.total);
96 | }
97 | }
98 | };
99 |
100 | xhr.onload = function() {
101 | let pkt;
102 | try {
103 | pkt = JSON.parse(this.response, jsonParseHelper);
104 | } catch (err) {
105 | instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.response);
106 | pkt = {
107 | ctrl: {
108 | code: this.status,
109 | text: this.statusText
110 | }
111 | };
112 | }
113 |
114 | if (this.status >= 200 && this.status < 300) {
115 | if (toResolve) {
116 | toResolve(pkt.ctrl.params.url);
117 | }
118 | if (onSuccess) {
119 | onSuccess(pkt.ctrl);
120 | }
121 | } else if (this.status >= 400) {
122 | if (toReject) {
123 | toReject(new CommError(pkt.ctrl.text, pkt.ctrl.code));
124 | }
125 | if (onFailure) {
126 | onFailure(pkt.ctrl);
127 | }
128 | } else {
129 | instance._tinode.logger("ERROR: Unexpected server response status", this.status, this.response);
130 | }
131 | };
132 |
133 | xhr.onerror = function(e) {
134 | if (toReject) {
135 | toReject(e || new Error("failed"));
136 | }
137 | if (onFailure) {
138 | onFailure(null);
139 | }
140 | };
141 |
142 | xhr.onabort = function(e) {
143 | if (toReject) {
144 | toReject(new Error("upload cancelled by user"));
145 | }
146 | if (onFailure) {
147 | onFailure(null);
148 | }
149 | };
150 |
151 | try {
152 | const form = new FormData();
153 | form.append('file', data);
154 | form.set('id', this._tinode.getNextUniqueId());
155 | if (avatarFor) {
156 | form.set('topic', avatarFor);
157 | }
158 | xhr.send(form);
159 | } catch (err) {
160 | if (toReject) {
161 | toReject(err);
162 | }
163 | if (onFailure) {
164 | onFailure(null);
165 | }
166 | }
167 |
168 | return result;
169 | }
170 | /**
171 | * Start uploading the file to default endpoint.
172 | *
173 | * @memberof Tinode.LargeFileHelper#
174 | *
175 | * @param {File|Blob} data to upload
176 | * @param {string} avatarFor topic name if the upload represents an avatar.
177 | * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
178 | * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
179 | * @param {Callback} onFailure callback. Called in case of a failure.
180 | *
181 | * @returns {Promise} resolved/rejected when the upload is completed/failed.
182 | */
183 | upload(data, avatarFor, onProgress, onSuccess, onFailure) {
184 | const baseUrl = (this._tinode._secure ? 'https://' : 'http://') + this._tinode._host;
185 | return this.uploadWithBaseUrl(baseUrl, data, avatarFor, onProgress, onSuccess, onFailure);
186 | }
187 | /**
188 | * Download the file from a given URL using GET request. This method works with the Tinode server only.
189 | *
190 | * @memberof Tinode.LargeFileHelper#
191 | *
192 | * @param {string} relativeUrl - URL to download the file from. Must be relative url, i.e. must not contain the host.
193 | * @param {string=} filename - file name to use for the downloaded file.
194 | *
195 | * @returns {Promise} resolved/rejected when the download is completed/failed.
196 | */
197 | download(relativeUrl, filename, mimetype, onProgress, onError) {
198 | if (!isUrlRelative(relativeUrl)) {
199 | // As a security measure refuse to download from an absolute URL.
200 | if (onError) {
201 | onError(`The URL '${relativeUrl}' must be relative, not absolute`);
202 | }
203 | return;
204 | }
205 | if (!this._authToken) {
206 | if (onError) {
207 | onError("Must authenticate first");
208 | }
209 | return;
210 | }
211 | const instance = this;
212 |
213 | const xhr = new XHRProvider();
214 | this.xhr.push(xhr);
215 |
216 | // Add '&asatt=1' to URL to request 'Content-Disposition: attachment' response header.
217 | relativeUrl = addURLParam(relativeUrl, 'asatt', '1');
218 |
219 | // Get data as blob (stored by the browser as a temporary file).
220 | xhr.open('GET', relativeUrl, true);
221 | xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
222 | xhr.setRequestHeader('X-Tinode-Auth', 'Token ' + this._authToken.token);
223 | xhr.responseType = 'blob';
224 |
225 | xhr.onprogress = function(e) {
226 | if (onProgress) {
227 | // Passing e.loaded instead of e.loaded/e.total because e.total
228 | // is always 0 with gzip compression enabled by the server.
229 | onProgress(e.loaded);
230 | }
231 | };
232 |
233 | let toResolve = null;
234 | let toReject = null;
235 |
236 | const result = new Promise((resolve, reject) => {
237 | toResolve = resolve;
238 | toReject = reject;
239 | });
240 |
241 | // The blob needs to be saved as file. There is no known way to
242 | // save the blob as file other than to fake a click on an .
243 | xhr.onload = function() {
244 | if (this.status == 200) {
245 | const link = document.createElement('a');
246 | // URL.createObjectURL is not available in non-browser environment. This call will fail.
247 | link.href = window.URL.createObjectURL(new Blob([this.response], {
248 | type: mimetype
249 | }));
250 | link.style.display = 'none';
251 | link.setAttribute('download', filename);
252 | document.body.appendChild(link);
253 | link.click();
254 | document.body.removeChild(link);
255 | window.URL.revokeObjectURL(link.href);
256 | if (toResolve) {
257 | toResolve();
258 | }
259 | } else if (this.status >= 400 && toReject) {
260 | // The this.responseText is undefined, must use this.response which is a blob.
261 | // Need to convert this.response to JSON. The blob can only be accessed by the
262 | // FileReader.
263 | const reader = new FileReader();
264 | reader.onload = function() {
265 | try {
266 | const pkt = JSON.parse(this.result, jsonParseHelper);
267 | toReject(new CommError(pkt.ctrl.text, pkt.ctrl.code));
268 | } catch (err) {
269 | instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.result);
270 | toReject(err);
271 | }
272 | };
273 | reader.readAsText(this.response);
274 | }
275 | };
276 |
277 | xhr.onerror = function(e) {
278 | if (toReject) {
279 | toReject(new Error("failed"));
280 | }
281 | if (onError) {
282 | onError(e);
283 | }
284 | };
285 |
286 | xhr.onabort = function() {
287 | if (toReject) {
288 | toReject(null);
289 | }
290 | };
291 |
292 | try {
293 | xhr.send();
294 | } catch (err) {
295 | if (toReject) {
296 | toReject(err);
297 | }
298 | if (onError) {
299 | onError(err);
300 | }
301 | }
302 |
303 | return result;
304 | }
305 | /**
306 | * Try to cancel all ongoing uploads or downloads.
307 | * @memberof Tinode.LargeFileHelper#
308 | */
309 | cancel() {
310 | this.xhr.forEach(req => {
311 | if (req.readyState < 4) {
312 | req.abort();
313 | }
314 | });
315 | }
316 | /**
317 | * To use LargeFileHelper in a non browser context, supply XMLHttpRequest provider.
318 | * @static
319 | * @memberof LargeFileHelper
320 | * @param xhrProvider XMLHttpRequest provider, e.g. for node require('xhr')
.
321 | */
322 | static setNetworkProvider(xhrProvider) {
323 | XHRProvider = xhrProvider;
324 | }
325 | }
326 |
--------------------------------------------------------------------------------
/src/me-topic.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Definition of 'me' topic.
3 | *
4 | * @copyright 2015-2023 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | import AccessMode from './access-mode.js';
9 | import * as Const from './config.js';
10 | import Topic from './topic.js';
11 | import {
12 | mergeObj
13 | } from './utils.js';
14 |
15 | /**
16 | * @class TopicMe - special case of {@link Tinode.Topic} for
17 | * managing data of the current user, including contact list.
18 | * @extends Tinode.Topic
19 | * @memberof Tinode
20 | *
21 | * @param {TopicMe.Callbacks} callbacks - Callbacks to receive various events.
22 | */
23 | export default class TopicMe extends Topic {
24 | onContactUpdate;
25 |
26 | constructor(callbacks) {
27 | super(Const.TOPIC_ME, callbacks);
28 |
29 | // me-specific callbacks
30 | if (callbacks) {
31 | this.onContactUpdate = callbacks.onContactUpdate;
32 | }
33 | }
34 |
35 | // Override the original Topic._processMetaDesc.
36 | _processMetaDesc(desc) {
37 | // Check if online contacts need to be turned off because P permission was removed.
38 | const turnOff = (desc.acs && !desc.acs.isPresencer()) && (this.acs && this.acs.isPresencer());
39 |
40 | // Copy parameters from desc object to this topic.
41 | mergeObj(this, desc);
42 | this._tinode._db.updTopic(this);
43 | // Update current user's record in the global cache.
44 | this._updateCachedUser(this._tinode._myUID, desc);
45 |
46 | // 'P' permission was removed. All topics are offline now.
47 | if (turnOff) {
48 | this._tinode.mapTopics((cont) => {
49 | if (cont.online) {
50 | cont.online = false;
51 | cont.seen = Object.assign(cont.seen || {}, {
52 | when: new Date()
53 | });
54 | this._refreshContact('off', cont);
55 | }
56 | });
57 | }
58 |
59 | if (this.onMetaDesc) {
60 | this.onMetaDesc(this);
61 | }
62 | }
63 |
64 | // Override the original Topic._processMetaSubs
65 | _processMetaSubs(subs) {
66 | let updateCount = 0;
67 | subs.forEach((sub) => {
68 | const topicName = sub.topic;
69 | // Don't show 'me' and 'fnd' topics in the list of contacts.
70 | if (topicName == Const.TOPIC_FND || topicName == Const.TOPIC_ME) {
71 | return;
72 | }
73 | sub.online = !!sub.online;
74 |
75 | let cont = null;
76 | if (sub.deleted) {
77 | cont = sub;
78 | this._tinode.cacheRemTopic(topicName);
79 | this._tinode._db.remTopic(topicName);
80 | } else {
81 | // Ensure the values are defined and are integers.
82 | if (typeof sub.seq != 'undefined') {
83 | sub.seq = sub.seq | 0;
84 | sub.recv = sub.recv | 0;
85 | sub.read = sub.read | 0;
86 | sub.unread = sub.seq - sub.read;
87 | }
88 |
89 | const topic = this._tinode.getTopic(topicName);
90 | if (topic._new) {
91 | delete topic._new;
92 | }
93 |
94 | cont = mergeObj(topic, sub);
95 | this._tinode._db.updTopic(cont);
96 |
97 | if (Topic.isP2PTopicName(topicName)) {
98 | this._cachePutUser(topicName, cont);
99 | this._tinode._db.updUser(topicName, cont.public);
100 | }
101 | // Notify topic of the update if it's an external update.
102 | if (!sub._noForwarding && topic) {
103 | sub._noForwarding = true;
104 | topic._processMetaDesc(sub);
105 | }
106 | }
107 |
108 | updateCount++;
109 |
110 | if (this.onMetaSub) {
111 | this.onMetaSub(cont);
112 | }
113 | });
114 |
115 | if (this.onSubsUpdated && updateCount > 0) {
116 | const keys = [];
117 | subs.forEach((s) => {
118 | keys.push(s.topic);
119 | });
120 | this.onSubsUpdated(keys, updateCount);
121 | }
122 | }
123 |
124 | // Called by Tinode when meta.sub is recived.
125 | _processMetaCreds(creds, upd) {
126 | if (creds.length == 1 && creds[0] == Const.DEL_CHAR) {
127 | creds = [];
128 | }
129 | if (upd) {
130 | creds.forEach((cr) => {
131 | if (cr.val) {
132 | // Adding a credential.
133 | let idx = this._credentials.findIndex((el) => {
134 | return el.meth == cr.meth && el.val == cr.val;
135 | });
136 | if (idx < 0) {
137 | // Not found.
138 | if (!cr.done) {
139 | // Unconfirmed credential replaces previous unconfirmed credential of the same method.
140 | idx = this._credentials.findIndex((el) => {
141 | return el.meth == cr.meth && !el.done;
142 | });
143 | if (idx >= 0) {
144 | // Remove previous unconfirmed credential.
145 | this._credentials.splice(idx, 1);
146 | }
147 | }
148 | this._credentials.push(cr);
149 | } else {
150 | // Found. Maybe change 'done' status.
151 | this._credentials[idx].done = cr.done;
152 | }
153 | } else if (cr.resp) {
154 | // Handle credential confirmation.
155 | const idx = this._credentials.findIndex((el) => {
156 | return el.meth == cr.meth && !el.done;
157 | });
158 | if (idx >= 0) {
159 | this._credentials[idx].done = true;
160 | }
161 | }
162 | });
163 | } else {
164 | this._credentials = creds;
165 | }
166 | if (this.onCredsUpdated) {
167 | this.onCredsUpdated(this._credentials);
168 | }
169 | }
170 |
171 | // Process presence change message
172 | _routePres(pres) {
173 | if (pres.what == 'term') {
174 | // The 'me' topic itself is detached. Mark as unsubscribed.
175 | this._resetSub();
176 | return;
177 | }
178 |
179 | if (pres.what == 'upd' && pres.src == Const.TOPIC_ME) {
180 | // Update to me's description. Request updated value.
181 | this.getMeta(this.startMetaQuery().withDesc().build());
182 | return;
183 | }
184 |
185 | const cont = this._tinode.cacheGetTopic(pres.src);
186 | if (cont) {
187 | switch (pres.what) {
188 | case 'on': // topic came online
189 | cont.online = true;
190 | break;
191 | case 'off': // topic went offline
192 | if (cont.online) {
193 | cont.online = false;
194 | cont.seen = Object.assign(cont.seen || {}, {
195 | when: new Date()
196 | });
197 | }
198 | break;
199 | case 'msg': // new message received
200 | cont._updateReceived(pres.seq, pres.act);
201 | break;
202 | case 'upd': // desc updated
203 | // Request updated subscription.
204 | this.getMeta(this.startMetaQuery().withLaterOneSub(pres.src).build());
205 | break;
206 | case 'acs': // access mode changed
207 | // If 'tgt' is not set then this is an update to the permissions of the current user.
208 | // Otherwise it's an update to group topic subscriber permissions while the topic is offline.
209 | // Just gnore it then.
210 | if (!pres.tgt) {
211 | if (cont.acs) {
212 | cont.acs.updateAll(pres.dacs);
213 | } else {
214 | cont.acs = new AccessMode().updateAll(pres.dacs);
215 | }
216 | cont.touched = new Date();
217 | }
218 | break;
219 | case 'ua':
220 | // user agent changed.
221 | cont.seen = {
222 | when: new Date(),
223 | ua: pres.ua
224 | };
225 | break;
226 | case 'recv':
227 | // user's other session marked some messges as received.
228 | pres.seq = pres.seq | 0;
229 | cont.recv = cont.recv ? Math.max(cont.recv, pres.seq) : pres.seq;
230 | break;
231 | case 'read':
232 | // user's other session marked some messages as read.
233 | pres.seq = pres.seq | 0;
234 | cont.read = cont.read ? Math.max(cont.read, pres.seq) : pres.seq;
235 | cont.recv = cont.recv ? Math.max(cont.read, cont.recv) : cont.recv;
236 | cont.unread = cont.seq - cont.read;
237 | break;
238 | case 'gone':
239 | // topic deleted or unsubscribed from.
240 | this._tinode.cacheRemTopic(pres.src);
241 | if (!cont._deleted) {
242 | cont._deleted = true;
243 | cont._attached = false;
244 | this._tinode._db.markTopicAsDeleted(pres.src, true);
245 | } else {
246 | this._tinode._db.remTopic(pres.src);
247 | }
248 | break;
249 | case 'del':
250 | // Update topic.del value.
251 | break;
252 | default:
253 | this._tinode.logger("INFO: Unsupported presence update in 'me'", pres.what);
254 | }
255 |
256 | this._refreshContact(pres.what, cont);
257 | } else {
258 | if (pres.what == 'acs') {
259 | // New subscriptions and deleted/banned subscriptions have full
260 | // access mode (no + or - in the dacs string). Changes to known subscriptions are sent as
261 | // deltas, but they should not happen here.
262 | const acs = new AccessMode(pres.dacs);
263 | if (!acs || acs.mode == AccessMode._INVALID) {
264 | this._tinode.logger("ERROR: Invalid access mode update", pres.src, pres.dacs);
265 | return;
266 | } else if (acs.mode == AccessMode._NONE) {
267 | this._tinode.logger("WARNING: Removing non-existent subscription", pres.src, pres.dacs);
268 | return;
269 | } else {
270 | // New subscription. Send request for the full description.
271 | // Using .withOneSub (not .withLaterOneSub) to make sure IfModifiedSince is not set.
272 | this.getMeta(this.startMetaQuery().withOneSub(undefined, pres.src).build());
273 | // Create a dummy entry to catch online status update.
274 | const dummy = this._tinode.getTopic(pres.src);
275 | dummy.topic = pres.src;
276 | dummy.online = false;
277 | dummy.acs = acs;
278 | this._tinode._db.updTopic(dummy);
279 | }
280 | } else if (pres.what == 'tags') {
281 | this.getMeta(this.startMetaQuery().withTags().build());
282 | } else if (pres.what == 'msg') {
283 | // Message received for un unknown (previously deleted) topic.
284 | this.getMeta(this.startMetaQuery().withOneSub(undefined, pres.src).build());
285 | // Create an entry to catch updates and messages.
286 | const dummy = this._tinode.getTopic(pres.src);
287 | dummy._deleted = false;
288 | this._tinode._db.updTopic(dummy);
289 | }
290 |
291 | this._refreshContact(pres.what, cont);
292 | }
293 |
294 | if (this.onPres) {
295 | this.onPres(pres);
296 | }
297 | }
298 |
299 | // Contact is updated, execute callbacks.
300 | _refreshContact(what, cont) {
301 | if (this.onContactUpdate) {
302 | this.onContactUpdate(what, cont);
303 | }
304 | }
305 |
306 | /**
307 | * Publishing to TopicMe is not supported. {@link Topic#publish} is overriden and thows an {Error} if called.
308 | * @memberof Tinode.TopicMe#
309 | * @throws {Error} Always throws an error.
310 | */
311 | publish() {
312 | return Promise.reject(new Error("Publishing to 'me' is not supported"));
313 | }
314 |
315 | /**
316 | * Delete validation credential.
317 | * @memberof Tinode.TopicMe#
318 | *
319 | * @param {string} topic - Name of the topic to delete
320 | * @param {string} user - User ID to remove.
321 | * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
322 | */
323 | delCredential(method, value) {
324 | if (!this._attached) {
325 | return Promise.reject(new Error("Cannot delete credential in inactive 'me' topic"));
326 | }
327 | // Send {del} message, return promise
328 | return this._tinode.delCredential(method, value).then(ctrl => {
329 | // Remove deleted credential from the cache.
330 | const index = this._credentials.findIndex((el) => {
331 | return el.meth == method && el.val == value;
332 | });
333 | if (index > -1) {
334 | this._credentials.splice(index, 1);
335 | }
336 | // Notify listeners
337 | if (this.onCredsUpdated) {
338 | this.onCredsUpdated(this._credentials);
339 | }
340 | return ctrl;
341 | });
342 | }
343 |
344 | /**
345 | * @callback contactFilter
346 | * @param {Object} contact to check for inclusion.
347 | * @returns {boolean} true
if contact should be processed, false
to exclude it.
348 | */
349 | /**
350 | * Iterate over cached contacts.
351 | *
352 | * @function
353 | * @memberof Tinode.TopicMe#
354 | * @param {TopicMe.ContactCallback} callback - Callback to call for each contact.
355 | * @param {contactFilter=} filter - Optionally filter contacts; include all if filter is false-ish, otherwise
356 | * include those for which filter returns true-ish.
357 | * @param {Object=} context - Context to use for calling the `callback`, i.e. the value of `this` inside the callback.
358 | */
359 | contacts(callback, filter, context) {
360 | this._tinode.mapTopics((c, idx) => {
361 | if (c.isCommType() && (!filter || filter(c))) {
362 | callback.call(context, c, idx);
363 | }
364 | });
365 | }
366 |
367 | /**
368 | * Get a contact from cache.
369 | * @memberof Tinode.TopicMe#
370 | *
371 | * @param {string} name - Name of the contact to get, either a UID (for p2p topics) or a topic name.
372 | * @returns {Tinode.Contact} - Contact or `undefined`.
373 | */
374 | getContact(name) {
375 | return this._tinode.cacheGetTopic(name);
376 | }
377 |
378 | /**
379 | * Get access mode of a given contact from cache.
380 | * @memberof Tinode.TopicMe#
381 | *
382 | * @param {string} name - Name of the contact to get access mode for, either a UID (for p2p topics)
383 | * or a topic name; if missing, access mode for the 'me' topic itself.
384 | * @returns {string} - access mode, such as `RWP`.
385 | */
386 | getAccessMode(name) {
387 | if (name) {
388 | const cont = this._tinode.cacheGetTopic(name);
389 | return cont ? cont.acs : null;
390 | }
391 | return this.acs;
392 | }
393 |
394 | /**
395 | * Check if contact is archived, i.e. contact.private.arch == true.
396 | * @memberof Tinode.TopicMe#
397 | *
398 | * @param {string} name - Name of the contact to check archived status, either a UID (for p2p topics) or a topic name.
399 | * @returns {boolean} - true if contact is archived, false otherwise.
400 | */
401 | isArchived(name) {
402 | const cont = this._tinode.cacheGetTopic(name);
403 | return cont && cont.private && !!cont.private.arch;
404 | }
405 |
406 | /**
407 | * @typedef Tinode.Credential
408 | * @memberof Tinode
409 | * @type Object
410 | * @property {string} meth - validation method such as 'email' or 'tel'.
411 | * @property {string} val - credential value, i.e. 'jdoe@example.com' or '+17025551234'
412 | * @property {boolean} done - true if credential is validated.
413 | */
414 | /**
415 | * Get the user's credentials: email, phone, etc.
416 | * @memberof Tinode.TopicMe#
417 | *
418 | * @returns {Tinode.Credential[]} - array of credentials.
419 | */
420 | getCredentials() {
421 | return this._credentials;
422 | }
423 | }
424 |
--------------------------------------------------------------------------------
/src/meta-builder.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Helper class for constructing {@link Tinode.GetQuery}.
3 | *
4 | * @copyright 2015-2023 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | import {
9 | listToRanges,
10 | normalizeRanges
11 | } from './utils';
12 |
13 | /**
14 | * Helper class for constructing {@link Tinode.GetQuery}.
15 | *
16 | * @class MetaGetBuilder
17 | * @memberof Tinode
18 | *
19 | * @param {Tinode.Topic} parent topic which instantiated this builder.
20 | */
21 | export default class MetaGetBuilder {
22 | constructor(parent) {
23 | this.topic = parent;
24 | this.what = {};
25 | }
26 |
27 | // Get timestamp of the most recent desc update.
28 | #get_desc_ims() {
29 | return this.topic._deleted ? undefined : this.topic.updated;
30 | }
31 |
32 | // Get timestamp of the most recent subs update.
33 | #get_subs_ims() {
34 | if (this.topic.isP2PType()) {
35 | return this.#get_desc_ims();
36 | }
37 | return this.topic._deleted ? undefined : this.topic._lastSubsUpdate;
38 | }
39 | /**
40 | * Add query parameters to fetch messages within explicit limits.
41 | * @memberof Tinode.MetaGetBuilder#
42 | *
43 | * @param {number=} since - messages newer than this (inclusive);
44 | * @param {number=} before - older than this (exclusive)
45 | * @param {number=} limit - number of messages to fetch
46 | * @returns {Tinode.MetaGetBuilder} this
object.
47 | */
48 | withData(since, before, limit) {
49 | this.what['data'] = {
50 | since: since,
51 | before: before,
52 | limit: limit
53 | };
54 | return this;
55 | }
56 | /**
57 | * Add query parameters to fetch messages newer than the latest saved message.
58 | * @memberof Tinode.MetaGetBuilder#
59 | *
60 | * @param {number=} limit - number of messages to fetch
61 | *
62 | * @returns {Tinode.MetaGetBuilder} this
object.
63 | */
64 | withLaterData(limit) {
65 | return this.withData(this.topic._maxSeq > 0 ? this.topic._maxSeq + 1 : undefined, undefined, limit);
66 | }
67 | /**
68 | * Add query parameters to fetch messages within ID ranges.
69 | * @memberof Tinode.MetaGetBuilder#
70 | *
71 | * @param {Array.} ranges - ranges of seq IDs to fetch.
72 | * @param {number=} limit - maximum number of messages to fetch.
73 | * @returns {Tinode.MetaGetBuilder} this
object.
74 | */
75 | withDataRanges(ranges, limit) {
76 | this.what['data'] = {
77 | ranges: normalizeRanges(ranges, this.topic._maxSeq),
78 | limit: limit
79 | };
80 | return this;
81 | }
82 | /**
83 | * Add query parameters to fetch messages by an array of IDs.
84 | * @memberof Tinode.MetaGetBuilder#
85 | *
86 | * @param {number[]} list - array of seq IDs to fetch.
87 | * @returns {Tinode.MetaGetBuilder} this
object.
88 | */
89 | withDataList(list) {
90 | return this.withDataRanges(listToRanges(list));
91 | }
92 | /**
93 | * Add query parameters to fetch messages older than the earliest saved message.
94 | * @memberof Tinode.MetaGetBuilder#
95 | *
96 | * @param {number=} limit - maximum number of messages to fetch.
97 | *
98 | * @returns {Tinode.MetaGetBuilder} this
object.
99 | */
100 | withEarlierData(limit) {
101 | return this.withData(undefined, this.topic._minSeq > 0 ? this.topic._minSeq : undefined, limit);
102 | }
103 | /**
104 | * Add query parameters to fetch topic description if it's newer than the given timestamp.
105 | * @memberof Tinode.MetaGetBuilder#
106 | *
107 | * @param {Date=} ims - fetch messages newer than this timestamp.
108 | *
109 | * @returns {Tinode.MetaGetBuilder} this
object.
110 | */
111 | withDesc(ims) {
112 | this.what['desc'] = {
113 | ims: ims
114 | };
115 | return this;
116 | }
117 | /**
118 | * Add query parameters to fetch topic description if it's newer than the last update.
119 | * @memberof Tinode.MetaGetBuilder#
120 | *
121 | * @returns {Tinode.MetaGetBuilder} this
object.
122 | */
123 | withLaterDesc() {
124 | return this.withDesc(this.#get_desc_ims());
125 | }
126 | /**
127 | * Add query parameters to fetch subscriptions.
128 | * @memberof Tinode.MetaGetBuilder#
129 | *
130 | * @param {Date=} ims - fetch subscriptions modified more recently than this timestamp
131 | * @param {number=} limit - maximum number of subscriptions to fetch.
132 | * @param {string=} userOrTopic - user ID or topic name to fetch for fetching one subscription.
133 | *
134 | * @returns {Tinode.MetaGetBuilder} this
object.
135 | */
136 | withSub(ims, limit, userOrTopic) {
137 | const opts = {
138 | ims: ims,
139 | limit: limit
140 | };
141 | if (this.topic.getType() == 'me') {
142 | opts.topic = userOrTopic;
143 | } else {
144 | opts.user = userOrTopic;
145 | }
146 | this.what['sub'] = opts;
147 | return this;
148 | }
149 | /**
150 | * Add query parameters to fetch a single subscription.
151 | * @memberof Tinode.MetaGetBuilder#
152 | *
153 | * @param {Date=} ims - fetch subscriptions modified more recently than this timestamp
154 | * @param {string=} userOrTopic - user ID or topic name to fetch for fetching one subscription.
155 | *
156 | * @returns {Tinode.MetaGetBuilder} this
object.
157 | */
158 | withOneSub(ims, userOrTopic) {
159 | return this.withSub(ims, undefined, userOrTopic);
160 | }
161 | /**
162 | * Add query parameters to fetch a single subscription if it's been updated since the last update.
163 | * @memberof Tinode.MetaGetBuilder#
164 | *
165 | * @param {string=} userOrTopic - user ID or topic name to fetch for fetching one subscription.
166 | *
167 | * @returns {Tinode.MetaGetBuilder} this
object.
168 | */
169 | withLaterOneSub(userOrTopic) {
170 | return this.withOneSub(this.topic._lastSubsUpdate, userOrTopic);
171 | }
172 | /**
173 | * Add query parameters to fetch subscriptions updated since the last update.
174 | * @memberof Tinode.MetaGetBuilder#
175 | *
176 | * @param {number=} limit - maximum number of subscriptions to fetch.
177 | *
178 | * @returns {Tinode.MetaGetBuilder} this
object.
179 | */
180 | withLaterSub(limit) {
181 | return this.withSub(this.#get_subs_ims(), limit);
182 | }
183 | /**
184 | * Add query parameters to fetch topic tags.
185 | * @memberof Tinode.MetaGetBuilder#
186 | *
187 | * @returns {Tinode.MetaGetBuilder} this
object.
188 | */
189 | withTags() {
190 | this.what['tags'] = true;
191 | return this;
192 | }
193 | /**
194 | * Add query parameters to fetch user's credentials. 'me'
topic only.
195 | * @memberof Tinode.MetaGetBuilder#
196 | *
197 | * @returns {Tinode.MetaGetBuilder} this
object.
198 | */
199 | withCred() {
200 | if (this.topic.getType() == 'me') {
201 | this.what['cred'] = true;
202 | } else {
203 | this.topic._tinode.logger("ERROR: Invalid topic type for MetaGetBuilder:withCreds", this.topic.getType());
204 | }
205 | return this;
206 | }
207 | /**
208 | * Add query parameters to fetch topic tags.
209 | * @memberof Tinode.MetaGetBuilder#
210 | *
211 | * @returns {Tinode.MetaGetBuilder} this
object.
212 | */
213 | withAux() {
214 | this.what['aux'] = true;
215 | return this;
216 | }
217 | /**
218 | * Add query parameters to fetch deleted messages within explicit limits. Any/all parameters can be null.
219 | * @memberof Tinode.MetaGetBuilder#
220 | *
221 | * @param {number=} since - ids of messages deleted since this 'del' id (inclusive)
222 | * @param {number=} limit - number of deleted message ids to fetch
223 | *
224 | * @returns {Tinode.MetaGetBuilder} this
object.
225 | */
226 | withDel(since, limit) {
227 | if (since || limit) {
228 | this.what['del'] = {
229 | since: since,
230 | limit: limit
231 | };
232 | }
233 | return this;
234 | }
235 | /**
236 | * Add query parameters to fetch messages deleted after the saved 'del'
id.
237 | * @memberof Tinode.MetaGetBuilder#
238 | *
239 | * @param {number=} limit - number of deleted message ids to fetch
240 | *
241 | * @returns {Tinode.MetaGetBuilder} this
object.
242 | */
243 | withLaterDel(limit) {
244 | // Specify 'since' only if we have already received some messages. If
245 | // we have no locally cached messages then we don't care if any messages were deleted.
246 | return this.withDel(this.topic._maxSeq > 0 ? this.topic._maxDel + 1 : undefined, limit);
247 | }
248 |
249 | /**
250 | * Extract subquery: get an object that contains specified subquery.
251 | * @memberof Tinode.MetaGetBuilder#
252 | * @param {string} what - subquery to return: one of 'data', 'sub', 'desc', 'tags', 'cred', 'del'.
253 | * @returns {Object} requested subquery or undefined
.
254 | */
255 | extract(what) {
256 | return this.what[what];
257 | }
258 |
259 | /**
260 | * Construct parameters.
261 | * @memberof Tinode.MetaGetBuilder#
262 | *
263 | * @returns {Tinode.GetQuery} Get query
264 | */
265 | build() {
266 | const what = [];
267 | let params = {};
268 | ['data', 'sub', 'desc', 'tags', 'cred', 'aux', 'del'].forEach((key) => {
269 | if (this.what.hasOwnProperty(key)) {
270 | what.push(key);
271 | if (Object.getOwnPropertyNames(this.what[key]).length > 0) {
272 | params[key] = this.what[key];
273 | }
274 | }
275 | });
276 | if (what.length > 0) {
277 | params.what = what.join(' ');
278 | } else {
279 | params = undefined;
280 | }
281 | return params;
282 | }
283 | }
284 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Utilities used in multiple places.
3 | *
4 | * @copyright 2015-2022 Tinode LLC.
5 | */
6 | 'use strict';
7 |
8 | import AccessMode from './access-mode.js';
9 | import {
10 | DEL_CHAR,
11 | LOCAL_SEQID
12 | } from './config.js';
13 |
14 | // Attempt to convert date and AccessMode strings to objects.
15 | export function jsonParseHelper(key, val) {
16 | // Try to convert string timestamps with optional milliseconds to Date,
17 | // e.g. 2015-09-02T01:45:43[.123]Z
18 | if (typeof val == 'string' && val.length >= 20 && val.length <= 24 && ['ts', 'touched', 'updated', 'created', 'when', 'deleted', 'expires'].includes(key)) {
19 | const date = new Date(val);
20 | if (!isNaN(date)) {
21 | return date;
22 | }
23 | } else if (key === 'acs' && typeof val === 'object') {
24 | return new AccessMode(val);
25 | }
26 | return val;
27 | }
28 |
29 | // Checks if URL is a relative url, i.e. has no 'scheme://', including the case of missing scheme '//'.
30 | // The scheme is expected to be RFC-compliant, e.g. [a-z][a-z0-9+.-]*
31 | // example.html - ok
32 | // https:example.com - not ok.
33 | // http:/example.com - not ok.
34 | // ' ↲ https://example.com' - not ok. (↲ means carriage return)
35 | export function isUrlRelative(url) {
36 | return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
37 | }
38 |
39 | function isValidDate(d) {
40 | return (d instanceof Date) && !isNaN(d) && (d.getTime() != 0);
41 | }
42 |
43 | // RFC3339 formater of Date
44 | export function rfc3339DateString(d) {
45 | if (!isValidDate(d)) {
46 | return undefined;
47 | }
48 |
49 | const pad = function(val, sp) {
50 | sp = sp || 2;
51 | return '0'.repeat(sp - ('' + val).length) + val;
52 | };
53 |
54 | const millis = d.getUTCMilliseconds();
55 | return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()) +
56 | 'T' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) +
57 | (millis ? '.' + pad(millis, 3) : '') + 'Z';
58 | }
59 |
60 | // Recursively merge src's own properties to dst.
61 | // Ignore properties where ignore[property] is true.
62 | // Array and Date objects are shallow-copied.
63 | export function mergeObj(dst, src, ignore) {
64 | if (typeof src != 'object') {
65 | if (src === undefined) {
66 | return dst;
67 | }
68 | if (src === DEL_CHAR) {
69 | return undefined;
70 | }
71 | return src;
72 | }
73 | // JS is crazy: typeof null is 'object'.
74 | if (src === null) {
75 | return src;
76 | }
77 |
78 | // Handle Date
79 | if (src instanceof Date && !isNaN(src)) {
80 | return (!dst || !(dst instanceof Date) || isNaN(dst) || dst < src) ? src : dst;
81 | }
82 |
83 | // Access mode
84 | if (src instanceof AccessMode) {
85 | return new AccessMode(src);
86 | }
87 |
88 | // Handle Array
89 | if (src instanceof Array) {
90 | return src;
91 | }
92 |
93 | if (!dst || dst === DEL_CHAR) {
94 | dst = src.constructor();
95 | }
96 |
97 | for (let prop in src) {
98 | if (src.hasOwnProperty(prop) && (!ignore || !ignore[prop]) && (prop != '_noForwarding')) {
99 | try {
100 | dst[prop] = mergeObj(dst[prop], src[prop]);
101 | } catch (err) {
102 | // FIXME: probably need to log something here.
103 | }
104 | }
105 | }
106 | return dst;
107 | }
108 |
109 | // Update object stored in a cache. Returns updated value.
110 | export function mergeToCache(cache, key, newval, ignore) {
111 | cache[key] = mergeObj(cache[key], newval, ignore);
112 | return cache[key];
113 | }
114 |
115 | // Strips all values from an object of they evaluate to false or if their name starts with '_'.
116 | // Used on all outgoing object before serialization to string.
117 | export function simplify(obj) {
118 | Object.keys(obj).forEach((key) => {
119 | if (key[0] == '_') {
120 | // Strip fields like "obj._key".
121 | delete obj[key];
122 | } else if (!obj[key]) {
123 | // Strip fields which evaluate to false.
124 | delete obj[key];
125 | } else if (Array.isArray(obj[key]) && obj[key].length == 0) {
126 | // Strip empty arrays.
127 | delete obj[key];
128 | } else if (!obj[key]) {
129 | // Strip fields which evaluate to false.
130 | delete obj[key];
131 | } else if (obj[key] instanceof Date) {
132 | // Strip invalid or zero date.
133 | if (!isValidDate(obj[key])) {
134 | delete obj[key];
135 | }
136 | } else if (typeof obj[key] == 'object') {
137 | simplify(obj[key]);
138 | // Strip empty objects.
139 | if (Object.getOwnPropertyNames(obj[key]).length == 0) {
140 | delete obj[key];
141 | }
142 | }
143 | });
144 | return obj;
145 | };
146 |
147 |
148 | // Trim whitespace, convert to lowercase, strip empty, short, and duplicate elements elements.
149 | // If the result is an empty array, add a single element "\u2421" (Unicode Del character).
150 | export function normalizeArray(arr) {
151 | let out = [];
152 | if (Array.isArray(arr)) {
153 | // Trim, throw away very short and empty tags.
154 | for (let i = 0, l = arr.length; i < l; i++) {
155 | let t = arr[i];
156 | if (t) {
157 | t = t.trim().toLowerCase();
158 | if (t.length > 1) {
159 | out.push(t);
160 | }
161 | }
162 | }
163 | out = out.sort().filter((item, pos, ary) => {
164 | return !pos || item != ary[pos - 1];
165 | });
166 | }
167 | if (out.length == 0) {
168 | // Add single tag with a Unicode Del character, otherwise an ampty array
169 | // is ambiguos. The Del tag will be stripped by the server.
170 | out.push(DEL_CHAR);
171 | }
172 | return out;
173 | }
174 |
175 | // Convert input to valid ranges of IDs.
176 | export function normalizeRanges(ranges, maxSeq) {
177 | if (!Array.isArray(ranges)) {
178 | return [];
179 | }
180 |
181 | // Sort ranges in accending order by low, then descending by hi.
182 | ranges.sort((r1, r2) => {
183 | if (r1.low < r2.low) {
184 | return -1;
185 | }
186 | if (r1.low == r2.low) {
187 | return (r2.hi | 0) - r1.hi;
188 | }
189 | return 1;
190 | });
191 |
192 | // Remove pending messages from ranges possibly clipping some ranges.
193 | ranges = ranges.reduce((out, r) => {
194 | if (r.low < LOCAL_SEQID && r.low > 0) {
195 | if (!r.hi || r.hi < LOCAL_SEQID) {
196 | out.push(r);
197 | } else {
198 | // Clip hi to max allowed value.
199 | out.push({
200 | low: r.low,
201 | hi: maxSeq + 1
202 | });
203 | }
204 | }
205 | return out;
206 | }, []);
207 |
208 | // Merge overlapping ranges.
209 | ranges = ranges.reduce((out, r) => {
210 | if (out.length == 0) {
211 | out.push(r);
212 | } else {
213 | let prev = out[out.length - 1];
214 | if (r.low <= prev.hi) {
215 | prev.hi = Math.max(prev.hi, r.hi);
216 | } else {
217 | out.push(r);
218 | }
219 | }
220 | return out;
221 | }, []);
222 |
223 | return ranges;
224 | }
225 |
226 | // Convert array of IDs to array of ranges.
227 | export function listToRanges(list) {
228 | // Sort the list in ascending order
229 | list.sort((a, b) => a - b);
230 | // Convert the array of IDs to ranges.
231 | return list.reduce((out, id) => {
232 | if (out.length == 0) {
233 | // First element.
234 | out.push({
235 | low: id
236 | });
237 | } else {
238 | let prev = out[out.length - 1];
239 | if ((!prev.hi && (id != prev.low + 1)) || (id > prev.hi)) {
240 | // New range.
241 | out.push({
242 | low: id
243 | });
244 | } else {
245 | // Expand existing range.
246 | prev.hi = prev.hi ? Math.max(prev.hi, id + 1) : id + 1;
247 | }
248 | }
249 | return out;
250 | }, []);
251 | }
252 |
253 | // Cuts 'clip' range out of the 'src' range.
254 | // Returns an array with 0, 1 or 2 elements.
255 | export function clipOutRange(src, clip) {
256 | if (clip.hi <= src.low || clip.low >= src.hi) {
257 | // Clip is completely outside of src, no intersection.
258 | return [src];
259 | }
260 |
261 |
262 | if (clip.low <= src.low) {
263 | if (clip.hi >= src.hi) {
264 | // The source range is completely inside the clipping range.
265 | return [];
266 | }
267 | // Partial clipping at the top.
268 | return [{
269 | low: clip.hi,
270 | hi: src.hi
271 | }];
272 | }
273 |
274 | // Range on the lower end.
275 | const result = [{
276 | low: src.low,
277 | hi: clip.low
278 | }];
279 | if (clip.hi < src.hi) {
280 | // Maybe a range on the higher end, if clip is completely inside the source.
281 | result.push({
282 | low: clip.hi,
283 | hi: src.hi
284 | });
285 | }
286 |
287 | return result;
288 | }
289 |
290 | // Cuts 'src' range to be completely within 'clip' range.
291 | // Returns clipped range or null if 'src' is outside of 'clip'.
292 | export function clipInRange(src, clip) {
293 | if (clip.hi <= src.low || clip.low >= src.hi) {
294 | // The src is completely outside of the clip, no intersection.
295 | return null;
296 | }
297 |
298 | if (src.low >= clip.low && src.hi <= clip.hi) {
299 | // Src is completely within the clip, return the entire src.
300 | return src;
301 | }
302 |
303 | // Partial overlap.
304 | return {
305 | low: Math.max(src.low, clip.low),
306 | hi: Math.min(src.hi, clip.hi)
307 | };
308 |
309 | return result;
310 | }
311 |
--------------------------------------------------------------------------------
/src/utils.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | isUrlRelative,
3 | rfc3339DateString,
4 | mergeObj,
5 | simplify,
6 | normalizeArray,
7 | normalizeRanges,
8 | listToRanges,
9 | clipInRange,
10 | clipOutRange
11 | } from "./utils";
12 | import {
13 | DEL_CHAR
14 | } from './config.js';
15 |
16 | test('isUrlRelative', () => {
17 | expect(isUrlRelative('example.html')).toBe(true);
18 | expect(isUrlRelative('https:example.com')).toBe(false);
19 | expect(isUrlRelative('http:/example.com')).toBe(false);
20 | expect(isUrlRelative(' \n https://example.com')).toBe(false);
21 | });
22 |
23 | test('rfc3339DateString', () => {
24 | expect(rfc3339DateString(new Date(Date.UTC(2020, 1, 2, 3, 4, 5, 6)))).toBe('2020-02-02T03:04:05.006Z');
25 | expect(rfc3339DateString(new Date(Date.UTC(2020, 1, 2, 3, 4, 5)))).toBe('2020-02-02T03:04:05Z');
26 | expect(rfc3339DateString(new Date(0))).toBe(undefined);
27 | expect(rfc3339DateString(new Date(''))).toBe(undefined);
28 | });
29 |
30 | // Recursively merge src's own properties to dst.
31 | // Ignore properties where ignore[property] is true.
32 | // Array and Date objects are shallow-copied.
33 | test('mergeObj', () => {
34 | const dst = {
35 | a: 1,
36 | b: 2
37 | };
38 | const src = {
39 | b: 3,
40 | c: 4
41 | };
42 | expect(mergeObj({
43 | a: 1,
44 | b: 2
45 | }, {
46 | b: 3,
47 | c: 4
48 | }, {
49 | b: true
50 | })).toEqual({
51 | a: 1,
52 | b: 2,
53 | c: 4
54 | });
55 | expect(mergeObj({
56 | a: 1,
57 | b: 2
58 | }, {
59 | b: 3,
60 | c: 4
61 | })).toEqual({
62 | a: 1,
63 | b: 3,
64 | c: 4
65 | });
66 | expect(mergeObj({
67 | a: 1,
68 | b: 2
69 | }, {
70 | b: 3,
71 | c: 4
72 | }, {
73 | a: true,
74 | b: true
75 | })).toEqual({
76 | a: 1,
77 | b: 2,
78 | c: 4
79 | });
80 | expect(mergeObj({
81 | a: 1,
82 | b: 2
83 | }, {
84 | b: 3,
85 | c: 4
86 | }, {
87 | a: true,
88 | b: true,
89 | c: true
90 | })).toEqual({
91 | a: 1,
92 | b: 2
93 | });
94 | expect(mergeObj(undefined, {
95 | b: 3,
96 | c: 4
97 | })).toEqual({
98 | b: 3,
99 | c: 4
100 | });
101 | expect(mergeObj({
102 | a: 1,
103 | b: 2
104 | }, undefined)).toEqual({
105 | a: 1,
106 | b: 2
107 | });
108 | expect(mergeObj({
109 | a: 1,
110 | b: 2
111 | }, null)).toEqual(null);
112 | expect(mergeObj({
113 | a: 1,
114 | b: 2
115 | }, 1)).toEqual(1);
116 | });
117 |
118 | // Strips all values from an object of they evaluate to false or if their name starts with '_'.
119 | // Used on all outgoing object before serialization to string.
120 | test('simplify', () => {
121 | const obj = {
122 | a: 1,
123 | b: 0,
124 | c: '',
125 | d: null,
126 | e: undefined,
127 | f: false,
128 | g: true,
129 | h: {},
130 | i: {
131 | j: 1
132 | },
133 | k: [],
134 | l: [1],
135 | m: new Date(0),
136 | n: new Date(),
137 | o: new Date(''),
138 | p: new Date('2020-02-02T03:04:05.006Z'),
139 | _q: 1,
140 | _r: 0,
141 | _s: '',
142 | _t: null,
143 | _u: undefined,
144 | _v: false,
145 | _w: true,
146 | _x: {},
147 | _y: {
148 | j: 1
149 | },
150 | _z: [],
151 | _1: [1],
152 | _2: new Date(0),
153 | _3: new Date(),
154 | _4: new Date(''),
155 | _5: new Date('2020-02-02T03:04:05.006Z')
156 | };
157 | expect(simplify(obj)).toEqual({
158 | a: 1,
159 | g: true,
160 | i: {
161 | j: 1
162 | },
163 | l: [1],
164 | n: obj.n,
165 | p: obj.p
166 | });
167 | });
168 |
169 | // Trim whitespace, convert to lowercase, strip empty, short, and duplicate elements elements.
170 | // If the result is an empty array, add a single element "\u2421" (Unicode Del character).
171 | test('normalizeArray', () => {
172 | expect(normalizeArray(null)).toEqual([DEL_CHAR]);
173 | expect(normalizeArray([])).toEqual([DEL_CHAR]);
174 | expect(normalizeArray([''])).toEqual([DEL_CHAR]);
175 | expect(normalizeArray(['', ''])).toEqual([DEL_CHAR]);
176 | expect(normalizeArray(['', ' ', ''])).toEqual([DEL_CHAR]);
177 | expect(normalizeArray(['a', 'aa'])).toEqual(['aa']);
178 | expect(normalizeArray(['aA'])).toEqual(['aa']);
179 | expect(normalizeArray(['aa', 'bb'])).toEqual(['aa', 'bb']);
180 | expect(normalizeArray(['aa', 'bb', 'aa'])).toEqual(['aa', 'bb']);
181 | expect(normalizeArray(['aa', 'bb', 'aa', 'bb'])).toEqual(['aa', 'bb']);
182 | expect(normalizeArray(['aa ', 'bb', 'Aa', 'bb', 'Cc'])).toEqual(['aa', 'bb', 'cc']);
183 | });
184 |
185 | test('normalizeRanges', () => {
186 | expect(normalizeRanges('hello', 100)).toEqual([]);
187 | expect(normalizeRanges(null, 100)).toEqual([]);
188 | expect(normalizeRanges({
189 | a: 1
190 | }, 100)).toEqual([]);
191 | expect(normalizeRanges([{
192 | a: 1
193 | }, {
194 | b: 2
195 | }], 100)).toEqual([]);
196 | expect(normalizeRanges([], 100)).toEqual([]);
197 | expect(normalizeRanges([{}], 100)).toEqual([]);
198 | expect(normalizeRanges([{
199 | low: 1
200 | }], 100)).toEqual([{
201 | low: 1
202 | }]);
203 | expect(normalizeRanges([{
204 | low: 1,
205 | hi: 2
206 | }], 100)).toEqual([{
207 | low: 1,
208 | hi: 2
209 | }]);
210 | expect(normalizeRanges([{
211 | low: 1,
212 | hi: 101
213 | }], 90)).toEqual([{
214 | low: 1,
215 | hi: 101
216 | }]);
217 | expect(normalizeRanges([{
218 | low: 1,
219 | hi: 101
220 | }, {
221 | low: 2,
222 | hi: 102
223 | }], 100)).toEqual([{
224 | low: 1,
225 | hi: 102
226 | }]);
227 | expect(normalizeRanges([{
228 | low: 2,
229 | hi: 102
230 | }, {
231 | low: 1,
232 | hi: 101
233 | }], 100)).toEqual([{
234 | low: 1,
235 | hi: 102
236 | }]);
237 | expect(normalizeRanges([{
238 | low: 1,
239 | hi: 101
240 | }, {
241 | low: 2,
242 | hi: 102
243 | }, {
244 | low: 102,
245 | hi: 110
246 | }], 100)).toEqual([{
247 | low: 1,
248 | hi: 110
249 | }]);
250 | expect(normalizeRanges([{
251 | low: 102,
252 | hi: 110
253 | }, {
254 | low: 2,
255 | hi: 102
256 | }, {
257 | low: 1,
258 | hi: 101
259 | }], 100)).toEqual([{
260 | low: 1,
261 | hi: 110
262 | }]);
263 | expect(normalizeRanges([{
264 | low: 1,
265 | hi: 101
266 | }, {
267 | low: 22,
268 | hi: 120
269 | }, {
270 | low: 122,
271 | hi: 125
272 | }], 100)).toEqual([{
273 | low: 1,
274 | hi: 120
275 | }, {
276 | low: 122,
277 | hi: 125
278 | }]);
279 | });
280 |
281 | // Convert array of IDs to array of ranges.
282 | test('listToRanges', () => {
283 | expect(listToRanges([])).toEqual([]);
284 | expect(listToRanges([1])).toEqual([{
285 | low: 1
286 | }]);
287 | expect(listToRanges([1, 2])).toEqual([{
288 | low: 1,
289 | hi: 3
290 | }]);
291 | expect(listToRanges([1, 2, 3])).toEqual([{
292 | low: 1,
293 | hi: 4
294 | }]);
295 | expect(listToRanges([1, 2, 3, 5])).toEqual([{
296 | low: 1,
297 | hi: 4
298 | }, {
299 | low: 5
300 | }]);
301 | expect(listToRanges([5, 3, 2, 1])).toEqual([{
302 | low: 1,
303 | hi: 4
304 | }, {
305 | low: 5
306 | }]);
307 | expect(listToRanges([1, 2, 3, 5, 6, 7])).toEqual([{
308 | low: 1,
309 | hi: 4
310 | }, {
311 | low: 5,
312 | hi: 8
313 | }]);
314 | });
315 |
316 | // Cuts 'clip' range out of the 'src' range.
317 | // Returns an array with 0, 1 or 2 elements.
318 | test('clipOutRange', () => {
319 | expect(clipOutRange({
320 | low: 10,
321 | hi: 20
322 | }, {
323 | low: 10,
324 | hi: 20
325 | })).toEqual([]);
326 | expect(clipOutRange({
327 | low: 10,
328 | hi: 20
329 | }, {
330 | low: 5,
331 | hi: 30
332 | })).toEqual([]);
333 | expect(clipOutRange({
334 | low: 10,
335 | hi: 20
336 | }, {
337 | low: 30,
338 | hi: 40
339 | })).toEqual([{
340 | low: 10,
341 | hi: 20
342 | }]);
343 | expect(clipOutRange({
344 | low: 10,
345 | hi: 20
346 | }, {
347 | low: 1,
348 | hi: 5
349 | })).toEqual([{
350 | low: 10,
351 | hi: 20
352 | }]);
353 | expect(clipOutRange({
354 | low: 10,
355 | hi: 20
356 | }, {
357 | low: 1,
358 | hi: 15
359 | })).toEqual([{
360 | low: 15,
361 | hi: 20
362 | }]);
363 | expect(clipOutRange({
364 | low: 10,
365 | hi: 20
366 | }, {
367 | low: 15,
368 | hi: 30
369 | })).toEqual([{
370 | low: 10,
371 | hi: 15
372 | }]);
373 | expect(clipOutRange({
374 | low: 10,
375 | hi: 20
376 | }, {
377 | low: 12,
378 | hi: 17
379 | })).toEqual([{
380 | low: 10,
381 | hi: 12
382 | }, {
383 | low: 17,
384 | hi: 20
385 | }]);
386 | });
387 |
388 | // Cuts 'src' range to be completely within 'clip' range.
389 | // Returns clipped range or null if 'src' is outside of 'clip'.
390 | test('clipInRange', () => {
391 | expect(clipInRange({
392 | low: 10,
393 | hi: 20
394 | }, {
395 | low: 21,
396 | hi: 30
397 | })).toBeNull();
398 | expect(clipInRange({
399 | low: 10,
400 | hi: 20
401 | }, {
402 | low: 10,
403 | hi: 20
404 | })).toEqual({
405 | low: 10,
406 | hi: 20
407 | });
408 | expect(clipInRange({
409 | low: 10,
410 | hi: 20
411 | }, {
412 | low: 5,
413 | hi: 30
414 | })).toEqual({
415 | low: 10,
416 | hi: 20
417 | });
418 | expect(clipInRange({
419 | low: 10,
420 | hi: 20
421 | }, {
422 | low: 1,
423 | hi: 15
424 | })).toEqual({
425 | low: 10,
426 | hi: 15
427 | });
428 | expect(clipInRange({
429 | low: 10,
430 | hi: 20
431 | }, {
432 | low: 1,
433 | hi: 5
434 | })).toBeNull();
435 | expect(clipInRange({
436 | low: 10,
437 | hi: 20
438 | }, {
439 | low: 1,
440 | hi: 10
441 | })).toBeNull();
442 | expect(clipInRange({
443 | low: 10,
444 | hi: 20
445 | }, {
446 | low: 20,
447 | hi: 30
448 | })).toBeNull();
449 | expect(clipInRange({
450 | low: 10,
451 | hi: 20
452 | }, {
453 | low: 15,
454 | hi: 30
455 | })).toEqual({
456 | low: 15,
457 | hi: 20
458 | });
459 | });
460 |
--------------------------------------------------------------------------------
/version.js:
--------------------------------------------------------------------------------
1 | export const PACKAGE_VERSION = "0.24.0-rc2";
2 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const TerserPlugin = require('terser-webpack-plugin');
2 | const path = require('path');
3 |
4 | module.exports = (env, argv) => {
5 | const mode = argv.mode === 'production' ? 'prod' : 'dev';
6 | return {
7 | entry: {
8 | index: path.resolve(__dirname, 'src/tinode.js'),
9 | },
10 | devtool: 'source-map',
11 | module: {
12 | rules: [
13 | {
14 | test: /\.js$/,
15 | use: [
16 | 'babel-loader',
17 | ],
18 | exclude: /node_modules/,
19 | },
20 | ],
21 | },
22 | output: {
23 | path: path.resolve(__dirname, 'umd'),
24 | filename: `tinode.${mode}.js`,
25 | globalObject: 'this',
26 | library: {
27 | name: 'tinode',
28 | type: 'umd',
29 | },
30 | },
31 | optimization: {
32 | minimize: (mode === 'prod'),
33 | minimizer: [
34 | new TerserPlugin({
35 | terserOptions: {
36 | ecma: undefined,
37 | warnings: false,
38 | parse: {},
39 | compress: {},
40 | format: {
41 | comments: false,
42 | },
43 | mangle: true, // Note `mangle.properties` is `false` by default.
44 | module: false,
45 | output: null,
46 | toplevel: false,
47 | nameCache: null,
48 | ie8: false,
49 | keep_classnames: undefined,
50 | keep_fnames: false,
51 | safari10: false,
52 | },
53 | extractComments: false,
54 | })
55 | ]
56 | },
57 | performance: {
58 | maxEntrypointSize: 360000,
59 | maxAssetSize: 360000
60 | },
61 | };
62 | }
63 |
--------------------------------------------------------------------------------