├── .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 | "
Alice Johnson
This is a reply to reply
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
Alice Johnson
This is a simple reply
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 | --------------------------------------------------------------------------------