Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7948236eb |
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
|
||||||
"comments": false,
|
|
||||||
"plugins": [
|
|
||||||
["react-intl", {
|
|
||||||
"messagesDir": "./src/i18n/"
|
|
||||||
}]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
index.dev.js binary
|
|
||||||
index.dev.js.map binary
|
|
||||||
index.prod.js binary
|
|
||||||
index.prod.js.map binary
|
|
||||||
base.min.css binary
|
|
||||||
tinode.dev.js binary
|
|
||||||
tinode.prod.js binary
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[{
|
|
||||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "android_app",
|
|
||||||
"package_name": "co.tinode.tindroidx",
|
|
||||||
"sha256_cert_fingerprints":
|
|
||||||
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"relation": ["delegate_permission/common.get_login_creds"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "web",
|
|
||||||
"site": "https://web.tinode.co"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"relation": ["delegate_permission/common.get_login_creds"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "android_app",
|
|
||||||
"package_name": "co.tinode.tindroidx",
|
|
||||||
"sha256_cert_fingerprints":
|
|
||||||
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
@@ -1,61 +1,2 @@
|
|||||||
# Single-page chat with Tinode
|
# im_webapp
|
||||||
|
|
||||||
Single-page web chat application built with [Tinode](https://github.com/tinode/chat/). The app uses
|
|
||||||
[React](https://facebook.github.io/react/), Google's material design [fonts](https://www.google.com/fonts/)
|
|
||||||
and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web). The [Tinode javascript SDK](https://github.com/tinode/tinode-js/) has no external dependencies.
|
|
||||||
Overall it's a lot like open source WhatsApp or Telegram web apps.
|
|
||||||
|
|
||||||
The app is about 200KB minified and gzipped. That includes all dependencies (React, ReactDOM), fonts, graphics, css etc.
|
|
||||||
|
|
||||||
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist. The app was tested in the latest Chrome & Firefox only. NPM package is available at https://www.npmjs.com/package/tinode-webapp.
|
|
||||||
|
|
||||||
Try possibly newer or older version live at https://web.tinode.co/. For passwords for demo accounts and other instructions [see here](https://github.com/tinode/chat/#demo).
|
|
||||||
|
|
||||||
## Getting support
|
|
||||||
|
|
||||||
* Read [client-side](http://tinode.github.io/js-api/) and [server-side](https://github.com/tinode/chat/blob/master/docs/API.md) API documentation.
|
|
||||||
* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
|
|
||||||
* For bugs and feature requests [open an issue](https://github.com/tinode/webchat/issues/new).
|
|
||||||
|
|
||||||
## Internationalization
|
|
||||||
|
|
||||||
The app is fully internationalized using [React-Intl](https://github.com/formatjs/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `#?hl=XX` parameter to the URL, i.e. https://web.tinode.co/#?hl=ru.
|
|
||||||
|
|
||||||
As of the time of this writing the following translations exist: English, Russian, Simplified Chinese. More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json), [Russian](/src/i18n/ru.json), or [Simplified Chinese](/src/i18n/zh-CN.json) translations for guidance.
|
|
||||||
|
|
||||||
|
|
||||||
## Not done yet
|
|
||||||
|
|
||||||
* End-to-End encryption.
|
|
||||||
* Emoji support is weak.
|
|
||||||
* Mentions, hashtags.
|
|
||||||
* Replying or forwarding messages.
|
|
||||||
* Previews not generated for videos, audio, links or docs.
|
|
||||||
|
|
||||||
## Other
|
|
||||||
|
|
||||||
* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
|
|
||||||
* Background patterns from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
|
|
||||||
|
|
||||||
## Push notifications
|
|
||||||
|
|
||||||
If you want to use the app with your own server and want web push notification to work you have to set them up:
|
|
||||||
|
|
||||||
* Register at https://firebase.google.com/, set up the project if you have not done so already.
|
|
||||||
* Open https://console.firebase.google.com/, navigate to your project the to `Cloud Messaging`.
|
|
||||||
* Locate `firebase-init.js` in the root folder of your copy of this web app. Get Sender ID and Web Push certificate from https://console.firebase.google.com/: copy `Sender ID` (Project Settings -> Cloud Messaging, "Sender ID") to `messagingSenderId` field, copy `Web Push certificate` (Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates) to `messagingVapidKey` field.
|
|
||||||
* Copy Google-provided server key to `tinode.conf`, see details [here](https://github.com/tinode/chat/blob/master/docs/faq.md#q-how-to-setup-fcm-push-notifications).
|
|
||||||
|
|
||||||
## Responsive design
|
|
||||||
|
|
||||||
### Desktop screenshot
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="web-desktop-2.png" alt="Desktop web: full app" width=866 />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Mobile screenshots
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<kbd><img src="web-mob-contacts-1.png" alt="Mobile web: contacts" width=323 /></kbd> <kbd><img src="web-mob-chat-1.png" alt="Mobile web: chat" width=323 /></kbd> <kbd><img src="web-mob-info-1.png" alt="Mobile web: topic info" width=323 /></kbd> <kbd><img src="web-mob-new-chat-1.png" alt="Mobile web: start new chat" width=323 /></kbd>
|
|
||||||
</p>
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
// Get these values for your project from the https://console.firebase.google.com/
|
|
||||||
const FIREBASE_INIT = {
|
|
||||||
// Project Settings -> Cloud Messaging, "Sender ID".
|
|
||||||
messagingSenderId: "114126160546",
|
|
||||||
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
|
|
||||||
// This value IS NOT included in the generated js config you can download from
|
|
||||||
// the firebase console. It needs to be added separately.
|
|
||||||
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
|
|
||||||
};
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192" height="192"><defs><filter id="f" color-interpolation-filters="sRGB"><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-1" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="fbSourceGraphic"/><feColorMatrix result="fbSourceGraphicAlpha" in="fbSourceGraphic" values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"/><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in2="fbSourceGraphic" in="flood" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-3" result="offset"/><feComposite in2="offset" in="fbSourceGraphic" result="composite2"/></filter><filter id="d" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#FFF" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="b" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="c" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><linearGradient id="a"><stop offset="0" stop-opacity=".251"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="102.814" y1="97.076" x2="219.019" y2="170.481" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.29306 0 0 1.00596 -43.959 .807)"/></defs><g fill-rule="evenodd"><path d="M14.102 64A88 87.925 0 008 95.924c-.012 44.455 18.485 70.138 1.863 79.822-12.733 7.419 43.153 8.258 70.137 8.248V64H14.102z" fill="#fc5d3e" filter="url(#b)"/><path d="M80 64v119.994c8.24-.003 14.055-.08 14.055-.08L96 183.85a88 87.925 0 0088-87.926A88 87.925 0 00177.99 64H80z" fill="#45ae49" filter="url(#c)"/><path d="M96 8A88 87.925 0 009.52 80h172.978A88 87.925 0 0096 8z" fill="#2d526b" filter="url(#d)"/></g><path d="M88.002 40.975l-8.6 112.808 39.791 26.94c34.169-9.369 59.381-38.298 63.971-73.4z" fill="url(#e)" fill-rule="evenodd" opacity=".5"/><text y="153.195" x="53.125" style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" font-weight="700" font-size="160" font-family="Arial" letter-spacing="0" word-spacing="0"><tspan style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" y="153.195" x="53.125" fill="#fff">t</tspan></text></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,53 +0,0 @@
|
|||||||
<!-- DEVELOPMENT version of the web app. -->
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>Tinode</title>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="application-name" content="Tinode Web, version for developers" />
|
|
||||||
<meta name="description" content="Tinode instant messenger, development version" />
|
|
||||||
<meta name="application-url" content="https://web.tinode.co/">
|
|
||||||
<!-- Browser styling for Chrome, FF, Opera -->
|
|
||||||
<meta name="theme-color" content="#3949AB" />
|
|
||||||
<!-- Ask Google not to offer page translation -->
|
|
||||||
<meta name="google" value="notranslate" />
|
|
||||||
<!-- Open Graph description -->
|
|
||||||
<meta name="og:url" content="https://web.tinode.co/">
|
|
||||||
<meta name="og:title" content="Tinode Web Development">
|
|
||||||
<meta name="og:image" content="img/og-logo.jpeg">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<link rel="manifest" href="manifest.json" />
|
|
||||||
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
|
||||||
<link rel="canonical" href="https://web.tinode.co/" />
|
|
||||||
<!-- Google's Roboto font -->
|
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
|
||||||
<!-- Google's material design icons -->
|
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
|
||||||
<!-- Styles for local elements -->
|
|
||||||
<link rel="stylesheet" href="css/base.css" />
|
|
||||||
<!-- ReactJS -->
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16/umd/react.development.js"></script>
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16/umd/react-dom.development.js"></script>
|
|
||||||
<!-- React-Intl for translations -->
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3/dist/react-intl.js"></script>
|
|
||||||
<!-- Firebase for push notifications -->
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-app.js"></script>
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-messaging.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
|
||||||
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
|
||||||
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
|
||||||
then <a href="">try again</a>.
|
|
||||||
</div>
|
|
||||||
</div></noscript>
|
|
||||||
<div id="mountPoint"></div>
|
|
||||||
<script src="firebase-init.js"></script>
|
|
||||||
<script src="umd/tinode.dev.js"></script>
|
|
||||||
<script src="umd/index.dev.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Tinode</title>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="application-name" content="Tinode Web" />
|
|
||||||
<meta name="description" content="Tinode instant messaging in a browser" />
|
|
||||||
<meta name="application-url" content="https://web.tinode.co/">
|
|
||||||
<meta name="theme-color" content="#3949AB" />
|
|
||||||
<meta name="google" value="notranslate" />
|
|
||||||
<meta name="format-detection" content="telephone=no" />
|
|
||||||
<meta name="og:url" content="https://web.tinode.co/">
|
|
||||||
<meta name="og:title" content="Tinode Web">
|
|
||||||
<meta name="og:image" content="img/og-logo.jpeg">
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<link rel="manifest" href="manifest.json" />
|
|
||||||
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
|
||||||
<link rel="canonical" href="https://web.tinode.co/" />
|
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
|
||||||
<link rel="stylesheet" href="css/base.min.css" />
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16.11.0/umd/react.production.min.js"></script>
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16.11.0/umd/react-dom.production.min.js"></script>
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3.4.0/dist/react-intl.min.js"></script>
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-app.js"></script>
|
|
||||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-messaging.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
|
||||||
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
|
||||||
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
|
||||||
then <a href="">try again</a>.</div></div></noscript>
|
|
||||||
<div id="mountPoint"></div>
|
|
||||||
<script src="firebase-init.js"></script>
|
|
||||||
<script src="umd/tinode.prod.js"></script>
|
|
||||||
<script src="umd/index.prod.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"short_name": "Tinode chat",
|
|
||||||
"name": "Tinode Web chat",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src":"img/logo96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src":"img/logo192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "img/logo.svg",
|
|
||||||
"type": "image/svg+xml",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": "/",
|
|
||||||
"background_color": "silver",
|
|
||||||
"theme_color": "#3949AB",
|
|
||||||
"display": "standalone",
|
|
||||||
"gcm_sender_id": "103953800507"
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "tinode-webapp",
|
|
||||||
"description": "Tinode messenger for the web",
|
|
||||||
"version": "0.16.2-rc4",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/tinode/example-react-js.git"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "npm run vers && npm run build:prod && npm run build:dev && npm run build:css && npm run build:i18n",
|
|
||||||
"build:css": "postcss css/base.css > css/base.min.css",
|
|
||||||
"build:dev": "webpack --mode development",
|
|
||||||
"build:prod": "webpack --mode production",
|
|
||||||
"build:i18n": "node scripts/flatten-messages.js",
|
|
||||||
"vers": "node scripts/gen-version.js"
|
|
||||||
},
|
|
||||||
"browserslist": "> 0.5%, not IE 11",
|
|
||||||
"files": [
|
|
||||||
"src/index.js",
|
|
||||||
"src/config.js",
|
|
||||||
"src/views/*.jsx",
|
|
||||||
"src/widgets/*.jsx",
|
|
||||||
"src/lib/*.js",
|
|
||||||
"src/i18n/*.json",
|
|
||||||
"umd/index.prod.js",
|
|
||||||
"umd/index.prod.js.map",
|
|
||||||
"umd/index.dev.js",
|
|
||||||
"umd/index.dev.js.map",
|
|
||||||
"umd/tinode.prod.js",
|
|
||||||
"umd/tinode.dev.js",
|
|
||||||
"audio/msg.mp3",
|
|
||||||
"css/base.css",
|
|
||||||
"css/base.min.css",
|
|
||||||
"img/ignasi_pattern_s.png",
|
|
||||||
"img/logo.svg",
|
|
||||||
"img/logo32x32.png",
|
|
||||||
"img/logo32x32a.png",
|
|
||||||
"img/logo96.png",
|
|
||||||
"img/logo192.png",
|
|
||||||
"img/badge96.png",
|
|
||||||
"img/broken_image.png",
|
|
||||||
"img/og-logo.jpeg",
|
|
||||||
"index-dev.html",
|
|
||||||
"index.html",
|
|
||||||
"manifest.json",
|
|
||||||
"service-worker.js",
|
|
||||||
"firebase-init.js",
|
|
||||||
"web-desktop-2.png",
|
|
||||||
"web-mob-chat-1.png",
|
|
||||||
"web-mob-contacts-1.png",
|
|
||||||
"web-mob-info-1.png",
|
|
||||||
"web-mob-new-chat-1.png"
|
|
||||||
],
|
|
||||||
"keywords": [
|
|
||||||
"chat",
|
|
||||||
"instant-messenger",
|
|
||||||
"message",
|
|
||||||
"messenger",
|
|
||||||
"react",
|
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"main": "umd/index.prod.js",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/tinode/example-react-js/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/tinode/chat",
|
|
||||||
"email": "info@tinode.co",
|
|
||||||
"author": "Tinode Authors <info@tinode.co>",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"firebase": "^6.4.2",
|
|
||||||
"react": "^16.9.0",
|
|
||||||
"react-dom": "^16.9.0",
|
|
||||||
"react-intl": "^3.2.1",
|
|
||||||
"tinode-sdk": "^0.16.2-rc3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.6.0",
|
|
||||||
"@babel/preset-env": "^7.6.0",
|
|
||||||
"@babel/preset-react": "^7.0.0",
|
|
||||||
"babel-loader": "^8.0.6",
|
|
||||||
"babel-plugin-react-intl": "^4.1.19",
|
|
||||||
"browserslist": "^4.7.0",
|
|
||||||
"copy-webpack-plugin": "^5.0.4",
|
|
||||||
"cssnano": "^4.1.10",
|
|
||||||
"postcss": "^7.0.18",
|
|
||||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
|
||||||
"webpack": "^4.40.2",
|
|
||||||
"webpack-cli": "^3.3.9"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: [
|
|
||||||
require('cssnano')({
|
|
||||||
preset: 'default',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
// 1.Flattens messages extracted via the React Intl Babel plugin. An error will be
|
|
||||||
// thrown if there are messages with the same `id`. The result is a file containing
|
|
||||||
// `id: message` pairs in "en" locale.
|
|
||||||
//
|
|
||||||
// 2. Takes all translations and groups them into a single file.
|
|
||||||
var fs = require('fs');
|
|
||||||
var path = require('path');
|
|
||||||
var globSync = require('glob').sync;
|
|
||||||
var mkdirpSync = require('mkdirp').sync;
|
|
||||||
|
|
||||||
const DEFAULT_TRANSLATION = 'en.json';
|
|
||||||
const MESSAGES_PATTERN = './src/i18n/src/**/*.json';
|
|
||||||
const LANG_DIR = './src/i18n/';
|
|
||||||
const LANG_PATTERN = './src/i18n/*.json';
|
|
||||||
const FINAL_FILE = './src/messages.json';
|
|
||||||
|
|
||||||
const MISSING_MARKER = ':!![MISSING]!!:';
|
|
||||||
const OBSOLETE_MARKER = ':!![OBSOLETE]!!:';
|
|
||||||
|
|
||||||
// The react-intl-babel-plugin saves extracted messages
|
|
||||||
// into files, one fine per component. Rewrite them into
|
|
||||||
// a single file en.json.
|
|
||||||
let messages = globSync(MESSAGES_PATTERN)
|
|
||||||
.map((filename) => fs.readFileSync(filename, 'utf8'))
|
|
||||||
.map((file) => JSON.parse(file))
|
|
||||||
.reduce((collection, descriptors) => {
|
|
||||||
descriptors.forEach(({id, defaultMessage}) => {
|
|
||||||
if (collection.hasOwnProperty(id)) {
|
|
||||||
throw new Error(`Duplicate message id: ${id}`);
|
|
||||||
}
|
|
||||||
collection[id] = defaultMessage;
|
|
||||||
});
|
|
||||||
|
|
||||||
return collection;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
let filename = path.join(LANG_DIR, DEFAULT_TRANSLATION);
|
|
||||||
mkdirpSync(path.dirname(filename));
|
|
||||||
fs.writeFileSync(filename, JSON.stringify(messages, null, 2)+"\n");
|
|
||||||
|
|
||||||
// Find messages present in en.json but missing in translations and vice versa.
|
|
||||||
// Mark missing or obsolete messages as such in translation files.
|
|
||||||
globSync(LANG_PATTERN)
|
|
||||||
.map((filename) => {
|
|
||||||
const parts = filename.split('/');
|
|
||||||
if (parts[parts.length-1] != DEFAULT_TRANSLATION) {
|
|
||||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
|
||||||
// Find missing messages.
|
|
||||||
Object.entries(messages).forEach((ent) => {
|
|
||||||
if (!translated.hasOwnProperty(ent[0])) {
|
|
||||||
translated[ent[0]] = MISSING_MARKER + ent[1];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Find no longer needed messages.
|
|
||||||
Object.entries(translated).forEach((ent) => {
|
|
||||||
if (!messages.hasOwnProperty(ent[0])) {
|
|
||||||
let val = translated[ent[0]];
|
|
||||||
if (val.indexOf(MISSING_MARKER) == 0) {
|
|
||||||
val = val.substring(0, MISSING_MARKER.length);
|
|
||||||
}
|
|
||||||
if (val.indexOf(OBSOLETE_MARKER) != 0) {
|
|
||||||
val = OBSOLETE_MARKER + val;
|
|
||||||
}
|
|
||||||
translated[ent[0]] = val;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
|
||||||
// while removing messages marked as missing or obsolete.
|
|
||||||
|
|
||||||
// Read all translations.
|
|
||||||
messages = globSync(LANG_PATTERN)
|
|
||||||
.map((filename) => {
|
|
||||||
const parts = filename.split('/');
|
|
||||||
const locale = parts[parts.length - 1].split('.json')[0];
|
|
||||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
|
||||||
|
|
||||||
// Strip messages prefixed with MISSING or OBSOLETE markers.
|
|
||||||
for (let id in translated) {
|
|
||||||
if (translated[id].indexOf(OBSOLETE_MARKER) == 0 ||
|
|
||||||
translated[id].indexOf(MISSING_MARKER) == 0) {
|
|
||||||
delete translated[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { [locale]: translated };
|
|
||||||
})
|
|
||||||
.reduce((collection, descriptors) => {
|
|
||||||
return { ...collection, ...descriptors };
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Write file with all translations combined.
|
|
||||||
mkdirpSync(path.dirname(FINAL_FILE));
|
|
||||||
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages, null, 2)+"\n");
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
var fs = require('fs');
|
|
||||||
var version = require('../package.json').version;
|
|
||||||
|
|
||||||
fs.writeFile('./src/version.js',
|
|
||||||
'// This is a generated file. Don\'t edit.\n\n'+
|
|
||||||
'export const PACKAGE_VERSION = "'+version+'";\n',
|
|
||||||
{ flag: 'w' },
|
|
||||||
function(err) {
|
|
||||||
if (err) {
|
|
||||||
return console.log(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// Must be located at the root.
|
|
||||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-app.js');
|
|
||||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@6.6.2/firebase-messaging.js');
|
|
||||||
importScripts('firebase-init.js');
|
|
||||||
|
|
||||||
firebase.initializeApp(FIREBASE_INIT);
|
|
||||||
|
|
||||||
// This method shows the notifications.
|
|
||||||
firebase.messaging().setBackgroundMessageHandler(function(payload) {
|
|
||||||
let title = payload.data.title || "New message";
|
|
||||||
let options = {
|
|
||||||
body: payload.data.content || "",
|
|
||||||
icon: '/img/logo96.png',
|
|
||||||
badge: '/img/badge96.png',
|
|
||||||
tag: payload.data.topic || undefined,
|
|
||||||
data: {
|
|
||||||
topic: payload.data.topic
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return self.registration.showNotification(title, options);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update service worker immediately for both the current client
|
|
||||||
// and all other active clients.
|
|
||||||
self.addEventListener('install', event => {
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
// This code handles a click on notification: takes
|
|
||||||
// the user to the browser tab with the chat or opens a new tab.
|
|
||||||
self.addEventListener('notificationclick', event => {
|
|
||||||
const notification = event.notification;
|
|
||||||
notification.close();
|
|
||||||
|
|
||||||
const urlHash = '#/' + notification.data.topic;
|
|
||||||
|
|
||||||
event.waitUntil(self.clients.matchAll({
|
|
||||||
type: 'window',
|
|
||||||
includeUncontrolled: true
|
|
||||||
}).then((windowClients) => {
|
|
||||||
let anyClient = null;
|
|
||||||
for (let i = 0; i < windowClients.length; i++) {
|
|
||||||
const url = new URL(windowClients[i].url);
|
|
||||||
if (url.hash.includes(notification.data.topic)) {
|
|
||||||
// Found the Tinode tab with the right topic open.
|
|
||||||
return windowClients[i].focus();
|
|
||||||
} else {
|
|
||||||
// This will be the least recently used tab.
|
|
||||||
anyClient = windowClients[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Found tab with Tinode on a different topic,
|
|
||||||
// navigate to the right topic.
|
|
||||||
if (anyClient) {
|
|
||||||
const url = new URL(anyClient.url);
|
|
||||||
url.hash = urlHash;
|
|
||||||
return anyClient.focus().then(thisClient => {
|
|
||||||
return thisClient.navigate(url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Did not find a Tinode browser tab. Open one.
|
|
||||||
const url = new URL(self.location.origin);
|
|
||||||
url.hash = urlHash;
|
|
||||||
return clients.openWindow(url);
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// This is needed for 'Add to Home Screen'.
|
|
||||||
self.addEventListener('fetch', event => {
|
|
||||||
// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=823392
|
|
||||||
if (event.request.cache == 'only-if-cached' && event.request.mode != 'same-origin') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.respondWith(
|
|
||||||
// Try to find response in cache.
|
|
||||||
caches.match(event.request)
|
|
||||||
.then((resp) => {
|
|
||||||
// If response is found in cache, return it. Otherwise fetch.
|
|
||||||
return resp || fetch(event.request);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
// Something went wrong.
|
|
||||||
console.log("Service worker Fetch: ", err);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { PACKAGE_VERSION } from './version.js';
|
|
||||||
|
|
||||||
// Name of this application, used in the User-Agent.
|
|
||||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.16');
|
|
||||||
|
|
||||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
|
||||||
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
|
||||||
|
|
||||||
// The array of possible hosts to connect to.
|
|
||||||
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
|
|
||||||
|
|
||||||
// Default host name and port to connect to.
|
|
||||||
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
|
||||||
|
|
||||||
// Enable console logging.
|
|
||||||
export const LOGGING_ENABLED = true;
|
|
||||||
|
|
||||||
// Minimum time between two keypress notifications, milliseconds.
|
|
||||||
export const KEYPRESS_DELAY = 3*1000;
|
|
||||||
// Delay before sending a {note} for reciving a message, milliseconds.
|
|
||||||
export const RECEIVED_DELAY = 500;
|
|
||||||
// Delay before sending a read notification, milliseconds.
|
|
||||||
export const READ_DELAY = 1000;
|
|
||||||
|
|
||||||
// The shortest allowed tag length. Matches the value on the server.
|
|
||||||
export const MIN_TAG_LENGTH = 4;
|
|
||||||
|
|
||||||
// Access mode for P2P subscriptions initiated by the current user.
|
|
||||||
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
|
|
||||||
// Access mode for new group topics created by the current user.
|
|
||||||
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
|
|
||||||
|
|
||||||
// Access mode for no access.
|
|
||||||
export const NO_ACCESS_MODE = 'N';
|
|
||||||
|
|
||||||
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
|
|
||||||
// in @media (max-size: 640px) in base.css
|
|
||||||
export const MEDIA_BREAKPOINT = 640;
|
|
||||||
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
|
|
||||||
export const REM_SIZE = 13;
|
|
||||||
|
|
||||||
// Size of the avatar image: When an avatar image is uploaded, it's resized to
|
|
||||||
// a square of this size.
|
|
||||||
export const AVATAR_SIZE = 128;
|
|
||||||
|
|
||||||
// Size of the broken_image shown in MessagesView
|
|
||||||
export const BROKEN_IMAGE_SIZE = 32;
|
|
||||||
|
|
||||||
// Number of chat messages to fetch in one call.
|
|
||||||
export const MESSAGES_PAGE = 24;
|
|
||||||
|
|
||||||
// Maximum in-band (included directly into the message) attachment size which fits into
|
|
||||||
// a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead.
|
|
||||||
// This is size of an object *before* base64 encoding is applied.
|
|
||||||
// Increase this limit to a greater value in production, if desired. Also increase
|
|
||||||
// max_message_size in server config.
|
|
||||||
// MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size - overhead);
|
|
||||||
export const MAX_INBAND_ATTACHMENT_SIZE = 195840;
|
|
||||||
|
|
||||||
// Absolute maximum attachment size to be used with the server = 8MB. Increase to
|
|
||||||
// something like 100MB in production.
|
|
||||||
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
|
|
||||||
|
|
||||||
// Maximum allowed linear dimension of an inline image in pixels. You may want
|
|
||||||
// to adjust it to 1600 or 2400 for production.
|
|
||||||
export const MAX_IMAGE_DIM = 768;
|
|
||||||
|
|
||||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
|
||||||
// hidden under "+X more"
|
|
||||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
|
||||||
|
|
||||||
// Maximum length of topic title (and Private comment) or user name.
|
|
||||||
export const MAX_TITLE_LENGTH = 40;
|
|
||||||
|
|
||||||
// Link for "Contact Us".
|
|
||||||
export const LINK_CONTACT_US = 'email:info@tinode.co';
|
|
||||||
|
|
||||||
// Link to Privacy Policy.
|
|
||||||
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
|
|
||||||
|
|
||||||
// Link to Terms of Service.
|
|
||||||
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
{
|
|
||||||
"archived_contacts": "Archived contacts ({count})",
|
|
||||||
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
|
|
||||||
"full_name_prompt": "Full name, e.g. John Doe",
|
|
||||||
"email_prompt": "Email, e.g. jdoe@example.com",
|
|
||||||
"button_sign_up": "Sign up",
|
|
||||||
"validate_credential_action": "confirm",
|
|
||||||
"label_your_name": "Your name",
|
|
||||||
"label_password": "Password",
|
|
||||||
"password_unchanged_prompt": "Unchanged",
|
|
||||||
"label_message_sound": "Message sound:",
|
|
||||||
"label_push_notifications": "Notification alerts:",
|
|
||||||
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
|
|
||||||
"title_tag_manager": "Tags (user discovery)",
|
|
||||||
"label_user_contacts": "Contacts:",
|
|
||||||
"label_user_id": "Address:",
|
|
||||||
"label_default_access_mode": "Default access mode:",
|
|
||||||
"button_logout": "Logout",
|
|
||||||
"label_legal": "Legal",
|
|
||||||
"link_contact_us": "Contact Us",
|
|
||||||
"link_terms_of_service": "Terms of Service",
|
|
||||||
"link_privacy_policy": "Privacy Policy",
|
|
||||||
"requested_permissions": "Requested",
|
|
||||||
"granted_permissions": "Granted",
|
|
||||||
"menu_item_edit_permissions": "Edit permissions",
|
|
||||||
"label_other_user": "Other",
|
|
||||||
"action_clear_messages": "Clear Messages",
|
|
||||||
"clear_messages_warning": "Are you sure you want to clear all messages? It cannot be undone.",
|
|
||||||
"action_delete_messages": "Clear Messages for All",
|
|
||||||
"delete_messages_warning": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
|
|
||||||
"action_leave_chat": "Leave Conversation",
|
|
||||||
"leave_chat_warning": "Are you sure you want to leave this conversation?",
|
|
||||||
"action_block_contact": "Block Contact",
|
|
||||||
"block_contact_warning": "Are you sure you want to block this contact?",
|
|
||||||
"action_report_chat": "Report Conversation",
|
|
||||||
"report_chat_warning": "Are you sure you want to block and report this conversation?",
|
|
||||||
"title_info": "Info",
|
|
||||||
"label_topic_name": "Name",
|
|
||||||
"label_private": "Private comment",
|
|
||||||
"private_editing_placeholder": "Visible to you only",
|
|
||||||
"label_muting_topic": "Muted:",
|
|
||||||
"action_more": "More",
|
|
||||||
"label_your_permissions": "Your permissions:",
|
|
||||||
"label_permissions": "Permissions:",
|
|
||||||
"label_you": "You:",
|
|
||||||
"label_default_access": "Default access mode:",
|
|
||||||
"label_group_members": "Group members:",
|
|
||||||
"button_add_members": "Add members",
|
|
||||||
"group_has_no_members": "No members",
|
|
||||||
"login_prompt": "Login",
|
|
||||||
"password_prompt": "Password",
|
|
||||||
"stay_logged_in": "Stay logged in",
|
|
||||||
"forgot_password_link": "Forgot password?",
|
|
||||||
"button_sign_in": "Sign in",
|
|
||||||
"label_client": "Client:",
|
|
||||||
"label_server": "Server:",
|
|
||||||
"online_now": "online now",
|
|
||||||
"last_seen_timestamp": "Last seen",
|
|
||||||
"title_not_found": "Not found",
|
|
||||||
"unnamed_topic": "Unnamed",
|
|
||||||
"messages_not_readable": "no access to messages",
|
|
||||||
"peers_messaging_disabled": "Peer's messaging is disabled.",
|
|
||||||
"enable_peers_messaging": "Enable",
|
|
||||||
"tabtitle_find_user": "find",
|
|
||||||
"tabtitle_new_group": "new group",
|
|
||||||
"tabtitle_group_by_id": "by id",
|
|
||||||
"search_for_contacts": "Use search to find contacts",
|
|
||||||
"new_password_placeholder": "Enter new password",
|
|
||||||
"label_reset_password": "Send a password reset email:",
|
|
||||||
"credential_email_prompt": "Your registration email",
|
|
||||||
"button_reset": "Reset",
|
|
||||||
"button_send_request": "Send request",
|
|
||||||
"label_server_to_use": "Server to use:",
|
|
||||||
"label_wire_transport": "Wire transport:",
|
|
||||||
"button_update": "Update",
|
|
||||||
"sidepanel_title_login": "Sign In",
|
|
||||||
"sidepanel_title_register": "Create Account",
|
|
||||||
"sidepanel_title_settings": "Settings",
|
|
||||||
"sidepanel_title_edit_account": "Edit Account",
|
|
||||||
"sidepanel_title_newtpk": "Start New Chat",
|
|
||||||
"sidepanel_title_cred": "Confirm Credentials",
|
|
||||||
"sidepanel_title_reset": "Reset Password",
|
|
||||||
"sidepanel_title_archive": "Archived Chats",
|
|
||||||
"update_available": "Update available. <a href=\"\">Reload</a>.",
|
|
||||||
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
|
|
||||||
"reconnect_now": "Try now",
|
|
||||||
"phone_dative": "phone",
|
|
||||||
"email_dative": "email",
|
|
||||||
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
|
|
||||||
"numeric_confirmation_code_prompt": "Numbers only",
|
|
||||||
"button_confirm": "Confirm",
|
|
||||||
"save_attachment": "save",
|
|
||||||
"invalid_content": "invalid content",
|
|
||||||
"user_not_found": "Not found",
|
|
||||||
"badge_you": "you",
|
|
||||||
"badge_owner": "owner",
|
|
||||||
"menu_item_info": "Info",
|
|
||||||
"menu_item_clear_messages": "Clear messages",
|
|
||||||
"menu_item_clear_messages_for_all": "Clear for All",
|
|
||||||
"menu_item_delete": "Delete",
|
|
||||||
"menu_item_delete_for_all": "Delete for All",
|
|
||||||
"menu_item_mute": "Mute",
|
|
||||||
"menu_item_unmute": "Unmute",
|
|
||||||
"menu_item_delete_topic": "Delete",
|
|
||||||
"topic_delete_warning": "Are you sure you want to delete this conversation?",
|
|
||||||
"menu_item_unblock": "Unblock",
|
|
||||||
"menu_item_block": "Block",
|
|
||||||
"topic_block_warning": "Are you sure you want to block this conversation?",
|
|
||||||
"menu_item_member_delete": "Remove",
|
|
||||||
"menu_item_archive_topic": "Archive",
|
|
||||||
"action_cancel": "cancel",
|
|
||||||
"upload_finishing": "finishing...",
|
|
||||||
"no_contacts": "You have no contacts :-(",
|
|
||||||
"contacts_not_found_short": "No contacts match '{query}'",
|
|
||||||
"title_group_members": "Group Members",
|
|
||||||
"title_all_contacts": "All Contacts",
|
|
||||||
"button_ok": "OK",
|
|
||||||
"button_cancel": "Cancel",
|
|
||||||
"more_online_members": "+{overflow} more",
|
|
||||||
"download_action": "download",
|
|
||||||
"label_file_name": "File name:",
|
|
||||||
"label_content_type": "Content type:",
|
|
||||||
"label_size": "Size:",
|
|
||||||
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
|
|
||||||
"chat_invitation_accept": "Accept",
|
|
||||||
"chat_invitation_ignore": "Ignore",
|
|
||||||
"chat_invitation_block": "Block",
|
|
||||||
"error_invalid_id": "Invalid ID",
|
|
||||||
"group_user_id_prompt": "Group or User ID",
|
|
||||||
"button_subscribe": "Subscribe",
|
|
||||||
"topic_name_editing_placeholder": "Freeform name of the group",
|
|
||||||
"button_create": "Create",
|
|
||||||
"permission_join": "Join ({val})",
|
|
||||||
"permission_read": "Read ({val})",
|
|
||||||
"permission_write": "Write ({val})",
|
|
||||||
"permission_pres": "Get notified ({val})",
|
|
||||||
"permission_admin": "Approve ({val})",
|
|
||||||
"permission_share": "Share ({val})",
|
|
||||||
"permission_delete": "Delete ({val})",
|
|
||||||
"permission_owner": "Owner ({val})",
|
|
||||||
"title_permissions": "Permissions",
|
|
||||||
"message_sending": "sending...",
|
|
||||||
"message_sending_failed": "failed",
|
|
||||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
|
||||||
"messaging_disabled_prompt": "Messaging disabled",
|
|
||||||
"new_message_prompt": "New message",
|
|
||||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
|
||||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
|
||||||
"tags_not_found": "No tags defined. Add some.",
|
|
||||||
"tags_editor_no_tags": "Add some tags",
|
|
||||||
"title_manage_tags": "Manage"
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
{
|
|
||||||
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
|
|
||||||
"full_name_prompt": "Полное имя, напр. Иван Петров",
|
|
||||||
"email_prompt": "Email, напр. ivan@example.com",
|
|
||||||
"button_sign_up": "Создать аккаунт",
|
|
||||||
"label_your_name": "Ваше имя",
|
|
||||||
"label_password": "Пароль",
|
|
||||||
"password_unchanged_prompt": "Не изменен",
|
|
||||||
"label_user_id": "Адрес:",
|
|
||||||
"label_default_access_mode": "Доступ по умолчанию:",
|
|
||||||
"label_message_sound": "Звук нового сообщения:",
|
|
||||||
"label_push_notifications": "Уведомления:",
|
|
||||||
"label_push_notifications_disabled": "Уведомления (требуют HTTPS):",
|
|
||||||
"title_tag_manager": "Теги для поиска",
|
|
||||||
"button_logout": "Выйти",
|
|
||||||
"login_prompt": "Логин",
|
|
||||||
"password_prompt": "Пароль",
|
|
||||||
"stay_logged_in": "Запомнить",
|
|
||||||
"forgot_password_link": "Напомнить пароль",
|
|
||||||
"button_sign_in": "Войти",
|
|
||||||
"label_client": "Клиент:",
|
|
||||||
"label_server": "Сервер:",
|
|
||||||
"online_now": "онлайн",
|
|
||||||
"last_seen_timestamp": "Был активен",
|
|
||||||
"title_not_found": "Не найден",
|
|
||||||
"unnamed_topic": "Без названия",
|
|
||||||
"messages_not_readable": "нет доступа к сообщениям",
|
|
||||||
"tabtitle_find_user": "найти",
|
|
||||||
"tabtitle_new_group": "создать",
|
|
||||||
"tabtitle_group_by_id": "по id",
|
|
||||||
"label_server_to_use": "Использовать сервер:",
|
|
||||||
"label_wire_transport": "Соединение:",
|
|
||||||
"button_update": "Применить",
|
|
||||||
"sidepanel_title_login": "Авторизация",
|
|
||||||
"sidepanel_title_register": "Зарегистрироваться",
|
|
||||||
"sidepanel_title_settings": "Настройки",
|
|
||||||
"sidepanel_title_edit_account": "Редактировать аккаунт",
|
|
||||||
"sidepanel_title_newtpk": "Новый чат",
|
|
||||||
"sidepanel_title_cred": "Подтвердить",
|
|
||||||
"sidepanel_title_reset": "Сменить пароль",
|
|
||||||
"tags_not_found": "Тегов нет. Добавьте",
|
|
||||||
"tags_editor_no_tags": "Добавьте теги",
|
|
||||||
"title_manage_tags": "Редактировать",
|
|
||||||
"message_sending": "в пути...",
|
|
||||||
"message_sending_failed": "ошибка",
|
|
||||||
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
|
|
||||||
"messaging_disabled_prompt": "Отправка недоступна",
|
|
||||||
"new_message_prompt": "Новое сообщение",
|
|
||||||
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
|
|
||||||
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
|
|
||||||
"search_for_contacts": "Поиск контактов",
|
|
||||||
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
|
|
||||||
"numeric_confirmation_code_prompt": "Только цифры",
|
|
||||||
"button_confirm": "Подтвердить",
|
|
||||||
"button_ok": "OK",
|
|
||||||
"button_cancel": "Отменить",
|
|
||||||
"invalid_content": "сообщение не читается",
|
|
||||||
"label_file_name": "Имя файла:",
|
|
||||||
"label_content_type": "Тип:",
|
|
||||||
"label_size": "Размер:",
|
|
||||||
"phone_dative": "телефону",
|
|
||||||
"email_dative": "емейлу",
|
|
||||||
"title_group_members": "Участники",
|
|
||||||
"download_action": "скачать",
|
|
||||||
"permission_join": "Подписываться ({val})",
|
|
||||||
"permission_read": "Читать ({val})",
|
|
||||||
"permission_write": "Писать ({val})",
|
|
||||||
"permission_pres": "Уведомлять ({val})",
|
|
||||||
"permission_admin": "Подтверждать ({val})",
|
|
||||||
"permission_share": "Приглашать ({val})",
|
|
||||||
"permission_delete": "Удалять ({val})",
|
|
||||||
"permission_owner": "Владелец ({val})",
|
|
||||||
"title_permissions": "Права доступа",
|
|
||||||
"requested_permissions": "Требуются",
|
|
||||||
"granted_permissions": "Получены",
|
|
||||||
"menu_item_edit_permissions": "Права доступа",
|
|
||||||
"label_other_user": "Второй",
|
|
||||||
"label_topic_name": "Название",
|
|
||||||
"label_private": "Комментарий",
|
|
||||||
"private_editing_placeholder": "Виден только вам",
|
|
||||||
"label_muting_topic": "Без уведомлений",
|
|
||||||
"action_more": "Ещё",
|
|
||||||
"label_your_permissions": "Ваши права доступа:",
|
|
||||||
"label_permissions": "Права доступа:",
|
|
||||||
"label_you": "Вы:",
|
|
||||||
"label_default_access": "Права по умолчанию:",
|
|
||||||
"label_group_members": "Участники чата:",
|
|
||||||
"button_add_members": "Добавить",
|
|
||||||
"group_has_no_members": "Нет участников",
|
|
||||||
"action_leave_chat": "Уйти из чата",
|
|
||||||
"menu_item_info": "Информация",
|
|
||||||
"menu_item_clear_messages": "Удалить сообщения",
|
|
||||||
"menu_item_clear_messages_for_all": "Удалить для всех",
|
|
||||||
"menu_item_delete": "Удалить",
|
|
||||||
"menu_item_delete_for_all": "Удалить для всех",
|
|
||||||
"menu_item_mute": "Не уведомлять",
|
|
||||||
"menu_item_unmute": "Уведомлять",
|
|
||||||
"menu_item_delete_topic": "Удалить чат",
|
|
||||||
"menu_item_unblock": "Разблокировать",
|
|
||||||
"menu_item_block": "Заблокировать",
|
|
||||||
"menu_item_member_delete": "Отписать",
|
|
||||||
"title_info": "Подробности",
|
|
||||||
"new_password_placeholder": "Введите новый пароль",
|
|
||||||
"label_reset_password": "Отправить емейл для смены пароля:",
|
|
||||||
"credential_email_prompt": "Регистрационный емейл",
|
|
||||||
"button_reset": "Изменить",
|
|
||||||
"button_send_request": "Отправить",
|
|
||||||
"action_cancel": "отменить",
|
|
||||||
"upload_finishing": "завершение...",
|
|
||||||
"no_contacts": "Ничего нет :-(",
|
|
||||||
"contacts_not_found_short": "Нет контактов для запроса '{query}'",
|
|
||||||
"title_all_contacts": "Все контакты",
|
|
||||||
"error_invalid_id": "Неверный ID",
|
|
||||||
"group_user_id_prompt": "ID чата или пользователя",
|
|
||||||
"button_subscribe": "Подписаться",
|
|
||||||
"topic_name_editing_placeholder": "Название чата",
|
|
||||||
"button_create": "Создать",
|
|
||||||
"badge_you": "вы",
|
|
||||||
"badge_owner": "влад.",
|
|
||||||
"update_available": "Есть новая версия приложения. <a href=\"\">Обновить</a>.",
|
|
||||||
"user_not_found": "Не найден",
|
|
||||||
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
|
|
||||||
"reconnect_now": "Подключить сейчас.",
|
|
||||||
"save_attachment": "сохранить",
|
|
||||||
"menu_item_archive_topic": "В архив",
|
|
||||||
"archived_contacts": "Чаты в архиве ({count})",
|
|
||||||
"sidepanel_title_archive": "Архив чатов",
|
|
||||||
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
|
|
||||||
"chat_invitation_accept": "Принять",
|
|
||||||
"chat_invitation_ignore": "Игнорировать",
|
|
||||||
"chat_invitation_block": "Заблокировать",
|
|
||||||
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
|
|
||||||
"enable_peers_messaging": "Разблокировать.",
|
|
||||||
"more_online_members": "+еще {overflow}",
|
|
||||||
"label_user_contacts": "Конакты:",
|
|
||||||
"validate_credential_action": "подтвердить",
|
|
||||||
"label_legal": "Прочее",
|
|
||||||
"link_contact_us": "Связаться с нами",
|
|
||||||
"link_terms_of_service": "Условия сервиса",
|
|
||||||
"link_privacy_policy": "Политика конфиденциальности",
|
|
||||||
"action_delete_messages": "Удалить сообщения",
|
|
||||||
"action_block_contact": "Заблокировать контакт",
|
|
||||||
"action_report_chat": "Сообщить о нарушении",
|
|
||||||
"delete_messages_warning": "Вы действительно хотите удалить все сообщения?",
|
|
||||||
"leave_chat_warning": "Вы действительно хотите покинуть этот чат?",
|
|
||||||
"block_contact_warning": "Вы действительно заблокировать этот контакт?",
|
|
||||||
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
|
|
||||||
"action_clear_messages": "Удалить сообщения",
|
|
||||||
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить.",
|
|
||||||
"topic_delete_warning": ":!![MISSING]!!:Are you sure you want to delete this conversation?",
|
|
||||||
"topic_block_warning": ":!![MISSING]!!:Are you sure you want to block this conversation?"
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
{
|
|
||||||
"archived_contacts": "已归档联系人 ({count})",
|
|
||||||
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
|
|
||||||
"full_name_prompt": "全名,例如张伟",
|
|
||||||
"email_prompt": "电子邮件,例如 zhang@example.com",
|
|
||||||
"button_sign_up": "注册",
|
|
||||||
"label_your_name": "你的姓名",
|
|
||||||
"label_password": "密码",
|
|
||||||
"password_unchanged_prompt": "未改变",
|
|
||||||
"label_user_id": "地址:",
|
|
||||||
"label_default_access_mode": "蓦然访问模式:",
|
|
||||||
"label_message_sound": "消息提示音:",
|
|
||||||
"label_push_notifications": "通知提醒:",
|
|
||||||
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
|
|
||||||
"title_tag_manager": "标签(用户发现)",
|
|
||||||
"button_logout": "登出",
|
|
||||||
"requested_permissions": "已请求",
|
|
||||||
"granted_permissions": "已授予",
|
|
||||||
"menu_item_edit_permissions": "编辑权限",
|
|
||||||
"label_other_user": "其他",
|
|
||||||
"title_info": "信息",
|
|
||||||
"label_topic_name": "名称",
|
|
||||||
"label_private": "私人评论",
|
|
||||||
"private_editing_placeholder": "仅自己可见",
|
|
||||||
"label_muting_topic": "已静音:",
|
|
||||||
"action_more": "更多",
|
|
||||||
"label_your_permissions": "你的权限:",
|
|
||||||
"label_permissions": "权限:",
|
|
||||||
"label_you": "你:",
|
|
||||||
"label_default_access": "默认权限模式:",
|
|
||||||
"label_group_members": "群组成员:",
|
|
||||||
"button_add_members": "添加成员",
|
|
||||||
"group_has_no_members": "无成员",
|
|
||||||
"action_leave_chat": "离开",
|
|
||||||
"login_prompt": "登录",
|
|
||||||
"password_prompt": "密码",
|
|
||||||
"stay_logged_in": "保持登录",
|
|
||||||
"forgot_password_link": "忘记密码?",
|
|
||||||
"button_sign_in": "登录",
|
|
||||||
"label_client": "客户端:",
|
|
||||||
"label_server": "服务器:",
|
|
||||||
"online_now": "在线",
|
|
||||||
"last_seen_timestamp": "最后可见",
|
|
||||||
"title_not_found": "无法找到",
|
|
||||||
"unnamed_topic": "未命名",
|
|
||||||
"messages_not_readable": "无消息访问权限",
|
|
||||||
"peers_messaging_disabled": "成员间消息已禁用。",
|
|
||||||
"enable_peers_messaging": "启用",
|
|
||||||
"tabtitle_find_user": "搜索",
|
|
||||||
"tabtitle_new_group": "新群组",
|
|
||||||
"tabtitle_group_by_id": "通过 id",
|
|
||||||
"search_for_contacts": "使用搜索寻找联系人",
|
|
||||||
"new_password_placeholder": "输入新密码",
|
|
||||||
"label_reset_password": "发送密码重置邮件:",
|
|
||||||
"credential_email_prompt": "你的注册邮箱",
|
|
||||||
"button_reset": "重置",
|
|
||||||
"button_send_request": "发送请求",
|
|
||||||
"label_server_to_use": "使用的服务器:",
|
|
||||||
"label_wire_transport": "线路传输:",
|
|
||||||
"button_update": "更新",
|
|
||||||
"sidepanel_title_login": "登录",
|
|
||||||
"sidepanel_title_register": "创建账户",
|
|
||||||
"sidepanel_title_settings": "设置",
|
|
||||||
"sidepanel_title_edit_account": "编辑账户",
|
|
||||||
"sidepanel_title_newtpk": "开始新会话",
|
|
||||||
"sidepanel_title_cred": "确认凭据",
|
|
||||||
"sidepanel_title_reset": "重置密码",
|
|
||||||
"sidepanel_title_archive": "已存档会话",
|
|
||||||
"update_available": "更新可用。<a href=\"\">重新载入</a>.",
|
|
||||||
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
|
|
||||||
"reconnect_now": "立即尝试",
|
|
||||||
"phone_dative": "电话",
|
|
||||||
"email_dative": "电子邮件",
|
|
||||||
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
|
|
||||||
"numeric_confirmation_code_prompt": "仅数字",
|
|
||||||
"button_confirm": "确认",
|
|
||||||
"save_attachment": "保存",
|
|
||||||
"invalid_content": "无效内容",
|
|
||||||
"user_not_found": "未找到",
|
|
||||||
"badge_you": "你",
|
|
||||||
"badge_owner": "所有者",
|
|
||||||
"menu_item_info": "信息",
|
|
||||||
"menu_item_clear_messages": "清空消息",
|
|
||||||
"menu_item_clear_messages_for_all": "全部清除",
|
|
||||||
"menu_item_delete": "删除",
|
|
||||||
"menu_item_delete_for_all": "全部删除",
|
|
||||||
"menu_item_mute": "静音",
|
|
||||||
"menu_item_unmute": "取消静音",
|
|
||||||
"menu_item_delete_topic": "删除",
|
|
||||||
"menu_item_unblock": "取消屏蔽",
|
|
||||||
"menu_item_block": "屏蔽",
|
|
||||||
"menu_item_member_delete": "移除",
|
|
||||||
"menu_item_archive_topic": "归档",
|
|
||||||
"action_cancel": "取消",
|
|
||||||
"upload_finishing": "正在结束...",
|
|
||||||
"no_contacts": "你尚无联系人 :-(",
|
|
||||||
"contacts_not_found_short": "无联系人匹配 '{query}'",
|
|
||||||
"title_group_members": "群组成员",
|
|
||||||
"title_all_contacts": "全部联系人",
|
|
||||||
"button_ok": "好",
|
|
||||||
"button_cancel": "取消",
|
|
||||||
"download_action": "下载",
|
|
||||||
"label_file_name": "文件名:",
|
|
||||||
"label_content_type": "内容类型:",
|
|
||||||
"label_size": "大小:",
|
|
||||||
"chat_invitation": "你受邀开始新会话。你想怎么做?",
|
|
||||||
"chat_invitation_accept": "接受",
|
|
||||||
"chat_invitation_ignore": "忽略",
|
|
||||||
"chat_invitation_block": "屏蔽",
|
|
||||||
"error_invalid_id": "无效 ID",
|
|
||||||
"group_user_id_prompt": "群组或用户 ID",
|
|
||||||
"button_subscribe": "订阅",
|
|
||||||
"topic_name_editing_placeholder": "群组自由格式名称",
|
|
||||||
"button_create": "创建",
|
|
||||||
"permission_join": "加入 ({val})",
|
|
||||||
"permission_read": "读取 ({val})",
|
|
||||||
"permission_write": "写入 ({val})",
|
|
||||||
"permission_pres": "获取通知 ({val})",
|
|
||||||
"permission_admin": "批准 ({val})",
|
|
||||||
"permission_share": "分享 ({val})",
|
|
||||||
"permission_delete": "删除 ({val})",
|
|
||||||
"permission_owner": "所有者 ({val})",
|
|
||||||
"title_permissions": "权限",
|
|
||||||
"message_sending": "正在发送...",
|
|
||||||
"message_sending_failed": "发送失败",
|
|
||||||
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
|
|
||||||
"messaging_disabled_prompt": "消息已禁用",
|
|
||||||
"new_message_prompt": "新消息",
|
|
||||||
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
|
|
||||||
"cannot_initiate_file_upload": "无法初始化文件上传。",
|
|
||||||
"tags_not_found": "尚未定义标签。添加一些。",
|
|
||||||
"tags_editor_no_tags": "添加一些标签",
|
|
||||||
"title_manage_tags": "管理标签",
|
|
||||||
"more_online_members": "还有{overflow}个",
|
|
||||||
"label_user_contacts": "往来:",
|
|
||||||
"validate_credential_action": "确认",
|
|
||||||
"label_legal": "法律东西",
|
|
||||||
"link_contact_us": "联系我们",
|
|
||||||
"link_terms_of_service": "条款和条件",
|
|
||||||
"link_privacy_policy": "隐私政策",
|
|
||||||
"action_delete_messages": "删除所有帖子",
|
|
||||||
"action_block_contact": "屏蔽联系人",
|
|
||||||
"delete_messages_warning": ":!![MISSING]!!:Are you sure you want to delete all mesages? It cannot be undone.",
|
|
||||||
"leave_chat_warning": ":!![MISSING]!!:Are you sure you want to leave this conversation.",
|
|
||||||
"block_contact_warning": ":!![MISSING]!!:Are you sure you want to block this contact?",
|
|
||||||
"action_report_chat": "检举垃圾邮件",
|
|
||||||
"report_chat_warning": ":!![MISSING]!!:Are you sure you want to block and report this conversation?",
|
|
||||||
"action_clear_messages": ":!![MISSING]!!:Clear Messages",
|
|
||||||
"clear_messages_warning": ":!![MISSING]!!:Are you sure you want to clear all messages? It cannot be undone.",
|
|
||||||
"topic_delete_warning": ":!![MISSING]!!:Are you sure you want to delete this conversation?",
|
|
||||||
"topic_block_warning": ":!![MISSING]!!:Are you sure you want to block this conversation?"
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// Put all packages together.
|
|
||||||
// Used to generate umd/index.prod.js
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import allMessages from './messages.json';
|
|
||||||
import TinodeWeb from './views/tinode-web.jsx';
|
|
||||||
import HashNavigation from './lib/navigation.js';
|
|
||||||
|
|
||||||
// Detect human language to use in the UI:
|
|
||||||
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
|
|
||||||
const { params } = HashNavigation.parseUrlHash(window.location.hash);
|
|
||||||
const language = (params && params.hl) ||
|
|
||||||
(navigator.languages && navigator.languages[0]) ||
|
|
||||||
navigator.language ||
|
|
||||||
navigator.userLanguage ||
|
|
||||||
'en';
|
|
||||||
|
|
||||||
// Get the base language 'en' from a more specific 'en_GB' or 'en-US' as a partial fallback.
|
|
||||||
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
|
|
||||||
|
|
||||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
|
||||||
const messages =
|
|
||||||
allMessages[language] ||
|
|
||||||
allMessages[baseLanguage] ||
|
|
||||||
allMessages.en;
|
|
||||||
|
|
||||||
ReactDOM.render(
|
|
||||||
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
|
|
||||||
<TinodeWeb />
|
|
||||||
</IntlProvider>,
|
|
||||||
document.getElementById('mountPoint')
|
|
||||||
);
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
// File and image helper functions.
|
|
||||||
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
|
|
||||||
|
|
||||||
// Supported image MIME types and corresponding file extensions.
|
|
||||||
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
|
|
||||||
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
|
|
||||||
|
|
||||||
// Make a data URL from public.photo
|
|
||||||
export function makeImageUrl(photo) {
|
|
||||||
return (photo && photo.type && photo.data) ?
|
|
||||||
'data:image/' + photo.type + ';base64,' + photo.data : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate linear dimensions for scaling image down to fit under a certain size.
|
|
||||||
// Returns an object which contains destination sizes, source sizes, and offsets
|
|
||||||
// into source (when making square images).
|
|
||||||
export function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {
|
|
||||||
// Sanitize input
|
|
||||||
width = width | 0;
|
|
||||||
height = height | 0;
|
|
||||||
maxWidth = maxWidth | 0;
|
|
||||||
maxHeight = maxHeight | 0;
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forceSquare) {
|
|
||||||
maxWidth = maxHeight = Math.min(maxWidth, maxHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
let scale = Math.min(
|
|
||||||
Math.min(width, maxWidth) / width,
|
|
||||||
Math.min(height, maxHeight) / height
|
|
||||||
);
|
|
||||||
|
|
||||||
let size = {
|
|
||||||
dstWidth: (width * scale) | 0,
|
|
||||||
dstHeight: (height * scale) | 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (forceSquare) {
|
|
||||||
// Also calculate parameters for making the image square.
|
|
||||||
size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight);
|
|
||||||
size.srcWidth = size.srcHeight = Math.min(width, height);
|
|
||||||
size.xoffset = ((width - size.srcWidth) / 2) | 0;
|
|
||||||
size.yoffset = ((height - size.srcWidth) / 2) | 0;
|
|
||||||
} else {
|
|
||||||
size.xoffset = size.yoffset = 0;
|
|
||||||
size.srcWidth = width;
|
|
||||||
size.srcHeight = height;
|
|
||||||
}
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure file's extension matches mime content type
|
|
||||||
export function fileNameForMime(fname, mime) {
|
|
||||||
var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
|
|
||||||
var ext = MIME_EXTENSIONS[idx];
|
|
||||||
|
|
||||||
var at = fname.lastIndexOf('.');
|
|
||||||
if (at >= 0) {
|
|
||||||
fname = fname.substring(0, at);
|
|
||||||
}
|
|
||||||
return fname + '.' + ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert uploaded image into a base64-encoded string possibly scaling
|
|
||||||
// linear dimensions or constraining to a square.
|
|
||||||
export function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) {
|
|
||||||
var img = new Image();
|
|
||||||
img.crossOrigin = 'Anonymous';
|
|
||||||
img.onerror = function(err) {
|
|
||||||
onError("Image format unrecognized");
|
|
||||||
}
|
|
||||||
img.onload = function() {
|
|
||||||
var dim = fitImageSize(this.width, this.height, width, height, forceSquare);
|
|
||||||
if (!dim) {
|
|
||||||
onError("Invalid image");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var canvas = document.createElement('canvas');
|
|
||||||
canvas.width = dim.dstWidth;
|
|
||||||
canvas.height = dim.dstHeight;
|
|
||||||
var ctx = canvas.getContext('2d');
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
|
||||||
0, 0, dim.dstWidth, dim.dstHeight);
|
|
||||||
|
|
||||||
var mime = (this.width != dim.dstWidth ||
|
|
||||||
this.height != dim.dstHeight ||
|
|
||||||
SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type;
|
|
||||||
var imageBits = canvas.toDataURL(mime);
|
|
||||||
var parts = imageBits.split(',');
|
|
||||||
// Get actual image type: 'data:image/png;base64,'
|
|
||||||
mime = getMimeType(parts[0]);
|
|
||||||
if (!mime) {
|
|
||||||
onError("Unsupported image format");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ensure the image is not too large
|
|
||||||
var quality = 0.78;
|
|
||||||
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
|
|
||||||
mime = 'image/jpeg';
|
|
||||||
}
|
|
||||||
if (mime == 'image/jpeg') {
|
|
||||||
// Reduce size of the jpeg by reducing image quality
|
|
||||||
while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) {
|
|
||||||
imageBits = canvas.toDataURL(mime, quality);
|
|
||||||
quality *= 0.84;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
|
|
||||||
onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) +
|
|
||||||
" exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
canvas = null;
|
|
||||||
onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime));
|
|
||||||
};
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert uploaded image file to base64-encoded string without scaling/converting the image
|
|
||||||
export function imageFileToBase64(file, onSuccess, onError) {
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.addEventListener('load', function() {
|
|
||||||
var parts = reader.result.split(',');
|
|
||||||
var mime = getMimeType(parts[0]);
|
|
||||||
if (!mime) {
|
|
||||||
onError("Failed to process image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get image size.
|
|
||||||
var img = new Image();
|
|
||||||
img.crossOrigin = 'Anonymous';
|
|
||||||
img.onload = function() {
|
|
||||||
onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime));
|
|
||||||
}
|
|
||||||
img.onerror = function(err) {
|
|
||||||
onError("Image format not recognized");
|
|
||||||
}
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
}, false);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileToBase64(file, onSuccess, onError) {
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.addEventListener('load', function() {
|
|
||||||
onSuccess(file.type, reader.result.split(',')[1], file.name);
|
|
||||||
});
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// File pasted from the clipboard. It's either an inline image or a file attachment.
|
|
||||||
// FIXME: handle large files out of band.
|
|
||||||
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
|
|
||||||
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
|
||||||
for (var i in items) {
|
|
||||||
var item = items[i];
|
|
||||||
if (item.kind === 'file') {
|
|
||||||
var file = item.getAsFile();
|
|
||||||
if (!file) {
|
|
||||||
console.log("Failed to get file object from pasted file item", item.kind, item.type);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (file.type && file.type.split('/')[0] == 'image') {
|
|
||||||
// Handle inline image
|
|
||||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
|
||||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError);
|
|
||||||
} else {
|
|
||||||
imageFileToBase64(file, onImageSuccess, onError);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle file attachment
|
|
||||||
fileToBase64(file, onAttachmentSuccess, onError)
|
|
||||||
}
|
|
||||||
// Indicate that the pasted data contains a file.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No file found.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get mime type from data URL header.
|
|
||||||
export function getMimeType(header) {
|
|
||||||
var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
|
|
||||||
return (mime && mime.length > 1) ? mime[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Given length of a binary object in bytes, calculate the length after
|
|
||||||
// base64 encoding.
|
|
||||||
export function base64EncodedLen(n) {
|
|
||||||
return Math.floor((n + 2) / 3) * 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Given length of a base64-encoded object, calculate decoded size of the
|
|
||||||
// pbject in bytes.
|
|
||||||
export function base64DecodedLen(n) {
|
|
||||||
return Math.floor(n / 4) * 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-encode string to standard base64 encoding with padding.
|
|
||||||
// The string may be base64-URL encoded without the padding.
|
|
||||||
export function base64ReEncode(str) {
|
|
||||||
if (str) {
|
|
||||||
str = str.replace('-', '+').replace('_', '/');
|
|
||||||
try {
|
|
||||||
str = btoa(atob(str));
|
|
||||||
} catch(err) {
|
|
||||||
console.log("Failed to base64 re-encode string");
|
|
||||||
str = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
|
|
||||||
|
|
||||||
// Detect server address from the URL
|
|
||||||
export function detectServerAddress() {
|
|
||||||
let host = DEFAULT_HOST;
|
|
||||||
if (typeof window.location == 'object') {
|
|
||||||
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') {
|
|
||||||
host = KNOWN_HOSTS.local;
|
|
||||||
} else if (window.location.hostname) {
|
|
||||||
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if the page is served over HTTPS.
|
|
||||||
export function isSecureConnection() {
|
|
||||||
if (typeof window.location == 'object') {
|
|
||||||
return window.location.protocol == 'https:';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLocalHost() {
|
|
||||||
if (typeof window.location == 'object') {
|
|
||||||
return window.location.hostname == 'localhost';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// Helper functions for storing values in localStorage.
|
|
||||||
// By default localStorage can store only strings, not objects or other types.
|
|
||||||
|
|
||||||
export default class LocalStorageUtil {
|
|
||||||
// Replace old object with the new one.
|
|
||||||
static setObject(key, value) {
|
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get stored object.
|
|
||||||
static getObject(key) {
|
|
||||||
const value = localStorage.getItem(key);
|
|
||||||
return value && JSON.parse(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partially or wholly update stored object.
|
|
||||||
static updateObject(key, value) {
|
|
||||||
const oldVal = this.getObject(key);
|
|
||||||
this.setObject(key, Object.assign(oldVal || {}, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just a wrapper.
|
|
||||||
static removeItem(key) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// Utility class for hash navigation.
|
|
||||||
|
|
||||||
// Parse hash as in http://www.example.com/path#hash as if it were
|
|
||||||
// path and arguments.
|
|
||||||
export default class HashNavigation {
|
|
||||||
static parseUrlHash(hash) {
|
|
||||||
// Split path from args, path -> parts[0], args->path[1]
|
|
||||||
let parts = hash.split('?', 2);
|
|
||||||
let params = {};
|
|
||||||
let path = [];
|
|
||||||
if (parts[0]) {
|
|
||||||
path = parts[0].substr(1).split('/');
|
|
||||||
}
|
|
||||||
if (parts[1]) {
|
|
||||||
parts[1].split('&').forEach(function(part) {
|
|
||||||
let item = part.split('=');
|
|
||||||
if (item[0]) {
|
|
||||||
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {path: path, params: params};
|
|
||||||
}
|
|
||||||
|
|
||||||
static navigateTo(url) {
|
|
||||||
window.location.hash = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
static composeUrlHash(path, params) {
|
|
||||||
var url = path.join('/');
|
|
||||||
var args = [];
|
|
||||||
for (var key in params) {
|
|
||||||
if (params.hasOwnProperty(key)) {
|
|
||||||
args.push(key + '=' + params[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (args.length > 0) {
|
|
||||||
url += '?' + args.join('&');
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
static addUrlParam(hash, key, value) {
|
|
||||||
var parsed = this.parseUrlHash(hash);
|
|
||||||
parsed.params[key] = value;
|
|
||||||
return this.composeUrlHash(parsed.path, parsed.params);
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeUrlParam(hash, key) {
|
|
||||||
var parsed = this.parseUrlHash(hash);
|
|
||||||
delete parsed.params[key];
|
|
||||||
return this.composeUrlHash(parsed.path, parsed.params);
|
|
||||||
}
|
|
||||||
|
|
||||||
static setUrlSidePanel(hash, sidepanel) {
|
|
||||||
var parsed = this.parseUrlHash(hash);
|
|
||||||
parsed.path[0] = sidepanel;
|
|
||||||
return this.composeUrlHash(parsed.path, parsed.params);
|
|
||||||
}
|
|
||||||
|
|
||||||
static setUrlTopic(hash, topic) {
|
|
||||||
var parsed = this.parseUrlHash(hash);
|
|
||||||
parsed.path[1] = topic;
|
|
||||||
// Close InfoView on topic change.
|
|
||||||
delete parsed.params.info;
|
|
||||||
return this.composeUrlHash(parsed.path, parsed.params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// Short representation of time in the past.
|
|
||||||
export function shortDateFormat(then, locale) {
|
|
||||||
locale = locale || window.navigator.userLanguage || window.navigator.language;
|
|
||||||
const now = new Date();
|
|
||||||
if (then.getFullYear() == now.getFullYear()) {
|
|
||||||
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
|
|
||||||
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
|
|
||||||
} else {
|
|
||||||
return then.toLocaleDateString(locale,
|
|
||||||
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return then.toLocaleDateString(locale,
|
|
||||||
{hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
|
|
||||||
export function secondsToTime(seconds) {
|
|
||||||
const min = Math.floor(seconds / 60);
|
|
||||||
let sec = seconds % 60;
|
|
||||||
sec = sec < 10 ? `0${sec}` : sec;
|
|
||||||
return `${min}:${sec}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a number of bytes to human-readable format.
|
|
||||||
export function bytesToHumanSize(bytes) {
|
|
||||||
if (!bytes || bytes == 0) {
|
|
||||||
return '0 Bytes';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
||||||
const bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1);
|
|
||||||
const count = bytes / Math.pow(1024, bucket);
|
|
||||||
const round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
|
|
||||||
return count.toFixed(round) + ' ' + sizes[bucket];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
|
|
||||||
// as Java's String#hash().
|
|
||||||
export function stringHash(value) {
|
|
||||||
let hash = 0;
|
|
||||||
value = '' + value;
|
|
||||||
for (let i = 0; i < value.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash) + value.charCodeAt(i);
|
|
||||||
hash = hash & hash; // Convert to 32bit integer
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// Odds and ends
|
|
||||||
|
|
||||||
// Make shortcut icon appear with a green dot + show unread count in title.
|
|
||||||
export function updateFavicon(count) {
|
|
||||||
const oldIcon = document.getElementById('shortcut-icon');
|
|
||||||
const head = document.head || document.getElementsByTagName('head')[0];
|
|
||||||
const newIcon = document.createElement('link');
|
|
||||||
newIcon.type = 'image/png';
|
|
||||||
newIcon.id = 'shortcut-icon';
|
|
||||||
newIcon.rel = 'shortcut icon';
|
|
||||||
newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png';
|
|
||||||
if (oldIcon) {
|
|
||||||
head.removeChild(oldIcon);
|
|
||||||
}
|
|
||||||
head.appendChild(newIcon);
|
|
||||||
|
|
||||||
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create VCard which represents topic 'public' info
|
|
||||||
export function vcard(fn, imageDataUrl) {
|
|
||||||
let card = null;
|
|
||||||
|
|
||||||
if ((fn && fn.trim()) || imageDataUrl) {
|
|
||||||
card = {};
|
|
||||||
if (fn) {
|
|
||||||
card.fn = fn.trim();
|
|
||||||
}
|
|
||||||
if (imageDataUrl) {
|
|
||||||
const dataStart = imageDataUrl.indexOf(',');
|
|
||||||
card.photo = dataStart >= 0 ? {
|
|
||||||
data: imageDataUrl.substring(dataStart+1),
|
|
||||||
type: 'jpg'
|
|
||||||
} : imageDataUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep-shallow compare two arrays: shallow compare each element.
|
|
||||||
export function arrayEqual(a, b) {
|
|
||||||
if (a === b) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(a) || !Array.isArray(b)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare lengths first.
|
|
||||||
if (a.length != b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Order of elements is ignored.
|
|
||||||
a.sort();
|
|
||||||
b.sort();
|
|
||||||
for (let i = 0, l = a.length; i < l; i++) {
|
|
||||||
if (a[i] !== b[i]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks (loosely) if the given string is a phone. If so, returns the phone number in a format
|
|
||||||
// as close to E.164 as possible.
|
|
||||||
export function asPhone(val) {
|
|
||||||
val = val.trim();
|
|
||||||
if (/^(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?$/.test(val)) {
|
|
||||||
return val.replace(/[- ().]*/, '');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks (loosely) if the given string is an email. If so returns the email.
|
|
||||||
export function asEmail(val) {
|
|
||||||
val = val.trim();
|
|
||||||
if (/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(val)) {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks if URL is a relative url, i.e. has no 'scheme://', including the case of missing scheme '//'.
|
|
||||||
// The scheme is expected to be RFC-compliant, e.g. [a-z][a-z0-9+.-]*
|
|
||||||
// example.html - ok
|
|
||||||
// https:example.com - not ok.
|
|
||||||
// http:/example.com - not ok.
|
|
||||||
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
|
|
||||||
export function isUrlRelative(url) {
|
|
||||||
return !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
|
|
||||||
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
|
|
||||||
export function sanitizeUrl(url, allowedSchemes) {
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip control characters and whitespace. They are not valid URL characters anyway.
|
|
||||||
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
|
|
||||||
|
|
||||||
// Relative URLs are safe.
|
|
||||||
if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
// Blob URLs are safe.
|
|
||||||
if (/^blob:http/.test(url)) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Absolute URL. Accept only safe schemes, or no scheme.
|
|
||||||
const schemes = Array.isArray(allowedSchemes) ? allowedSchemes.join('|') : 'http|https';
|
|
||||||
const re = new RegExp('^((' + schemes + '):|//)', 'i');
|
|
||||||
if (!re.test(url)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
|
||||||
// have http:, https:, or data: scheme. In case of data: scheme, the URL must start with
|
|
||||||
// a 'data:image/XXXX;base64,'.
|
|
||||||
export function sanitizeImageUrl(url) {
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizedUrl = sanitizeUrl(url);
|
|
||||||
if (sanitizedUrl) {
|
|
||||||
return sanitizedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this a data: URL of an image?
|
|
||||||
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
{
|
|
||||||
"en": {
|
|
||||||
"archived_contacts": "Archived contacts ({count})",
|
|
||||||
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
|
|
||||||
"full_name_prompt": "Full name, e.g. John Doe",
|
|
||||||
"email_prompt": "Email, e.g. jdoe@example.com",
|
|
||||||
"button_sign_up": "Sign up",
|
|
||||||
"validate_credential_action": "confirm",
|
|
||||||
"label_your_name": "Your name",
|
|
||||||
"label_password": "Password",
|
|
||||||
"password_unchanged_prompt": "Unchanged",
|
|
||||||
"label_message_sound": "Message sound:",
|
|
||||||
"label_push_notifications": "Notification alerts:",
|
|
||||||
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
|
|
||||||
"title_tag_manager": "Tags (user discovery)",
|
|
||||||
"label_user_contacts": "Contacts:",
|
|
||||||
"label_user_id": "Address:",
|
|
||||||
"label_default_access_mode": "Default access mode:",
|
|
||||||
"button_logout": "Logout",
|
|
||||||
"label_legal": "Legal",
|
|
||||||
"link_contact_us": "Contact Us",
|
|
||||||
"link_terms_of_service": "Terms of Service",
|
|
||||||
"link_privacy_policy": "Privacy Policy",
|
|
||||||
"requested_permissions": "Requested",
|
|
||||||
"granted_permissions": "Granted",
|
|
||||||
"menu_item_edit_permissions": "Edit permissions",
|
|
||||||
"label_other_user": "Other",
|
|
||||||
"action_clear_messages": "Clear Messages",
|
|
||||||
"clear_messages_warning": "Are you sure you want to clear all messages? It cannot be undone.",
|
|
||||||
"action_delete_messages": "Clear Messages for All",
|
|
||||||
"delete_messages_warning": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
|
|
||||||
"action_leave_chat": "Leave Conversation",
|
|
||||||
"leave_chat_warning": "Are you sure you want to leave this conversation?",
|
|
||||||
"action_block_contact": "Block Contact",
|
|
||||||
"block_contact_warning": "Are you sure you want to block this contact?",
|
|
||||||
"action_report_chat": "Report Conversation",
|
|
||||||
"report_chat_warning": "Are you sure you want to block and report this conversation?",
|
|
||||||
"title_info": "Info",
|
|
||||||
"label_topic_name": "Name",
|
|
||||||
"label_private": "Private comment",
|
|
||||||
"private_editing_placeholder": "Visible to you only",
|
|
||||||
"label_muting_topic": "Muted:",
|
|
||||||
"action_more": "More",
|
|
||||||
"label_your_permissions": "Your permissions:",
|
|
||||||
"label_permissions": "Permissions:",
|
|
||||||
"label_you": "You:",
|
|
||||||
"label_default_access": "Default access mode:",
|
|
||||||
"label_group_members": "Group members:",
|
|
||||||
"button_add_members": "Add members",
|
|
||||||
"group_has_no_members": "No members",
|
|
||||||
"login_prompt": "Login",
|
|
||||||
"password_prompt": "Password",
|
|
||||||
"stay_logged_in": "Stay logged in",
|
|
||||||
"forgot_password_link": "Forgot password?",
|
|
||||||
"button_sign_in": "Sign in",
|
|
||||||
"label_client": "Client:",
|
|
||||||
"label_server": "Server:",
|
|
||||||
"online_now": "online now",
|
|
||||||
"last_seen_timestamp": "Last seen",
|
|
||||||
"title_not_found": "Not found",
|
|
||||||
"unnamed_topic": "Unnamed",
|
|
||||||
"messages_not_readable": "no access to messages",
|
|
||||||
"peers_messaging_disabled": "Peer's messaging is disabled.",
|
|
||||||
"enable_peers_messaging": "Enable",
|
|
||||||
"tabtitle_find_user": "find",
|
|
||||||
"tabtitle_new_group": "new group",
|
|
||||||
"tabtitle_group_by_id": "by id",
|
|
||||||
"search_for_contacts": "Use search to find contacts",
|
|
||||||
"new_password_placeholder": "Enter new password",
|
|
||||||
"label_reset_password": "Send a password reset email:",
|
|
||||||
"credential_email_prompt": "Your registration email",
|
|
||||||
"button_reset": "Reset",
|
|
||||||
"button_send_request": "Send request",
|
|
||||||
"label_server_to_use": "Server to use:",
|
|
||||||
"label_wire_transport": "Wire transport:",
|
|
||||||
"button_update": "Update",
|
|
||||||
"sidepanel_title_login": "Sign In",
|
|
||||||
"sidepanel_title_register": "Create Account",
|
|
||||||
"sidepanel_title_settings": "Settings",
|
|
||||||
"sidepanel_title_edit_account": "Edit Account",
|
|
||||||
"sidepanel_title_newtpk": "Start New Chat",
|
|
||||||
"sidepanel_title_cred": "Confirm Credentials",
|
|
||||||
"sidepanel_title_reset": "Reset Password",
|
|
||||||
"sidepanel_title_archive": "Archived Chats",
|
|
||||||
"update_available": "Update available. <a href=\"\">Reload</a>.",
|
|
||||||
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
|
|
||||||
"reconnect_now": "Try now",
|
|
||||||
"phone_dative": "phone",
|
|
||||||
"email_dative": "email",
|
|
||||||
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
|
|
||||||
"numeric_confirmation_code_prompt": "Numbers only",
|
|
||||||
"button_confirm": "Confirm",
|
|
||||||
"save_attachment": "save",
|
|
||||||
"invalid_content": "invalid content",
|
|
||||||
"user_not_found": "Not found",
|
|
||||||
"badge_you": "you",
|
|
||||||
"badge_owner": "owner",
|
|
||||||
"menu_item_info": "Info",
|
|
||||||
"menu_item_clear_messages": "Clear messages",
|
|
||||||
"menu_item_clear_messages_for_all": "Clear for All",
|
|
||||||
"menu_item_delete": "Delete",
|
|
||||||
"menu_item_delete_for_all": "Delete for All",
|
|
||||||
"menu_item_mute": "Mute",
|
|
||||||
"menu_item_unmute": "Unmute",
|
|
||||||
"menu_item_delete_topic": "Delete",
|
|
||||||
"topic_delete_warning": "Are you sure you want to delete this conversation?",
|
|
||||||
"menu_item_unblock": "Unblock",
|
|
||||||
"menu_item_block": "Block",
|
|
||||||
"topic_block_warning": "Are you sure you want to block this conversation?",
|
|
||||||
"menu_item_member_delete": "Remove",
|
|
||||||
"menu_item_archive_topic": "Archive",
|
|
||||||
"action_cancel": "cancel",
|
|
||||||
"upload_finishing": "finishing...",
|
|
||||||
"no_contacts": "You have no contacts :-(",
|
|
||||||
"contacts_not_found_short": "No contacts match '{query}'",
|
|
||||||
"title_group_members": "Group Members",
|
|
||||||
"title_all_contacts": "All Contacts",
|
|
||||||
"button_ok": "OK",
|
|
||||||
"button_cancel": "Cancel",
|
|
||||||
"more_online_members": "+{overflow} more",
|
|
||||||
"download_action": "download",
|
|
||||||
"label_file_name": "File name:",
|
|
||||||
"label_content_type": "Content type:",
|
|
||||||
"label_size": "Size:",
|
|
||||||
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
|
|
||||||
"chat_invitation_accept": "Accept",
|
|
||||||
"chat_invitation_ignore": "Ignore",
|
|
||||||
"chat_invitation_block": "Block",
|
|
||||||
"error_invalid_id": "Invalid ID",
|
|
||||||
"group_user_id_prompt": "Group or User ID",
|
|
||||||
"button_subscribe": "Subscribe",
|
|
||||||
"topic_name_editing_placeholder": "Freeform name of the group",
|
|
||||||
"button_create": "Create",
|
|
||||||
"permission_join": "Join ({val})",
|
|
||||||
"permission_read": "Read ({val})",
|
|
||||||
"permission_write": "Write ({val})",
|
|
||||||
"permission_pres": "Get notified ({val})",
|
|
||||||
"permission_admin": "Approve ({val})",
|
|
||||||
"permission_share": "Share ({val})",
|
|
||||||
"permission_delete": "Delete ({val})",
|
|
||||||
"permission_owner": "Owner ({val})",
|
|
||||||
"title_permissions": "Permissions",
|
|
||||||
"message_sending": "sending...",
|
|
||||||
"message_sending_failed": "failed",
|
|
||||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
|
||||||
"messaging_disabled_prompt": "Messaging disabled",
|
|
||||||
"new_message_prompt": "New message",
|
|
||||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
|
||||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
|
||||||
"tags_not_found": "No tags defined. Add some.",
|
|
||||||
"tags_editor_no_tags": "Add some tags",
|
|
||||||
"title_manage_tags": "Manage"
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
|
|
||||||
"full_name_prompt": "Полное имя, напр. Иван Петров",
|
|
||||||
"email_prompt": "Email, напр. ivan@example.com",
|
|
||||||
"button_sign_up": "Создать счет",
|
|
||||||
"label_your_name": "Ваше имя",
|
|
||||||
"label_password": "Пароль",
|
|
||||||
"password_unchanged_prompt": "Не изменен",
|
|
||||||
"label_user_id": "Адрес:",
|
|
||||||
"label_default_access_mode": "Доступ по умолчанию:",
|
|
||||||
"label_message_sound": "Звук нового сообщения:",
|
|
||||||
"label_push_notifications": "Уведомления:",
|
|
||||||
"label_push_notifications_disabled": "Уведомления (требуют HTTPS):",
|
|
||||||
"title_tag_manager": "Теги для поиска",
|
|
||||||
"button_logout": "Выйти",
|
|
||||||
"login_prompt": "Логин",
|
|
||||||
"password_prompt": "Пароль",
|
|
||||||
"stay_logged_in": "Запомнить",
|
|
||||||
"forgot_password_link": "Напомнить пароль",
|
|
||||||
"button_sign_in": "Войти",
|
|
||||||
"label_client": "Клиент:",
|
|
||||||
"label_server": "Сервер:",
|
|
||||||
"online_now": "онлайн",
|
|
||||||
"last_seen_timestamp": "Был активен",
|
|
||||||
"title_not_found": "Не найден",
|
|
||||||
"unnamed_topic": "Без названия",
|
|
||||||
"messages_not_readable": "нет доступа к сообщениям",
|
|
||||||
"tabtitle_find_user": "найти",
|
|
||||||
"tabtitle_new_group": "создать",
|
|
||||||
"tabtitle_group_by_id": "по id",
|
|
||||||
"label_server_to_use": "Использовать сервер:",
|
|
||||||
"label_wire_transport": "Соединение:",
|
|
||||||
"button_update": "Применить",
|
|
||||||
"sidepanel_title_login": "Авторизация",
|
|
||||||
"sidepanel_title_register": "Зарегистрироваться",
|
|
||||||
"sidepanel_title_settings": "Настройки",
|
|
||||||
"sidepanel_title_edit_account": "Редактировать счет",
|
|
||||||
"sidepanel_title_newtpk": "Новый чат",
|
|
||||||
"sidepanel_title_cred": "Подтвердить",
|
|
||||||
"sidepanel_title_reset": "Сменить пароль",
|
|
||||||
"tags_not_found": "Тегов нет. Добавьте",
|
|
||||||
"tags_editor_no_tags": "Добавьте теги",
|
|
||||||
"title_manage_tags": "Редактировать",
|
|
||||||
"message_sending": "в пути...",
|
|
||||||
"message_sending_failed": "ошибка",
|
|
||||||
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
|
|
||||||
"messaging_disabled_prompt": "Отправка недоступна",
|
|
||||||
"new_message_prompt": "Новое сообщение",
|
|
||||||
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
|
|
||||||
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
|
|
||||||
"search_for_contacts": "Поиск контактов",
|
|
||||||
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
|
|
||||||
"numeric_confirmation_code_prompt": "Только цифры",
|
|
||||||
"button_confirm": "Подтвердить",
|
|
||||||
"button_ok": "OK",
|
|
||||||
"button_cancel": "Отменить",
|
|
||||||
"invalid_content": "сообщение не читается",
|
|
||||||
"label_file_name": "Имя файла:",
|
|
||||||
"label_content_type": "Тип:",
|
|
||||||
"label_size": "Размер:",
|
|
||||||
"phone_dative": "телефону",
|
|
||||||
"email_dative": "емейлу",
|
|
||||||
"title_group_members": "Участники",
|
|
||||||
"download_action": "скачать",
|
|
||||||
"permission_join": "Подписываться ({val})",
|
|
||||||
"permission_read": "Читать ({val})",
|
|
||||||
"permission_write": "Писать ({val})",
|
|
||||||
"permission_pres": "Уведомлять ({val})",
|
|
||||||
"permission_admin": "Подтверждать ({val})",
|
|
||||||
"permission_share": "Приглашать ({val})",
|
|
||||||
"permission_delete": "Удалять ({val})",
|
|
||||||
"permission_owner": "Владелец ({val})",
|
|
||||||
"title_permissions": "Права доступа",
|
|
||||||
"requested_permissions": "Требуются",
|
|
||||||
"granted_permissions": "Получены",
|
|
||||||
"menu_item_edit_permissions": "Права доступа",
|
|
||||||
"label_other_user": "Второй",
|
|
||||||
"label_topic_name": "Название",
|
|
||||||
"label_private": "Комментарий",
|
|
||||||
"private_editing_placeholder": "Виден только вам",
|
|
||||||
"label_muting_topic": "Без уведомлений",
|
|
||||||
"action_more": "Ещё",
|
|
||||||
"label_your_permissions": "Ваши права доступа:",
|
|
||||||
"label_permissions": "Права доступа:",
|
|
||||||
"label_you": "Вы:",
|
|
||||||
"label_default_access": "Права по умолчанию:",
|
|
||||||
"label_group_members": "Участники чата:",
|
|
||||||
"button_add_members": "Добавить",
|
|
||||||
"group_has_no_members": "Нет участников",
|
|
||||||
"action_leave_chat": "Уйти из чата",
|
|
||||||
"menu_item_info": "Информация",
|
|
||||||
"menu_item_clear_messages": "Удалить сообщения",
|
|
||||||
"menu_item_clear_messages_for_all": "Удалить для всех",
|
|
||||||
"menu_item_delete": "Удалить",
|
|
||||||
"menu_item_delete_for_all": "Удалить для всех",
|
|
||||||
"menu_item_mute": "Не уведомлять",
|
|
||||||
"menu_item_unmute": "Уведомлять",
|
|
||||||
"menu_item_delete_topic": "Удалить чат",
|
|
||||||
"menu_item_unblock": "Разблокировать",
|
|
||||||
"menu_item_block": "Заблокировать",
|
|
||||||
"menu_item_member_delete": "Отписать",
|
|
||||||
"title_info": "Подробности",
|
|
||||||
"new_password_placeholder": "Введите новый пароль",
|
|
||||||
"label_reset_password": "Отправить емейл для смены пароля:",
|
|
||||||
"credential_email_prompt": "Регистрационный емейл",
|
|
||||||
"button_reset": "Изменить",
|
|
||||||
"button_send_request": "Отправить",
|
|
||||||
"action_cancel": "отменить",
|
|
||||||
"upload_finishing": "завершение...",
|
|
||||||
"no_contacts": "Ничего нет :-(",
|
|
||||||
"contacts_not_found_short": "Нет контактов для запроса '{query}'",
|
|
||||||
"title_all_contacts": "Все контакты",
|
|
||||||
"error_invalid_id": "Неверный ID",
|
|
||||||
"group_user_id_prompt": "ID чата или пользователя",
|
|
||||||
"button_subscribe": "Подписаться",
|
|
||||||
"topic_name_editing_placeholder": "Название чата",
|
|
||||||
"button_create": "Создать",
|
|
||||||
"badge_you": "вы",
|
|
||||||
"badge_owner": "влад.",
|
|
||||||
"update_available": "Есть новая версия приложения. <a href=\"\">Обновить</a>.",
|
|
||||||
"user_not_found": "Не найден",
|
|
||||||
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
|
|
||||||
"reconnect_now": "Подключить сейчас.",
|
|
||||||
"save_attachment": "сохранить",
|
|
||||||
"menu_item_archive_topic": "В архив",
|
|
||||||
"archived_contacts": "Чаты в архиве ({count})",
|
|
||||||
"sidepanel_title_archive": "Архив чатов",
|
|
||||||
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
|
|
||||||
"chat_invitation_accept": "Принять",
|
|
||||||
"chat_invitation_ignore": "Игнорировать",
|
|
||||||
"chat_invitation_block": "Заблокировать",
|
|
||||||
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
|
|
||||||
"enable_peers_messaging": "Разблокировать.",
|
|
||||||
"more_online_members": "+еще {overflow}",
|
|
||||||
"label_user_contacts": "Конакты:",
|
|
||||||
"validate_credential_action": "подтвердить",
|
|
||||||
"label_legal": "Прочее",
|
|
||||||
"link_contact_us": "Связаться с нами",
|
|
||||||
"link_terms_of_service": "Условия сервиса",
|
|
||||||
"link_privacy_policy": "Политика конфиденциальности",
|
|
||||||
"action_delete_messages": "Удалить сообщения",
|
|
||||||
"action_block_contact": "Заблокировать контакт",
|
|
||||||
"action_report_chat": "Сообщить о нарушении",
|
|
||||||
"delete_messages_warning": "Вы действительно хотите удалить все сообщения?",
|
|
||||||
"leave_chat_warning": "Вы действительно хотите покинуть этот чат?",
|
|
||||||
"block_contact_warning": "Вы действительно заблокировать этот контакт?",
|
|
||||||
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
|
|
||||||
"action_clear_messages": "Удалить сообщения",
|
|
||||||
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить."
|
|
||||||
},
|
|
||||||
"zh": {
|
|
||||||
"archived_contacts": "已归档联系人 ({count})",
|
|
||||||
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
|
|
||||||
"full_name_prompt": "全名,例如张伟",
|
|
||||||
"email_prompt": "电子邮件,例如 zhang@example.com",
|
|
||||||
"button_sign_up": "注册",
|
|
||||||
"label_your_name": "你的姓名",
|
|
||||||
"label_password": "密码",
|
|
||||||
"password_unchanged_prompt": "未改变",
|
|
||||||
"label_user_id": "地址:",
|
|
||||||
"label_default_access_mode": "蓦然访问模式:",
|
|
||||||
"label_message_sound": "消息提示音:",
|
|
||||||
"label_push_notifications": "通知提醒:",
|
|
||||||
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
|
|
||||||
"title_tag_manager": "标签(用户发现)",
|
|
||||||
"button_logout": "登出",
|
|
||||||
"requested_permissions": "已请求",
|
|
||||||
"granted_permissions": "已授予",
|
|
||||||
"menu_item_edit_permissions": "编辑权限",
|
|
||||||
"label_other_user": "其他",
|
|
||||||
"title_info": "信息",
|
|
||||||
"label_topic_name": "名称",
|
|
||||||
"label_private": "私人评论",
|
|
||||||
"private_editing_placeholder": "仅自己可见",
|
|
||||||
"label_muting_topic": "已静音:",
|
|
||||||
"action_more": "更多",
|
|
||||||
"label_your_permissions": "你的权限:",
|
|
||||||
"label_permissions": "权限:",
|
|
||||||
"label_you": "你:",
|
|
||||||
"label_default_access": "默认权限模式:",
|
|
||||||
"label_group_members": "群组成员:",
|
|
||||||
"button_add_members": "添加成员",
|
|
||||||
"group_has_no_members": "无成员",
|
|
||||||
"action_leave_chat": "离开",
|
|
||||||
"login_prompt": "登录",
|
|
||||||
"password_prompt": "密码",
|
|
||||||
"stay_logged_in": "保持登录",
|
|
||||||
"forgot_password_link": "忘记密码?",
|
|
||||||
"button_sign_in": "登录",
|
|
||||||
"label_client": "客户端:",
|
|
||||||
"label_server": "服务器:",
|
|
||||||
"online_now": "在线",
|
|
||||||
"last_seen_timestamp": "最后可见",
|
|
||||||
"title_not_found": "无法找到",
|
|
||||||
"unnamed_topic": "未命名",
|
|
||||||
"messages_not_readable": "无消息访问权限",
|
|
||||||
"peers_messaging_disabled": "成员间消息已禁用。",
|
|
||||||
"enable_peers_messaging": "启用",
|
|
||||||
"tabtitle_find_user": "搜索",
|
|
||||||
"tabtitle_new_group": "新群组",
|
|
||||||
"tabtitle_group_by_id": "通过 id",
|
|
||||||
"search_for_contacts": "使用搜索寻找联系人",
|
|
||||||
"new_password_placeholder": "输入新密码",
|
|
||||||
"label_reset_password": "发送密码重置邮件:",
|
|
||||||
"credential_email_prompt": "你的注册邮箱",
|
|
||||||
"button_reset": "重置",
|
|
||||||
"button_send_request": "发送请求",
|
|
||||||
"label_server_to_use": "使用的服务器:",
|
|
||||||
"label_wire_transport": "线路传输:",
|
|
||||||
"button_update": "更新",
|
|
||||||
"sidepanel_title_login": "登录",
|
|
||||||
"sidepanel_title_register": "创建账户",
|
|
||||||
"sidepanel_title_settings": "设置",
|
|
||||||
"sidepanel_title_edit_account": "编辑账户",
|
|
||||||
"sidepanel_title_newtpk": "开始新会话",
|
|
||||||
"sidepanel_title_cred": "确认凭据",
|
|
||||||
"sidepanel_title_reset": "重置密码",
|
|
||||||
"sidepanel_title_archive": "已存档会话",
|
|
||||||
"update_available": "更新可用。<a href=\"\">重新载入</a>.",
|
|
||||||
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
|
|
||||||
"reconnect_now": "立即尝试",
|
|
||||||
"phone_dative": "电话",
|
|
||||||
"email_dative": "电子邮件",
|
|
||||||
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
|
|
||||||
"numeric_confirmation_code_prompt": "仅数字",
|
|
||||||
"button_confirm": "确认",
|
|
||||||
"save_attachment": "保存",
|
|
||||||
"invalid_content": "无效内容",
|
|
||||||
"user_not_found": "未找到",
|
|
||||||
"badge_you": "你",
|
|
||||||
"badge_owner": "所有者",
|
|
||||||
"menu_item_info": "信息",
|
|
||||||
"menu_item_clear_messages": "清空消息",
|
|
||||||
"menu_item_clear_messages_for_all": "全部清除",
|
|
||||||
"menu_item_delete": "删除",
|
|
||||||
"menu_item_delete_for_all": "全部删除",
|
|
||||||
"menu_item_mute": "静音",
|
|
||||||
"menu_item_unmute": "取消静音",
|
|
||||||
"menu_item_delete_topic": "删除",
|
|
||||||
"menu_item_unblock": "取消屏蔽",
|
|
||||||
"menu_item_block": "屏蔽",
|
|
||||||
"menu_item_member_delete": "移除",
|
|
||||||
"menu_item_archive_topic": "归档",
|
|
||||||
"action_cancel": "取消",
|
|
||||||
"upload_finishing": "正在结束...",
|
|
||||||
"no_contacts": "你尚无联系人 :-(",
|
|
||||||
"contacts_not_found_short": "无联系人匹配 '{query}'",
|
|
||||||
"title_group_members": "群组成员",
|
|
||||||
"title_all_contacts": "全部联系人",
|
|
||||||
"button_ok": "好",
|
|
||||||
"button_cancel": "取消",
|
|
||||||
"download_action": "下载",
|
|
||||||
"label_file_name": "文件名:",
|
|
||||||
"label_content_type": "内容类型:",
|
|
||||||
"label_size": "大小:",
|
|
||||||
"chat_invitation": "你受邀开始新会话。你想怎么做?",
|
|
||||||
"chat_invitation_accept": "接受",
|
|
||||||
"chat_invitation_ignore": "忽略",
|
|
||||||
"chat_invitation_block": "屏蔽",
|
|
||||||
"error_invalid_id": "无效 ID",
|
|
||||||
"group_user_id_prompt": "群组或用户 ID",
|
|
||||||
"button_subscribe": "订阅",
|
|
||||||
"topic_name_editing_placeholder": "群组自由格式名称",
|
|
||||||
"button_create": "创建",
|
|
||||||
"permission_join": "加入 ({val})",
|
|
||||||
"permission_read": "读取 ({val})",
|
|
||||||
"permission_write": "写入 ({val})",
|
|
||||||
"permission_pres": "获取通知 ({val})",
|
|
||||||
"permission_admin": "批准 ({val})",
|
|
||||||
"permission_share": "分享 ({val})",
|
|
||||||
"permission_delete": "删除 ({val})",
|
|
||||||
"permission_owner": "所有者 ({val})",
|
|
||||||
"title_permissions": "权限",
|
|
||||||
"message_sending": "正在发送...",
|
|
||||||
"message_sending_failed": "发送失败",
|
|
||||||
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
|
|
||||||
"messaging_disabled_prompt": "消息已禁用",
|
|
||||||
"new_message_prompt": "新消息",
|
|
||||||
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
|
|
||||||
"cannot_initiate_file_upload": "无法初始化文件上传。",
|
|
||||||
"tags_not_found": "尚未定义标签。添加一些。",
|
|
||||||
"tags_editor_no_tags": "添加一些标签",
|
|
||||||
"title_manage_tags": "管理标签",
|
|
||||||
"more_online_members": "还有{overflow}个",
|
|
||||||
"label_user_contacts": "往来:",
|
|
||||||
"validate_credential_action": "确认",
|
|
||||||
"label_legal": "法律东西",
|
|
||||||
"link_contact_us": "联系我们",
|
|
||||||
"link_terms_of_service": "条款和条件",
|
|
||||||
"link_privacy_policy": "隐私政策",
|
|
||||||
"action_delete_messages": "删除所有帖子",
|
|
||||||
"action_block_contact": "屏蔽联系人",
|
|
||||||
"action_report_chat": "检举垃圾邮件"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// This is a generated file. Don't edit.
|
|
||||||
|
|
||||||
export const PACKAGE_VERSION = "0.16.2-rc4";
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/* ContactsView holds all contacts-related stuff */
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedHTMLMessage, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import ContactList from '../widgets/contact-list.jsx';
|
|
||||||
|
|
||||||
import { updateFavicon } from '../lib/utils.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
archived_contacts_title: {
|
|
||||||
id: "archived_contacts",
|
|
||||||
defaultMessage: "Archived contacts ({count})",
|
|
||||||
description: "Label for archived chats"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default class ContactsView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleAction = this.handleAction.bind(this);
|
|
||||||
|
|
||||||
this.state = ContactsView.deriveStateFromProps(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
static deriveStateFromProps(props) {
|
|
||||||
const contacts = [];
|
|
||||||
let unreadThreads = 0;
|
|
||||||
let archivedCount = 0;
|
|
||||||
props.chatList.map((c) => {
|
|
||||||
if (c.private && c.private.arch) {
|
|
||||||
if (props.archive) {
|
|
||||||
contacts.push(c);
|
|
||||||
} else {
|
|
||||||
archivedCount ++;
|
|
||||||
}
|
|
||||||
} else if (!props.archive) {
|
|
||||||
contacts.push(c);
|
|
||||||
unreadThreads += c.unread > 0 ? 1 : 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
contacts.sort(function(a, b) {
|
|
||||||
return (b.touched || 0) - (a.touched || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (archivedCount > 0) {
|
|
||||||
contacts.push({
|
|
||||||
action: 'archive',
|
|
||||||
title: messages.archived_contacts_title,
|
|
||||||
values: {count: archivedCount}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
contactList: contacts,
|
|
||||||
unreadThreads: unreadThreads
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
if (prevProps.chatList != this.props.chatList || prevProps.archive != this.props.archive) {
|
|
||||||
const newState = ContactsView.deriveStateFromProps(this.props);
|
|
||||||
this.setState(newState);
|
|
||||||
if (newState.unreadThreads != prevState.unreadThreads) {
|
|
||||||
updateFavicon(newState.unreadThreads);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAction(action_ignored) {
|
|
||||||
this.props.onShowArchive();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<FormattedHTMLMessage id="contacts_not_found"
|
|
||||||
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
|
|
||||||
description="HTML message shown in ContactList when no contacts are found">{
|
|
||||||
(no_contacts) => <ContactList
|
|
||||||
connected={this.props.connected}
|
|
||||||
contacts={this.state.contactList}
|
|
||||||
emptyListMessage={no_contacts}
|
|
||||||
topicSelected={this.props.topicSelected}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
showOnline={true}
|
|
||||||
showUnread={true}
|
|
||||||
onTopicSelected={this.props.onTopicSelected}
|
|
||||||
showContextMenu={this.props.showContextMenu}
|
|
||||||
onAction={this.handleAction} />
|
|
||||||
}</FormattedHTMLMessage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
// Account registration.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
|
||||||
import CheckBox from '../widgets/checkbox.jsx';
|
|
||||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
|
||||||
|
|
||||||
import LocalStorageUtil from '../lib/local-storage.js';
|
|
||||||
import { vcard } from '../lib/utils.js';
|
|
||||||
|
|
||||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
|
||||||
|
|
||||||
export default class CreateAccountView extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
login: '',
|
|
||||||
password: '',
|
|
||||||
email: '',
|
|
||||||
fn: '', // full/formatted name
|
|
||||||
imageDataUrl: null,
|
|
||||||
errorCleared: false,
|
|
||||||
saveToken: LocalStorageUtil.getObject('keep-logged-in')
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleLoginChange = this.handleLoginChange.bind(this);
|
|
||||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
|
||||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
|
||||||
this.handleFnChange = this.handleFnChange.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
|
||||||
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoginChange(e) {
|
|
||||||
this.setState({login: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePasswordChange(password) {
|
|
||||||
this.setState({password: password});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEmailChange(e) {
|
|
||||||
this.setState({email: e.target.value})
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFnChange(e) {
|
|
||||||
this.setState({fn: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageChanged(img) {
|
|
||||||
this.setState({imageDataUrl: img});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggleSaveToken() {
|
|
||||||
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
|
|
||||||
this.setState({saveToken: !this.state.saveToken});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.setState({errorCleared: false});
|
|
||||||
this.props.onCreateAccount(
|
|
||||||
this.state.login.trim(),
|
|
||||||
this.state.password.trim(),
|
|
||||||
vcard(this.state.fn.trim().substring(0, MAX_TITLE_LENGTH), this.state.imageDataUrl),
|
|
||||||
{'meth': 'email', 'val': this.state.email});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let submitClasses = 'blue';
|
|
||||||
if (this.props.disabled) {
|
|
||||||
submitClasses += ' disabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="panel-form-column" onSubmit={this.handleSubmit}>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<FormattedMessage id="login_prompt">{
|
|
||||||
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
|
|
||||||
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<FormattedMessage id="password_prompt">{
|
|
||||||
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
|
|
||||||
value={this.state.password} onFinished={this.handlePasswordChange}
|
|
||||||
required={true} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<AvatarUpload
|
|
||||||
onImageChanged={this.handleImageChanged}
|
|
||||||
onError={this.props.onError} />
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
|
|
||||||
description="Input placeholder for person's full name">{
|
|
||||||
(full_name_prompt) => <input type="text" placeholder={full_name_prompt} autoComplete="name"
|
|
||||||
value={this.state.fn} onChange={this.handleFnChange} required/>
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
|
|
||||||
description="Input placeholder for email entry">{
|
|
||||||
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
|
|
||||||
value={this.state.email} onChange={this.handleEmailChange} required/>
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
|
||||||
onChange={this.handleToggleSaveToken} />
|
|
||||||
<FormattedMessage id="stay_logged_in">{
|
|
||||||
(stay_logged_in) => <label htmlFor="save-token"> {stay_logged_in}</label>
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className={submitClasses} type="submit">
|
|
||||||
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
|
|
||||||
description="Create account button [Sign Up]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
// Edit account parameters.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
|
||||||
import CheckBox from '../widgets/checkbox.jsx';
|
|
||||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
|
||||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
|
||||||
import TagManager from '../widgets/tag-manager.jsx';
|
|
||||||
|
|
||||||
import { LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE, MAX_TITLE_LENGTH } from '../config.js';
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
import { arrayEqual, asEmail, asPhone, vcard } from '../lib/utils.js';
|
|
||||||
|
|
||||||
export default class EditAccountView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
const defacs = me.getDefaultAccess();
|
|
||||||
this.state = {
|
|
||||||
fullName: me.public ? me.public.fn : undefined,
|
|
||||||
avatar: makeImageUrl(me.public ? me.public.photo : null),
|
|
||||||
auth: defacs ? defacs.auth : null,
|
|
||||||
anon: defacs ? defacs.anon : null,
|
|
||||||
tags: me.tags(),
|
|
||||||
credentials: me.getCredentials() || [],
|
|
||||||
addCredActive: false,
|
|
||||||
addCredInvalid: false,
|
|
||||||
newCred: '',
|
|
||||||
showPermissionEditorFor: undefined,
|
|
||||||
previousOnTags: me.onTagsUpdated
|
|
||||||
};
|
|
||||||
|
|
||||||
this.tnNewTags = this.tnNewTags.bind(this);
|
|
||||||
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
|
|
||||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
|
||||||
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
|
||||||
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
|
|
||||||
this.handleCredChange = this.handleCredChange.bind(this);
|
|
||||||
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
|
|
||||||
this.handleCredEntered = this.handleCredEntered.bind(this);
|
|
||||||
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
|
||||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
|
||||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
|
||||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
me.onCredsUpdated = this.tnCredsUpdated;
|
|
||||||
me.onTagsUpdated = this.tnNewTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
const me = this.props.tinode.getMeTopic();
|
|
||||||
me.onTagsUpdated = this.state.previousOnTags;
|
|
||||||
me.onCredsUpdated = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
tnNewTags(tags) {
|
|
||||||
this.setState({tags: tags});
|
|
||||||
}
|
|
||||||
|
|
||||||
tnCredsUpdated(creds) {
|
|
||||||
this.setState({credentials: creds || []});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFullNameUpdate(fn) {
|
|
||||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (fn) {
|
|
||||||
this.setState({fullName: fn});
|
|
||||||
this.props.onUpdateAccount(undefined, vcard(fn, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePasswordUpdate(pwd) {
|
|
||||||
this.setState({password: pwd});
|
|
||||||
this.props.onUpdateAccount(pwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageChanged(img) {
|
|
||||||
this.setState({avatar: img});
|
|
||||||
this.props.onUpdateAccount(undefined, vcard(null, img || Tinode.DEL_CHAR));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCheckboxClick(what, checked) {
|
|
||||||
if (what == 'sound') {
|
|
||||||
this.props.onToggleMessageSounds(checked);
|
|
||||||
} else if (what == 'alert') {
|
|
||||||
this.props.onTogglePushNotifications(checked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCredChange(e) {
|
|
||||||
this.setState({newCred: e.target.value, addCredInvalid: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCredKeyDown(e) {
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
// Escape pressed
|
|
||||||
this.setState({newCred: '', addCredActive: false});
|
|
||||||
} else if (e.keyCode === 13) {
|
|
||||||
// Enter pressed
|
|
||||||
this.handleCredEntered(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCredEntered(e) {
|
|
||||||
let value = this.state.newCred.trim();
|
|
||||||
if (!value) {
|
|
||||||
this.setState({addCredActive: false, addCredInvalid: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let val = asPhone(value);
|
|
||||||
let method;
|
|
||||||
if (val) {
|
|
||||||
method = 'tel';
|
|
||||||
} else {
|
|
||||||
val = asEmail(value);
|
|
||||||
if (val) {
|
|
||||||
method = 'email';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (method) {
|
|
||||||
this.props.onCredAdd(method, val);
|
|
||||||
this.setState({addCredActive: false, newCred: ''});
|
|
||||||
} else {
|
|
||||||
this.setState({addCredInvalid: true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLaunchPermissionsEditor(which) {
|
|
||||||
this.setState({
|
|
||||||
showPermissionEditorFor: which,
|
|
||||||
editedPermissions: this.state[which]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHidePermissionsEditor() {
|
|
||||||
this.setState({showPermissionEditorFor: undefined});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePermissionsChanged(perm) {
|
|
||||||
let defacs = {};
|
|
||||||
defacs[this.state.showPermissionEditorFor] = perm;
|
|
||||||
this.props.onUpdateAccount(undefined, undefined, defacs);
|
|
||||||
|
|
||||||
let newState = {showPermissionEditorFor: undefined};
|
|
||||||
newState[this.state.showPermissionEditorFor] = perm;
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTagsUpdated(tags) {
|
|
||||||
// Check if tags have actually changed.
|
|
||||||
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.props.onUpdateTags(tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let credentials = [];
|
|
||||||
this.state.credentials.map((cred) => {
|
|
||||||
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
|
|
||||||
<span > {!cred.done ?
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
|
|
||||||
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
|
|
||||||
description="Validate credentail call to action" />
|
|
||||||
</a>
|
|
||||||
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
|
|
||||||
className="material-icons gray">delete_outline</i></a></span></div>);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>{this.state.showPermissionEditorFor ?
|
|
||||||
<PermissionsEditor
|
|
||||||
mode={this.state.editedPermissions}
|
|
||||||
skip="O"
|
|
||||||
onSubmit={this.handlePermissionsChanged}
|
|
||||||
onCancel={this.handleHidePermissionsEditor} />
|
|
||||||
:
|
|
||||||
<div id="edit-account" className="scrollable-panel">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div><label className="small">
|
|
||||||
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
|
||||||
description="Label for full name editing" />
|
|
||||||
</label></div>
|
|
||||||
<div><FormattedMessage id="full_name_prompt">{
|
|
||||||
(full_name_placeholder) => <InPlaceEdit
|
|
||||||
placeholder={full_name_placeholder}
|
|
||||||
value={this.state.fullName}
|
|
||||||
onFinished={this.handleFullNameUpdate} />
|
|
||||||
}</FormattedMessage></div>
|
|
||||||
<div><label className="small">
|
|
||||||
<FormattedMessage id="label_password" defaultMessage="Password"
|
|
||||||
description="Label for password editing" />
|
|
||||||
</label></div>
|
|
||||||
<div>
|
|
||||||
<FormattedMessage id="password_unchanged_prompt" defaultMessage="Unchanged"
|
|
||||||
description="Message in editor while password is unchanged">{
|
|
||||||
(password_unchanged) => <InPlaceEdit
|
|
||||||
placeholder={password_unchanged}
|
|
||||||
type="password"
|
|
||||||
onFinished={this.handlePasswordUpdate} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AvatarUpload
|
|
||||||
avatar={this.state.avatar}
|
|
||||||
uid={this.props.myUserId}
|
|
||||||
title={this.state.fullName}
|
|
||||||
onImageChanged={this.handleImageChanged}
|
|
||||||
onError={this.props.onError} />
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label htmlFor="message-sound">
|
|
||||||
<FormattedMessage id="label_message_sound" defaultMessage="Message sound:"
|
|
||||||
description="Label for message sounds toggle" />
|
|
||||||
</label>
|
|
||||||
<CheckBox name="sound" id="message-sound"
|
|
||||||
checked={this.props.messageSounds}
|
|
||||||
onChange={this.handleCheckboxClick} />
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label htmlFor="desktop-alerts">
|
|
||||||
{this.props.desktopAlertsEnabled ?
|
|
||||||
<FormattedMessage id="label_push_notifications" defaultMessage="Notification alerts:"
|
|
||||||
description="Label for push notifications switch" />
|
|
||||||
:
|
|
||||||
<FormattedMessage id="label_push_notifications_disabled"
|
|
||||||
defaultMessage="Notification alerts (requires HTTPS):"
|
|
||||||
description="Label for push notifications switch" />
|
|
||||||
}
|
|
||||||
</label>
|
|
||||||
<CheckBox name="alert" id="desktop-alerts"
|
|
||||||
checked={this.props.desktopAlerts}
|
|
||||||
onChange={this.props.desktopAlertsEnabled ? this.handleCheckboxClick : null} />
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
|
||||||
description="Section title for TagManager">{
|
|
||||||
(title_tag_manager) => <TagManager
|
|
||||||
title={title_tag_manager}
|
|
||||||
activated={false}
|
|
||||||
tags={this.state.tags}
|
|
||||||
onSubmit={this.handleTagsUpdated} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
|
|
||||||
description="Label for user contacts" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
{credentials}
|
|
||||||
{this.state.addCredActive ?
|
|
||||||
<input type="text" value={this.state.value}
|
|
||||||
className={this.state.addCredInvalid ? "invalid" : null}
|
|
||||||
placeholder="Phone number or email" required="required" autoFocus
|
|
||||||
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
|
|
||||||
: null}
|
|
||||||
<div><a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>+ Add another</a></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="Address:"
|
|
||||||
description="Label for user address (ID)" /></label>
|
|
||||||
<tt>{this.props.myUserId}</tt>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
|
|
||||||
description="Label for default access mode" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<div>Auth: <tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>{this.state.auth}</tt></div>
|
|
||||||
<div>Anon: <tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>{this.state.anon}</tt></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<a href="#" className="red flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
|
|
||||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
|
||||||
defaultMessage="Logout" description="Button [Logout]" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_legal" defaultMessage="Legal"
|
|
||||||
description="Section label" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
|
|
||||||
<i className="material-icons">email</i> <FormattedMessage id="link_contact_us"
|
|
||||||
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
|
|
||||||
<i className="material-icons">description</i> <FormattedMessage id="link_terms_of_service"
|
|
||||||
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
|
|
||||||
<i className="material-icons">policy</i> <FormattedMessage id="link_privacy_policy"
|
|
||||||
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,764 +0,0 @@
|
|||||||
// InfoView: panel with topic/user info.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
|
||||||
import CheckBox from '../widgets/checkbox.jsx';
|
|
||||||
import ContactList from '../widgets/contact-list.jsx';
|
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
|
||||||
import GroupManager from '../widgets/group-manager.jsx';
|
|
||||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
|
||||||
import MenuCancel from '../widgets/menu-cancel.jsx';
|
|
||||||
import MoreButton from '../widgets/more-button.jsx';
|
|
||||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
|
||||||
import TagManager from '../widgets/tag-manager.jsx';
|
|
||||||
|
|
||||||
import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
|
||||||
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
import { arrayEqual, vcard } from '../lib/utils.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
requested: {
|
|
||||||
id: 'requested_permissions',
|
|
||||||
defaultMessage: 'Requested',
|
|
||||||
description: 'Title for permissions'
|
|
||||||
},
|
|
||||||
granted: {
|
|
||||||
id: 'granted_permissions',
|
|
||||||
defaultMessage: 'Granted',
|
|
||||||
description: 'Title for permissions'
|
|
||||||
},
|
|
||||||
edit_permissions: {
|
|
||||||
id: 'menu_item_edit_permissions',
|
|
||||||
defaultMessage: 'Edit permissions',
|
|
||||||
description: 'Menu item [Edit permissions]'
|
|
||||||
},
|
|
||||||
other_user: {
|
|
||||||
id: 'label_other_user',
|
|
||||||
defaultMessage: 'Other',
|
|
||||||
description: 'Label for the other user when the user is unnamed'
|
|
||||||
},
|
|
||||||
clear_messages: {
|
|
||||||
id: 'action_clear_messages',
|
|
||||||
defaultMessage: 'Clear Messages',
|
|
||||||
description: 'Flat button [Clear Messages] (soft-delete messages)'
|
|
||||||
},
|
|
||||||
clear_messages_warning: {
|
|
||||||
id: 'clear_messages_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
|
|
||||||
description: 'Alert dialog warning when deleting all messages.'
|
|
||||||
},
|
|
||||||
delete_messages: {
|
|
||||||
id: 'action_delete_messages',
|
|
||||||
defaultMessage: 'Clear Messages for All',
|
|
||||||
description: 'Flat button [Clear for All] (hard-delete all messages)'
|
|
||||||
},
|
|
||||||
delete_messages_warning: {
|
|
||||||
id: 'delete_messages_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
|
|
||||||
description: 'Alert dialog warning when hard-deleting all messages.'
|
|
||||||
},
|
|
||||||
leave_chat: {
|
|
||||||
id: 'action_leave_chat',
|
|
||||||
defaultMessage: 'Leave Conversation',
|
|
||||||
description: 'Flat button [Leave Conversation]'
|
|
||||||
},
|
|
||||||
leave_chat_warning: {
|
|
||||||
id: 'leave_chat_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to leave this conversation?',
|
|
||||||
description: 'Alert dialog warning when unsubscribing from a chat.'
|
|
||||||
},
|
|
||||||
block_contact: {
|
|
||||||
id: 'action_block_contact',
|
|
||||||
defaultMessage: "Block Contact",
|
|
||||||
description: "Flat button [Block Contact]"
|
|
||||||
},
|
|
||||||
block_contact_warning: {
|
|
||||||
id: 'block_contact_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to block this contact?',
|
|
||||||
description: 'Alert dialog warning when blocking a contact.'
|
|
||||||
},
|
|
||||||
report_chat: {
|
|
||||||
id: 'action_report_chat',
|
|
||||||
defaultMessage: 'Report Conversation',
|
|
||||||
description: 'Flat button [Report Group]'
|
|
||||||
},
|
|
||||||
report_chat_warning: {
|
|
||||||
id: 'report_chat_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to block and report this conversation?',
|
|
||||||
description: 'Alert dialog warning when reporting a conversation for abuse'
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
class InfoView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
topic: null,
|
|
||||||
owner: false,
|
|
||||||
admin: false,
|
|
||||||
sharer: false,
|
|
||||||
deleter: false,
|
|
||||||
muted: false,
|
|
||||||
address: null,
|
|
||||||
groupTopic: undefined,
|
|
||||||
fullName: undefined,
|
|
||||||
avatar: null,
|
|
||||||
private: null,
|
|
||||||
selectedContact: null,
|
|
||||||
access: null,
|
|
||||||
modeGiven: null,
|
|
||||||
modeWant: null,
|
|
||||||
modeGiven2: null, // P2P topic, the other user mode given
|
|
||||||
modeWant2: null, // P2P topic, the other user mode want
|
|
||||||
auth: null,
|
|
||||||
anon: null,
|
|
||||||
contactList: [],
|
|
||||||
tags: [],
|
|
||||||
showMemberPanel: false,
|
|
||||||
showPermissionEditorFor: undefined,
|
|
||||||
moreInfoExpanded: false,
|
|
||||||
previousMetaDesc: undefined,
|
|
||||||
previousSubsUpdated: undefined,
|
|
||||||
previousTagsUpdated: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.resetSubs = this.resetSubs.bind(this);
|
|
||||||
this.resetDesc = this.resetDesc.bind(this);
|
|
||||||
this.onMetaDesc = this.onMetaDesc.bind(this);
|
|
||||||
this.onSubsUpdated = this.onSubsUpdated.bind(this);
|
|
||||||
this.onTagsUpdated = this.onTagsUpdated.bind(this);
|
|
||||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
|
||||||
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
|
||||||
this.handleMuted = this.handleMuted.bind(this);
|
|
||||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
|
||||||
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
|
||||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
|
||||||
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
|
|
||||||
this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
|
|
||||||
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
|
||||||
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
|
|
||||||
this.handleLeave = this.handleLeave.bind(this);
|
|
||||||
this.handleBlock = this.handleBlock.bind(this);
|
|
||||||
this.handleReport = this.handleReport.bind(this);
|
|
||||||
this.handleMemberSelected = this.handleMemberSelected.bind(this);
|
|
||||||
this.handleMoreInfo = this.handleMoreInfo.bind(this);
|
|
||||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
|
||||||
this.handleContextMenu = this.handleContextMenu.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No need to separately handle component mount.
|
|
||||||
componentDidUpdate(props) {
|
|
||||||
const topic = this.props.tinode.getTopic(props.topic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.onMetaDesc != topic.onMetaDesc) {
|
|
||||||
this.previousMetaDesc = topic.onMetaDesc;
|
|
||||||
topic.onMetaDesc = this.onMetaDesc;
|
|
||||||
|
|
||||||
this.previousSubsUpdated = topic.onSubsUpdated;
|
|
||||||
topic.onSubsUpdated = this.onSubsUpdated;
|
|
||||||
|
|
||||||
if (topic.getType() == 'grp') {
|
|
||||||
this.previousTagsUpdated = topic.onTagsUpdated;
|
|
||||||
topic.onTagsUpdated = this.onTagsUpdated;
|
|
||||||
} else {
|
|
||||||
this.previousTagsUpdated = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.topic != props.topic) {
|
|
||||||
this.setState({topic: props.topic});
|
|
||||||
this.resetDesc(topic, props);
|
|
||||||
this.resetSubs(topic, props);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({topic: null});
|
|
||||||
topic.onMetaDesc = this.previousMetaDesc;
|
|
||||||
topic.onSubsUpdated = this.previousSubsUpdated;
|
|
||||||
topic.onTagsUpdated = this.previousTagsUpdated;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSubs(topic, props) {
|
|
||||||
const newState = {contactList: []};
|
|
||||||
if (topic.getType() == 'p2p') {
|
|
||||||
// Fetch the other party in the p2p conversation.
|
|
||||||
// Topic may not be ready yet, so check if user is found.
|
|
||||||
const user2 = topic.subscriber(props.topic);
|
|
||||||
if (user2) {
|
|
||||||
newState.modeGiven2 = user2.acs.getGiven();
|
|
||||||
newState.modeWant2 = user2.acs.getWant();
|
|
||||||
} else {
|
|
||||||
newState.modeGiven2 = NO_ACCESS_MODE;
|
|
||||||
newState.modeWant2 = NO_ACCESS_MODE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
topic.subscribers((sub) => {
|
|
||||||
newState.contactList.push(sub);
|
|
||||||
}, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetDesc(topic, props) {
|
|
||||||
const defacs = topic.getDefaultAccess() || {};
|
|
||||||
const acs = topic.getAccessMode();
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
owner: acs && acs.isOwner(),
|
|
||||||
admin: acs && acs.isAdmin(),
|
|
||||||
sharer: acs && acs.isSharer(),
|
|
||||||
deleter: acs && acs.isDeleter(),
|
|
||||||
muted: acs && acs.isMuted(),
|
|
||||||
|
|
||||||
fullName: topic.public ? topic.public.fn : undefined,
|
|
||||||
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
|
||||||
private: topic.private ? topic.private.comment : null,
|
|
||||||
address: topic.name,
|
|
||||||
groupTopic: (topic.getType() == 'grp'),
|
|
||||||
showMemberPanel: false,
|
|
||||||
access: acs ? acs.getMode() : undefined,
|
|
||||||
modeGiven: acs ? acs.getGiven() : undefined,
|
|
||||||
modeWant: acs ? acs.getWant() : undefined,
|
|
||||||
auth: defacs.auth,
|
|
||||||
anon: defacs.anon
|
|
||||||
});
|
|
||||||
|
|
||||||
if (topic.getType() == 'grp' && acs && acs.isOwner()) {
|
|
||||||
// Requesting tags: owner is editing the topic.
|
|
||||||
topic.getMeta(topic.startMetaQuery().withTags().build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMetaDesc(desc) {
|
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.resetDesc(topic, this.props);
|
|
||||||
|
|
||||||
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
|
|
||||||
this.previousMetaDesc(desc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubsUpdated(subs) {
|
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.resetSubs(topic, this.props);
|
|
||||||
|
|
||||||
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
|
|
||||||
this.previousSubsUpdated(subs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onTagsUpdated(tags) {
|
|
||||||
this.setState({tags: tags});
|
|
||||||
|
|
||||||
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
|
|
||||||
this.previousTagsUpdated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFullNameUpdate(fn) {
|
|
||||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (this.state.fullName !== fn) {
|
|
||||||
this.setState({fullName: fn});
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, vcard(fn, null), null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePrivateUpdate(comment) {
|
|
||||||
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (this.state.private !== comment) {
|
|
||||||
this.setState({private: comment});
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, null, comment || Tinode.DEL_CHAR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageChanged(img) {
|
|
||||||
this.setState({avatar: img});
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, vcard(null, img || Tinode.DEL_CHAR), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMuted(ignored, checked) {
|
|
||||||
this.setState({muted: checked});
|
|
||||||
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePermissionsChanged(perm) {
|
|
||||||
switch (this.state.showPermissionEditorFor) {
|
|
||||||
case 'auth':
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
|
|
||||||
break;
|
|
||||||
case 'anon':
|
|
||||||
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
|
|
||||||
break;
|
|
||||||
case 'mode':
|
|
||||||
case 'want':
|
|
||||||
this.props.onChangePermissions(this.props.topic, perm);
|
|
||||||
break;
|
|
||||||
case 'given':
|
|
||||||
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
|
|
||||||
break;
|
|
||||||
case 'user':
|
|
||||||
this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({showPermissionEditorFor: undefined});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLaunchPermissionsEditor(which, uid) {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
let toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar;
|
|
||||||
switch (which) {
|
|
||||||
case 'mode':
|
|
||||||
toEdit = this.state.access;
|
|
||||||
break;
|
|
||||||
case 'want':
|
|
||||||
toEdit = this.state.modeWant;
|
|
||||||
toCompare = this.state.modeGiven;
|
|
||||||
toSkip = this.state.groupTopic ? 'O' : 'ASDO';
|
|
||||||
titleEdit = formatMessage(messages.requested);
|
|
||||||
titleCompare = formatMessage(messages.granted);
|
|
||||||
break;
|
|
||||||
case 'given':
|
|
||||||
toEdit = this.state.modeGiven2;
|
|
||||||
toCompare = this.state.modeWant2;
|
|
||||||
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
|
|
||||||
titleEdit = formatMessage(messages.granted);
|
|
||||||
titleCompare = formatMessage(messages.requested);
|
|
||||||
break;
|
|
||||||
case 'auth':
|
|
||||||
toEdit = this.state.auth;
|
|
||||||
toSkip = 'O';
|
|
||||||
break;
|
|
||||||
case 'anon':
|
|
||||||
toEdit = this.state.anon;
|
|
||||||
toSkip = 'O';
|
|
||||||
break;
|
|
||||||
case 'user': {
|
|
||||||
let topic = this.props.tinode.getTopic(this.props.topic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var user = topic.subscriber(uid);
|
|
||||||
if (!user || !user.acs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toEdit = user.acs.getGiven();
|
|
||||||
toCompare = user.acs.getWant();
|
|
||||||
toSkip = this.state.owner ? '' : 'O';
|
|
||||||
titleEdit = formatMessage(messages.granted);
|
|
||||||
titleCompare = formatMessage(messages.requested);
|
|
||||||
if (user.public) {
|
|
||||||
userTitle = user.public.fn;
|
|
||||||
userAvatar = user.public.photo;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
console.log("Unknown permission editing mode '" + which + "'");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
showPermissionEditorFor: which,
|
|
||||||
userPermissionsEdited: uid,
|
|
||||||
userPermissionsTitle: userTitle,
|
|
||||||
userPermissionsAvatar: userAvatar,
|
|
||||||
editedPermissions: toEdit,
|
|
||||||
immutablePermissions: toCompare,
|
|
||||||
editedPermissionsTitle: titleEdit,
|
|
||||||
immutablePermissionsTitle: titleCompare,
|
|
||||||
editedPermissionsSkipped: toSkip,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHidePermissionsEditor() {
|
|
||||||
this.setState({showPermissionEditorFor: undefined});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleShowAddMembers(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onInitFind();
|
|
||||||
this.setState({showMemberPanel: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHideAddMembers() {
|
|
||||||
this.setState({showMemberPanel: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMemberUpdateRequest(members, added, removed) {
|
|
||||||
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
|
|
||||||
this.setState({showMemberPanel: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDeleteMessages(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages), // title
|
|
||||||
formatMessage(this.state.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
|
|
||||||
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLeave(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.leave_chat), // title
|
|
||||||
formatMessage(messages.leave_chat_warning), // content
|
|
||||||
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBlock(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.block_contact), // title
|
|
||||||
formatMessage(messages.block_contact_warning), // content
|
|
||||||
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReport(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
this.props.onShowAlert(
|
|
||||||
formatMessage(messages.report_chat), // title
|
|
||||||
formatMessage(messages.report_chat_warning), // content
|
|
||||||
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMemberSelected(uid) {
|
|
||||||
this.setState({selectedContact: uid});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMoreInfo(open) {
|
|
||||||
this.setState({moreInfoExpanded: open});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTagsUpdated(tags) {
|
|
||||||
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
|
||||||
this.props.onTopicTagsUpdate(this.props.topic, tags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextMenu(params) {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
const instance = this;
|
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = topic.subscriber(params.topicName);
|
|
||||||
if (!user || !user.acs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{title: formatMessage(messages.edit_permissions), handler: function() {
|
|
||||||
instance.handleLaunchPermissionsEditor('user', params.topicName);
|
|
||||||
}},
|
|
||||||
'member_delete',
|
|
||||||
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
|
|
||||||
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
|
|
||||||
];
|
|
||||||
this.props.showContextMenu({
|
|
||||||
topicName: this.props.topic,
|
|
||||||
x: params.x,
|
|
||||||
y: params.y,
|
|
||||||
user: params.topicName}, menuItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="info-view">
|
|
||||||
<div className="caption-panel" id="info-caption-panel">
|
|
||||||
<div className="panel-title" id="info-title">
|
|
||||||
<FormattedMessage id="title_info" defaultMessage="Info" description="Title for InfoView" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<MenuCancel onCancel={this.props.onCancel} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{this.props.displayMobile ?
|
|
||||||
<ErrorPanel
|
|
||||||
level={this.props.errorLevel}
|
|
||||||
text={this.props.errorText}
|
|
||||||
onClearError={this.props.onError} /> : null}
|
|
||||||
{this.state.showMemberPanel ?
|
|
||||||
<GroupManager
|
|
||||||
members={this.state.contactList}
|
|
||||||
requiredMember={this.props.myUserId}
|
|
||||||
keepInitialMembers={!this.state.admin && !this.state.owner}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
contacts={this.props.searchableContacts}
|
|
||||||
onCancel={this.handleHideAddMembers}
|
|
||||||
onSubmit={this.handleMemberUpdateRequest} />
|
|
||||||
:
|
|
||||||
this.state.showPermissionEditorFor ?
|
|
||||||
<PermissionsEditor
|
|
||||||
mode={this.state.editedPermissions}
|
|
||||||
compare={this.state.immutablePermissions}
|
|
||||||
skip={this.state.editedPermissionsSkipped}
|
|
||||||
modeTitle={this.state.editedPermissionsTitle}
|
|
||||||
compareTitle={this.state.immutablePermissionsTitle}
|
|
||||||
userTitle={this.state.userPermissionsTitle}
|
|
||||||
item={this.state.userPermissionsEdited}
|
|
||||||
userAvatar={this.state.userPermissionsAvatar}
|
|
||||||
onSubmit={this.handlePermissionsChanged}
|
|
||||||
onCancel={this.handleHidePermissionsEditor}
|
|
||||||
/>
|
|
||||||
:
|
|
||||||
<div id="info-view-content" className="scrollable-panel">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div><label className="small">
|
|
||||||
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
|
||||||
description="Label for editing topic name" />
|
|
||||||
</label></div>
|
|
||||||
<div><InPlaceEdit
|
|
||||||
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
|
|
||||||
readOnly={!this.state.owner}
|
|
||||||
value={this.state.fullName}
|
|
||||||
required={true}
|
|
||||||
onFinished={this.handleFullNameUpdate} /></div>
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
|
||||||
description="Label for editing 'private'" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormattedMessage id="private_editing_placeholder"
|
|
||||||
defaultMessage="Visible to you only"
|
|
||||||
description="Placeholder for editing 'private'">{
|
|
||||||
(private_placeholder) => <InPlaceEdit
|
|
||||||
placeholder={private_placeholder}
|
|
||||||
value={this.state.private}
|
|
||||||
onFinished={this.handlePrivateUpdate} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AvatarUpload
|
|
||||||
avatar={this.state.avatar}
|
|
||||||
readOnly={!this.state.owner}
|
|
||||||
uid={this.props.topic}
|
|
||||||
title={this.state.fullName}
|
|
||||||
onImageChanged={this.handleImageChanged}
|
|
||||||
onError={this.props.onError} />
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label>
|
|
||||||
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
|
|
||||||
description="Label for Muting/unmuting the topic" />
|
|
||||||
</label>
|
|
||||||
<CheckBox name="P" checked={this.state.muted}
|
|
||||||
onChange={this.handleMuted} />
|
|
||||||
</div>
|
|
||||||
<FormattedMessage id="action_more" defaultMessage="More"
|
|
||||||
description="Action for showing more content">{
|
|
||||||
(more) => <MoreButton
|
|
||||||
title={more}
|
|
||||||
open={this.state.moreInfoExpanded}
|
|
||||||
onToggle={this.handleMoreInfo} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
{this.state.moreInfoExpanded ?
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label><FormattedMessage id="label_user_id" /></label>
|
|
||||||
<tt>{this.state.address}</tt>
|
|
||||||
</div>
|
|
||||||
{this.state.groupTopic ?
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label>
|
|
||||||
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:"
|
|
||||||
description="Label for current user permissions" />
|
|
||||||
</label>
|
|
||||||
<tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
|
||||||
{this.state.access}
|
|
||||||
</tt>
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
|
|
||||||
description="Section title" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<div>
|
|
||||||
<FormattedMessage id="label_you" defaultMessage="You:"
|
|
||||||
description="Label for the current user" /> <tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
|
||||||
{this.state.access}
|
|
||||||
</tt></div>
|
|
||||||
<div>{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}:
|
|
||||||
<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
|
|
||||||
{this.state.modeGiven2}
|
|
||||||
</tt>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{this.state.sharer && (this.state.auth || this.state.anon) ?
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_default_access" defaultMessage="Default access mode:"
|
|
||||||
description="Section title" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="quoted">
|
|
||||||
<div>Auth: {this.state.admin ?
|
|
||||||
<tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
|
|
||||||
{this.state.auth}
|
|
||||||
</tt>
|
|
||||||
:
|
|
||||||
<tt>{this.state.auth}</tt>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div>Anon: {this.state.admin ?
|
|
||||||
<tt className="clickable"
|
|
||||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
|
|
||||||
{this.state.anon}
|
|
||||||
</tt>
|
|
||||||
:
|
|
||||||
<tt>{this.state.anon}</tt>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="hr" />
|
|
||||||
{this.state.owner ?
|
|
||||||
<>
|
|
||||||
<FormattedMessage id="title_tag_manager">{
|
|
||||||
(tags) => <TagManager
|
|
||||||
title={tags}
|
|
||||||
tags={this.state.tags}
|
|
||||||
activated={false}
|
|
||||||
onSubmit={this.handleTagsUpdated} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<div className="hr" />
|
|
||||||
</>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
|
|
||||||
<i className="material-icons">delete_outline</i> {
|
|
||||||
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
<a href="#" className="red flat-button" onClick={this.handleLeave}>
|
|
||||||
<i className="material-icons">exit_to_app</i> {formatMessage(messages.leave_chat)}
|
|
||||||
</a>
|
|
||||||
{!this.state.groupTopic ?
|
|
||||||
<a href="#" className="red flat-button" onClick={this.handleBlock}>
|
|
||||||
<i className="material-icons">block</i> {formatMessage(messages.block_contact)}
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
{!this.state.owner ?
|
|
||||||
<a href="#" className="red flat-button" onClick={this.handleReport}>
|
|
||||||
<i className="material-icons">report</i> {formatMessage(messages.report_chat)}
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{this.state.groupTopic ?
|
|
||||||
<>
|
|
||||||
<div className="hr" />
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
|
|
||||||
description="Section title or label" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
{this.state.sharer ?
|
|
||||||
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
|
|
||||||
<i className="material-icons">person_add</i> <FormattedMessage id="button_add_members"
|
|
||||||
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
|
|
||||||
</a>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
|
|
||||||
description="Shown in place of group members">{
|
|
||||||
(no_members) => <ContactList
|
|
||||||
contacts={this.state.contactList}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
emptyListMessage={no_members}
|
|
||||||
topicSelected={this.state.selectedContact}
|
|
||||||
showOnline={false}
|
|
||||||
showUnread={false}
|
|
||||||
showMode={true}
|
|
||||||
noScroll={true}
|
|
||||||
onTopicSelected={this.handleMemberSelected}
|
|
||||||
showContextMenu={this.state.admin ? this.handleContextMenu : false}
|
|
||||||
/>
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(InfoView);
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// Login form.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import CheckBox from '../widgets/checkbox.jsx';
|
|
||||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
|
||||||
|
|
||||||
import LocalStorageUtil from '../lib/local-storage.js';
|
|
||||||
|
|
||||||
export default class LoginView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
login: props.login,
|
|
||||||
password: '',
|
|
||||||
hostName: props.serverAddress,
|
|
||||||
saveToken: LocalStorageUtil.getObject('keep-logged-in')
|
|
||||||
};
|
|
||||||
this.handleLoginChange = this.handleLoginChange.bind(this);
|
|
||||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
|
||||||
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoginChange(e) {
|
|
||||||
this.setState({login: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePasswordChange(e) {
|
|
||||||
this.setState({password: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggleSaveToken() {
|
|
||||||
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
|
|
||||||
this.setState({saveToken: !this.state.saveToken});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
var submitClasses = 'blue';
|
|
||||||
if (this.props.disabled) {
|
|
||||||
submitClasses += ' disabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form id="login-form" onSubmit={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="login_prompt" defaultMessage="Login"
|
|
||||||
description="Login placeholder in LoginView">
|
|
||||||
{
|
|
||||||
(login_prompt) => <input type="text" id="inputLogin"
|
|
||||||
placeholder={login_prompt}
|
|
||||||
autoComplete="username"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="none"
|
|
||||||
value={this.state.login}
|
|
||||||
onChange={this.handleLoginChange}
|
|
||||||
required autoFocus />
|
|
||||||
}
|
|
||||||
</FormattedMessage>
|
|
||||||
<FormattedMessage id="password_prompt" defaultMessage="Password"
|
|
||||||
description="Password placeholder in LoginView">
|
|
||||||
{
|
|
||||||
(password_prompt) => <VisiblePassword type="password" id="inputPassword"
|
|
||||||
placeholder={password_prompt}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={this.state.password}
|
|
||||||
onChange={this.handlePasswordChange}
|
|
||||||
required={true} />
|
|
||||||
}
|
|
||||||
</FormattedMessage>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
|
||||||
onChange={this.handleToggleSaveToken} />
|
|
||||||
<label htmlFor="save-token">
|
|
||||||
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
|
|
||||||
description="Label for a checkbox" />
|
|
||||||
</label>
|
|
||||||
<a href="#reset">
|
|
||||||
<FormattedMessage id="forgot_password_link" defaultMessage="Forgot password?"
|
|
||||||
description="Link to Reset password form" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className={submitClasses} type="submit">
|
|
||||||
<FormattedMessage id="button_sign_in" defaultMessage="Sign in"
|
|
||||||
description="Button [Sign In]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
/* END Login */
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/* This is just a static page to display when no conversation is selected. */
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import { APP_NAME } from '../config.js';
|
|
||||||
|
|
||||||
export default class LogoView extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
var version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
|
|
||||||
return (
|
|
||||||
<div id="dummy-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/tinode/chat/">
|
|
||||||
<img id="logo" alt="logo" src="img/logo.svg" />
|
|
||||||
<h2>Tinode Web</h2>
|
|
||||||
</a>
|
|
||||||
<p><FormattedMessage id="label_client" defaultMessage="Client:" /> {version}</p>
|
|
||||||
<p><FormattedMessage id="label_server" defaultMessage="Server:" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,664 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
const Drafty = Tinode.Drafty;
|
|
||||||
|
|
||||||
import ChatMessage from '../widgets/chat-message.jsx';
|
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
|
||||||
import GroupSubs from '../widgets/group-subs.jsx';
|
|
||||||
import ImagePreview from '../widgets/image-preview.jsx';
|
|
||||||
import Invitation from '../widgets/Invitation.jsx';
|
|
||||||
import LetterTile from '../widgets/letter-tile.jsx';
|
|
||||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
|
||||||
import LogoView from './logo-view.jsx';
|
|
||||||
import SendMessage from '../widgets/send-message.jsx';
|
|
||||||
|
|
||||||
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE } from '../config.js';
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
import { shortDateFormat } from '../lib/strformat.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
online_now: {
|
|
||||||
id: 'online_now',
|
|
||||||
defaultMessage: 'online now',
|
|
||||||
description: 'Indicator that the user or topic is currently online',
|
|
||||||
},
|
|
||||||
last_seen: {
|
|
||||||
id: 'last_seen_timestamp',
|
|
||||||
defaultMessage: 'Last seen',
|
|
||||||
description: 'Label for the timestamp of when the user or topic was last online'
|
|
||||||
},
|
|
||||||
'not_found': {
|
|
||||||
id: 'title_not_found',
|
|
||||||
defaultMessage: 'Not found',
|
|
||||||
description: 'Title shown when topic is not found'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checks if the access permissions are granted but not yet accepted.
|
|
||||||
function isUnconfirmed(acs) {
|
|
||||||
if (acs) {
|
|
||||||
const ex = acs.getExcessive() || '';
|
|
||||||
return acs.isJoiner('given') && (ex.includes('R') || ex.includes('W'));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPeerRestricted(acs) {
|
|
||||||
if (acs) {
|
|
||||||
const ms = acs.getMissing() || '';
|
|
||||||
return acs.isJoiner('want') && (ms.includes('R') || ms.includes('W'));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessagesView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = MessagesView.getDerivedStateFromProps(props, {});
|
|
||||||
|
|
||||||
// this.propsChange = this.propsChange.bind(this);
|
|
||||||
this.leave = this.leave.bind(this);
|
|
||||||
this.handleScrollReference = this.handleScrollReference.bind(this);
|
|
||||||
this.handleScrollEvent = this.handleScrollEvent.bind(this);
|
|
||||||
this.handleDescChange = this.handleDescChange.bind(this);
|
|
||||||
this.handleSubsUpdated = this.handleSubsUpdated.bind(this);
|
|
||||||
this.handleNewMessage = this.handleNewMessage.bind(this);
|
|
||||||
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
|
|
||||||
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
|
|
||||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
|
||||||
this.handleCloseImagePreview = this.handleCloseImagePreview.bind(this);
|
|
||||||
this.handleFormResponse = this.handleFormResponse.bind(this);
|
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
|
||||||
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
|
|
||||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
|
||||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
// this.propsChange(this.props, this.state);
|
|
||||||
if (this.messagesScroller) {
|
|
||||||
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this.messagesScroller) {
|
|
||||||
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll last message into view on component update e.g. on message received
|
|
||||||
// or vertical shrinking.
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
if (this.messagesScroller) {
|
|
||||||
if (prevState.topic != this.state.topic || prevState.messages.length != this.state.messages.length) {
|
|
||||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
|
|
||||||
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
|
|
||||||
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
|
||||||
if (this.state.topic != prevState.topic) {
|
|
||||||
if (prevState.topic && !Tinode.isNewGroupTopicName(prevState.topic)) {
|
|
||||||
this.leave(prevState.topic);
|
|
||||||
this.props.readTimerHandler(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic) {
|
|
||||||
topic.onData = this.handleNewMessage;
|
|
||||||
topic.onAllMessagesReceived = this.handleAllMessagesReceived;
|
|
||||||
topic.onInfo = this.handleInfoReceipt;
|
|
||||||
topic.onMetaDesc = this.handleDescChange;
|
|
||||||
topic.onSubsUpdated = this.handleSubsUpdated;
|
|
||||||
topic.onPres = this.handleSubsUpdated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic && !topic.isSubscribed() && this.props.ready &&
|
|
||||||
((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
|
||||||
// Is this a new topic?
|
|
||||||
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
|
|
||||||
|
|
||||||
// Don't request the tags. They are useless unless the user
|
|
||||||
// is the owner and is editing the topic.
|
|
||||||
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
|
|
||||||
if (this.state.isReader || newTopic) {
|
|
||||||
// If reading is either permitted or we don't know because it's a new topic. Ask for messages.
|
|
||||||
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
|
|
||||||
if (this.state.isReader) {
|
|
||||||
getQuery = getQuery.withLaterDel();
|
|
||||||
}
|
|
||||||
// And show "loading" spinner.
|
|
||||||
this.setState({ fetchingMessages: true });
|
|
||||||
}
|
|
||||||
const setQuery = newTopic ? this.props.newTopicParams : undefined;
|
|
||||||
topic.subscribe(getQuery.build(), setQuery)
|
|
||||||
.then((ctrl) => {
|
|
||||||
if (this.state.topic != ctrl.topic) {
|
|
||||||
this.setState({topic: ctrl.topic});
|
|
||||||
}
|
|
||||||
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
|
||||||
// If there are unsent messages, try sending them now.
|
|
||||||
topic.queuedMessages((pub) => {
|
|
||||||
if (!pub._sending && topic.isSubscribed()) {
|
|
||||||
topic.publishMessage(pub);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.log("Failed subscription to", this.state.topic);
|
|
||||||
this.props.onError(err.message, 'err');
|
|
||||||
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
|
||||||
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
|
||||||
this.setState(blankState);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromProps(nextProps, prevState) {
|
|
||||||
let nextState = {};
|
|
||||||
if (!nextProps.topic) {
|
|
||||||
// Default state: no topic.
|
|
||||||
nextState = {
|
|
||||||
messages: [],
|
|
||||||
onlineSubs: [],
|
|
||||||
topic: null,
|
|
||||||
title: '',
|
|
||||||
avatar: null,
|
|
||||||
imagePreview: null,
|
|
||||||
typingIndicator: false,
|
|
||||||
scrollPosition: 0,
|
|
||||||
fetchingMessages: false,
|
|
||||||
peerMessagingDisabled: false
|
|
||||||
};
|
|
||||||
} else if (nextProps.topic != prevState.topic) {
|
|
||||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
|
||||||
nextState = {
|
|
||||||
topic: nextProps.topic,
|
|
||||||
imagePreview: null,
|
|
||||||
typingIndicator: false,
|
|
||||||
scrollPosition: 0,
|
|
||||||
fetchingMessages: false
|
|
||||||
};
|
|
||||||
|
|
||||||
if (topic) {
|
|
||||||
// Topic exists.
|
|
||||||
const msgs = [];
|
|
||||||
const subs = [];
|
|
||||||
|
|
||||||
if (nextProps.connected) {
|
|
||||||
topic.subscribers((sub) => {
|
|
||||||
if (sub.online && sub.user != nextProps.myUserId) {
|
|
||||||
subs.push(sub);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.messages(function(msg) {
|
|
||||||
if (!msg.deleted) {
|
|
||||||
msgs.push(msg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.assign(nextState, {
|
|
||||||
messages: msgs,
|
|
||||||
onlineSubs: subs
|
|
||||||
});
|
|
||||||
|
|
||||||
if (topic.public) {
|
|
||||||
Object.assign(nextState, {
|
|
||||||
title: topic.public.fn,
|
|
||||||
avatar: makeImageUrl(topic.public.photo)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Object.assign(nextState, {
|
|
||||||
title: '',
|
|
||||||
avatar: null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const peer = topic.p2pPeerDesc();
|
|
||||||
if (peer) {
|
|
||||||
Object.assign(nextState, {
|
|
||||||
peerMessagingDisabled: isPeerRestricted(peer.acs)
|
|
||||||
});
|
|
||||||
} else if (prevState.peerMessagingDisabled) {
|
|
||||||
Object.assign(nextState, {
|
|
||||||
peerMessagingDisabled: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Invalid topic.
|
|
||||||
Object.assign(nextState, {
|
|
||||||
messages: [],
|
|
||||||
onlineSubs: [],
|
|
||||||
title: '',
|
|
||||||
avatar: null,
|
|
||||||
peerMessagingDisabled: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextProps.acs) {
|
|
||||||
if (nextProps.acs.isWriter() != prevState.isWriter) {
|
|
||||||
nextState.isWriter = !prevState.isWriter;
|
|
||||||
}
|
|
||||||
if (nextProps.acs.isReader() != prevState.isReader) {
|
|
||||||
nextState.isReader = !prevState.isReader;
|
|
||||||
}
|
|
||||||
if (!nextProps.acs.isReader('given') != prevState.readingBlocked) {
|
|
||||||
nextState.readingBlocked = !prevState.readingBlocked;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (prevState.isWriter) {
|
|
||||||
nextState.isWriter = false;
|
|
||||||
}
|
|
||||||
if (prevState.isReader) {
|
|
||||||
nextState.isReader = false;
|
|
||||||
}
|
|
||||||
if (!prevState.readingBlocked) {
|
|
||||||
prevState.readingBlocked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
|
|
||||||
nextState.unconfirmed = !prevState.unconformed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear subscribers online when there is no connection.
|
|
||||||
if (!nextProps.connected && prevState.onlineSubs && prevState.onlineSubs.length > 0) {
|
|
||||||
nextState.onlineSubs = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextState;
|
|
||||||
}
|
|
||||||
|
|
||||||
leave(oldTopicName) {
|
|
||||||
if (!oldTopicName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let oldTopic = this.props.tinode.getTopic(oldTopicName);
|
|
||||||
if (oldTopic && oldTopic.isSubscribed()) {
|
|
||||||
oldTopic.leave(false)
|
|
||||||
.catch(() => { /* do nothing here */ })
|
|
||||||
.finally(() => {
|
|
||||||
// We don't care if the request succeeded or failed.
|
|
||||||
// The topic is dead regardless.
|
|
||||||
this.setState({fetchingMessages: false});
|
|
||||||
oldTopic.onData = undefined;
|
|
||||||
oldTopic.onAllMessagesReceived = undefined;
|
|
||||||
oldTopic.onInfo = undefined;
|
|
||||||
oldTopic.onMetaDesc = undefined;
|
|
||||||
oldTopic.onSubsUpdated = undefined;
|
|
||||||
oldTopic.onPres = undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleScrollReference(node) {
|
|
||||||
if (node) {
|
|
||||||
node.addEventListener('scroll', this.handleScrollEvent);
|
|
||||||
this.messagesScroller = node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get older messages
|
|
||||||
handleScrollEvent(event) {
|
|
||||||
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
|
|
||||||
if (event.target.scrollTop <= 0) {
|
|
||||||
this.setState((prevState, props) => {
|
|
||||||
const newState = {};
|
|
||||||
if (!prevState.fetchingMessages) {
|
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
|
||||||
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
|
|
||||||
newState.fetchingMessages = true;
|
|
||||||
topic.getMessagesPage(MESSAGES_PAGE).catch((err) => {
|
|
||||||
this.setState({fetchingMessages: false});
|
|
||||||
this.props.onError(err.message, 'err');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDescChange(desc) {
|
|
||||||
if (desc.public) {
|
|
||||||
this.setState({
|
|
||||||
title: desc.public.fn,
|
|
||||||
avatar: makeImageUrl(desc.public.photo)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
title: '',
|
|
||||||
avatar: null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desc.acs) {
|
|
||||||
this.setState({
|
|
||||||
isWriter: desc.acs.isWriter(),
|
|
||||||
isReader: desc.acs.isReader(),
|
|
||||||
readingBlocked: !desc.acs.isReader('given'),
|
|
||||||
unconfirmed: isUnconfirmed(desc.acs),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubsUpdated() {
|
|
||||||
if (this.state.topic) {
|
|
||||||
const subs = [];
|
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
|
||||||
topic.subscribers((sub) => {
|
|
||||||
if (sub.online && sub.user != this.props.myUserId) {
|
|
||||||
subs.push(sub);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const newState = {onlineSubs: subs};
|
|
||||||
const peer = topic.p2pPeerDesc();
|
|
||||||
if (peer) {
|
|
||||||
Object.assign(newState, {
|
|
||||||
peerMessagingDisabled: isPeerRestricted(peer.acs)
|
|
||||||
});
|
|
||||||
} else if (this.state.peerMessagingDisabled) {
|
|
||||||
Object.assign(newState, {
|
|
||||||
peerMessagingDisabled: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNewMessage(msg) {
|
|
||||||
// Regenerate messages list
|
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
|
||||||
const newState = {messages: []};
|
|
||||||
topic.messages((m) => {
|
|
||||||
if (!m.deleted) {
|
|
||||||
newState.messages.push(m);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// msg could be null if one or more messages were deleted.
|
|
||||||
if (msg && !msg.deleted) {
|
|
||||||
// If the message is added to the end of the message list,
|
|
||||||
// scroll to the bottom.
|
|
||||||
if (topic.isNewMessage(msg.seq)) {
|
|
||||||
newState.scrollPosition = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aknowledge messages except own messages. They are
|
|
||||||
// automatically assumed to be read and recived.
|
|
||||||
const status = topic.msgStatus(msg);
|
|
||||||
if (status >= Tinode.MESSAGE_STATUS_SENT && msg.from != this.props.myUserId) {
|
|
||||||
this.props.readTimerHandler(() => {
|
|
||||||
topic.noteRead(msg.seq);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.props.onData(msg);
|
|
||||||
}
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAllMessagesReceived(count) {
|
|
||||||
this.setState({fetchingMessages: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInfoReceipt(info) {
|
|
||||||
switch (info.what) {
|
|
||||||
case 'kp': {
|
|
||||||
clearTimeout(this.keyPressTimer);
|
|
||||||
var instance = this;
|
|
||||||
this.keyPressTimer = setTimeout(function() {
|
|
||||||
instance.setState({typingIndicator: false});
|
|
||||||
}, KEYPRESS_DELAY + 1000);
|
|
||||||
if (!this.state.typingIndicator) {
|
|
||||||
this.setState({typingIndicator: true});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'read':
|
|
||||||
case 'recv':
|
|
||||||
// Redraw due to changed recv/read status.
|
|
||||||
this.forceUpdate();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log("Other change in topic: ", info.what);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImagePreview(content) {
|
|
||||||
this.setState({ imagePreview: content });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseImagePreview() {
|
|
||||||
this.setState({ imagePreview: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFormResponse(action, text, data) {
|
|
||||||
if (action == 'pub') {
|
|
||||||
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
|
|
||||||
} else if (action == 'url') {
|
|
||||||
const url = new URL(data.ref);
|
|
||||||
const params = url.searchParams;
|
|
||||||
for (let key in data.resp) {
|
|
||||||
if (data.resp.hasOwnProperty(key)) {
|
|
||||||
params.set(key, data.resp[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
['name', 'seq'].map(function(key) {
|
|
||||||
if (data[key]) {
|
|
||||||
params.set(key, data[key]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
params.set('uid', this.props.myUserId);
|
|
||||||
url.search = params;
|
|
||||||
window.open(url, '_blank');
|
|
||||||
} else {
|
|
||||||
console.log("Unknown action in form", action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleShowContextMenuMessage(params) {
|
|
||||||
params.topicName = this.state.topic;
|
|
||||||
const menuItems = ['message_delete'];
|
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
|
||||||
if (topic) {
|
|
||||||
const acs = topic.getAccessMode();
|
|
||||||
if (acs && acs.isDeleter()) {
|
|
||||||
menuItems.push('message_delete_hard');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.props.showContextMenu(params, menuItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNewChatAcceptance(action) {
|
|
||||||
this.props.onNewChat(this.state.topic, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEnablePeer(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
|
|
||||||
let component;
|
|
||||||
if (this.state.topic) {
|
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
|
||||||
const groupTopic = topic.getType() == 'grp';
|
|
||||||
let messageNodes = [];
|
|
||||||
let previousFrom = null;
|
|
||||||
let chatBoxClass = null;
|
|
||||||
for (let i=0; i<this.state.messages.length; i++) {
|
|
||||||
let msg = this.state.messages[i];
|
|
||||||
let nextFrom = null;
|
|
||||||
|
|
||||||
if (i + 1 < this.state.messages.length) {
|
|
||||||
nextFrom = this.state.messages[i+1].from
|
|
||||||
}
|
|
||||||
|
|
||||||
let sequence = 'single';
|
|
||||||
if (msg.from == previousFrom) {
|
|
||||||
if (msg.from == nextFrom) {
|
|
||||||
sequence = 'middle';
|
|
||||||
} else {
|
|
||||||
sequence = 'last';
|
|
||||||
}
|
|
||||||
} else if (msg.from == nextFrom) {
|
|
||||||
sequence = 'first';
|
|
||||||
}
|
|
||||||
previousFrom = msg.from;
|
|
||||||
|
|
||||||
const isReply = !(msg.from == this.props.myUserId);
|
|
||||||
const deliveryStatus = topic.msgStatus(msg);
|
|
||||||
|
|
||||||
let userName, userAvatar, userFrom;
|
|
||||||
if (groupTopic) {
|
|
||||||
const user = topic.userDesc(msg.from);
|
|
||||||
if (user && user.public) {
|
|
||||||
userName = user.public.fn;
|
|
||||||
userAvatar = makeImageUrl(user.public.photo);
|
|
||||||
}
|
|
||||||
userFrom = msg.from;
|
|
||||||
chatBoxClass='chat-box group';
|
|
||||||
} else {
|
|
||||||
chatBoxClass='chat-box';
|
|
||||||
}
|
|
||||||
|
|
||||||
messageNodes.push(
|
|
||||||
<ChatMessage
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
content={msg.content}
|
|
||||||
mimeType={msg.head ? msg.head.mime : null}
|
|
||||||
timestamp={msg.ts} response={isReply} seq={msg.seq}
|
|
||||||
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
|
||||||
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
|
|
||||||
viewportWidth={this.props.viewportWidth}
|
|
||||||
showContextMenu={this.handleShowContextMenuMessage}
|
|
||||||
onImagePreview={this.handleImagePreview}
|
|
||||||
onFormResponse={this.handleFormResponse}
|
|
||||||
onError={this.props.onError}
|
|
||||||
key={msg.seq} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastSeen = null;
|
|
||||||
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
|
|
||||||
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
|
|
||||||
if (cont.online) {
|
|
||||||
lastSeen = formatMessage(messages.online_now);
|
|
||||||
} else if (cont.seen) {
|
|
||||||
lastSeen = formatMessage(messages.last_seen) + ": " +
|
|
||||||
shortDateFormat(cont.seen.when, this.props.intl.locale);
|
|
||||||
// TODO: also handle user agent in c.seen.ua
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const avatar = this.state.avatar || true;
|
|
||||||
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
|
||||||
|
|
||||||
component = (
|
|
||||||
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
||||||
<div id="topic-caption-panel" className="caption-panel">
|
|
||||||
{this.props.displayMobile ?
|
|
||||||
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
|
|
||||||
<i className="material-icons">arrow_back</i>
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
null}
|
|
||||||
<div className="avatar-box">
|
|
||||||
<LetterTile
|
|
||||||
avatar={avatar}
|
|
||||||
topic={this.state.topic}
|
|
||||||
title={this.state.title} />
|
|
||||||
<span className={online} />
|
|
||||||
</div>
|
|
||||||
<div id="topic-title-group">
|
|
||||||
<div id="topic-title" className="panel-title">{
|
|
||||||
this.state.title ||
|
|
||||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
|
||||||
description="Title shown when the topic has no name" /></i>
|
|
||||||
}</div>
|
|
||||||
<div id="topic-last-seen">{lastSeen}</div>
|
|
||||||
</div>
|
|
||||||
{groupTopic ?
|
|
||||||
<GroupSubs
|
|
||||||
subscribers={this.state.onlineSubs} /> :
|
|
||||||
<div id="topic-users" />
|
|
||||||
}
|
|
||||||
<div>
|
|
||||||
<a href="#" onClick={this.handleContextClick}>
|
|
||||||
<i className="material-icons">more_vert</i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{this.props.displayMobile ?
|
|
||||||
<ErrorPanel
|
|
||||||
level={this.props.errorLevel}
|
|
||||||
text={this.props.errorText}
|
|
||||||
onClearError={this.props.onError} />
|
|
||||||
: null}
|
|
||||||
<LoadSpinner show={this.state.fetchingMessages} />
|
|
||||||
<div id="messages-container">
|
|
||||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
|
||||||
<ul id="scroller" className={chatBoxClass}>
|
|
||||||
{messageNodes}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{!this.state.isReader ?
|
|
||||||
<div id="write-only-background">
|
|
||||||
{this.state.readingBlocked ?
|
|
||||||
<div id="write-only-note">
|
|
||||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
|
||||||
description="Message shown in topic without the read access" />
|
|
||||||
</div>
|
|
||||||
: null }
|
|
||||||
</div>
|
|
||||||
: null }
|
|
||||||
</div>
|
|
||||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
|
||||||
<div id="peer-messaging-disabled-note">
|
|
||||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
|
||||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
|
||||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
|
||||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
|
||||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
|
||||||
</div> : null}
|
|
||||||
{this.state.unconfirmed ?
|
|
||||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
|
||||||
:
|
|
||||||
<SendMessage
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
topic={this.props.topic}
|
|
||||||
disabled={!this.state.isWriter}
|
|
||||||
sendMessage={this.props.sendMessage}
|
|
||||||
onError={this.props.onError} />}
|
|
||||||
{this.state.imagePreview ?
|
|
||||||
<ImagePreview content={this.state.imagePreview}
|
|
||||||
onClose={this.handleCloseImagePreview} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
component = (
|
|
||||||
<LogoView hideSelf={this.props.hideSelf}
|
|
||||||
serverVersion={this.props.serverVersion}
|
|
||||||
serverAddress={this.props.serverAddress} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return component;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(MessagesView);
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// Create new topic and invite users or send an invite.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ContactList from '../widgets/contact-list.jsx';
|
|
||||||
import NewTopicById from '../widgets/new-topic-by-id.jsx';
|
|
||||||
import NewTopicGroup from '../widgets/new-topic-group.jsx';
|
|
||||||
import SearchContacts from '../widgets/search-contacts.jsx';
|
|
||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
|
||||||
import { vcard } from '../lib/utils.js';
|
|
||||||
|
|
||||||
export default class NewTopicView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
tabSelected: 'p2p'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleTabClick = this.handleTabClick.bind(this);
|
|
||||||
this.handleContactSelected = this.handleContactSelected.bind(this);
|
|
||||||
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
|
|
||||||
this.handleGroupByID = this.handleGroupByID.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.onInitFind();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTabClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
|
||||||
this.setState({tabSelected: e.currentTarget.dataset.id});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContactSelected(sel) {
|
|
||||||
if (this.state.tabSelected === 'p2p') {
|
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
|
||||||
this.props.onCreateTopic(sel, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNewGroupSubmit(name, dataUrl, priv, tags) {
|
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
|
||||||
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleGroupByID(topicName) {
|
|
||||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
|
||||||
this.props.onCreateTopic(topicName);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="flex-column">
|
|
||||||
<ul className="tabbar">
|
|
||||||
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
|
|
||||||
<a href="#" data-id="p2p" onClick={this.handleTabClick}>
|
|
||||||
<FormattedMessage id="tabtitle_find_user" defaultMessage="find"
|
|
||||||
description="Tab title Find" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className={this.state.tabSelected === "grp" ? "active" : null}>
|
|
||||||
<a href="#" data-id="grp" onClick={this.handleTabClick}>
|
|
||||||
<FormattedMessage id="tabtitle_new_group" defaultMessage="new group"
|
|
||||||
description="Tab title New Group" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className={this.state.tabSelected === "byid" ? "active" : null}>
|
|
||||||
<a href="#" data-id="byid" onClick={this.handleTabClick}>
|
|
||||||
<FormattedMessage id="tabtitle_group_by_id" defaultMessage="by id"
|
|
||||||
description="Tab title Find topic by ID" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{this.state.tabSelected === 'grp' ?
|
|
||||||
<NewTopicGroup onSubmit={this.handleNewGroupSubmit} /> :
|
|
||||||
this.state.tabSelected === 'byid' ?
|
|
||||||
<NewTopicById
|
|
||||||
onSubmit={this.handleGroupByID}
|
|
||||||
onError={this.props.onError} /> :
|
|
||||||
<div className="flex-column">
|
|
||||||
<SearchContacts type="p2p"
|
|
||||||
onSearchContacts={this.props.onSearchContacts} />
|
|
||||||
<FormattedMessage id="search_for_contacts" defaultMessage="Use search to find contacts"
|
|
||||||
description="Prompt in search field.">{
|
|
||||||
(search_for_contacts) => <ContactList
|
|
||||||
contacts={this.props.searchResults}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
emptyListMessage={search_for_contacts}
|
|
||||||
showOnline={false}
|
|
||||||
showUnread={false}
|
|
||||||
showContextMenu={false}
|
|
||||||
onTopicSelected={this.handleContactSelected} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
// A password reset form.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
|
||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
|
||||||
|
|
||||||
export default class PasswordResetView extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
email: '',
|
|
||||||
password: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
|
||||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
let parsed = HashNavigation.parseUrlHash(window.location.hash);
|
|
||||||
this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this.state.token) {
|
|
||||||
this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
|
|
||||||
} else {
|
|
||||||
this.props.onRequest('email', this.state.email.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEmailChange(e) {
|
|
||||||
this.setState({email: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePasswordChange(e) {
|
|
||||||
this.setState({password: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let reset = (this.state.token && this.state.scheme);
|
|
||||||
return (
|
|
||||||
<form id="password-reset-form" onSubmit={this.handleSubmit}>
|
|
||||||
{reset ?
|
|
||||||
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
|
|
||||||
description="Placeholder for entering new password">{
|
|
||||||
(placeholder) => <VisiblePassword
|
|
||||||
placeholder={placeholder}
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={this.state.password}
|
|
||||||
required={true} autoFocus={true}
|
|
||||||
onChange={this.handlePasswordChange} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
:
|
|
||||||
<>
|
|
||||||
<label htmlFor="inputEmail">
|
|
||||||
<FormattedMessage id="label_reset_password"
|
|
||||||
defaultMessage="Send a password reset email:"
|
|
||||||
description="Label for password reset field" />
|
|
||||||
</label>
|
|
||||||
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
|
|
||||||
description="Placeholder for entering email">{
|
|
||||||
(placeholder) => <input type="email" id="inputEmail"
|
|
||||||
placeholder={placeholder}
|
|
||||||
autoComplete="email"
|
|
||||||
value={this.state.email}
|
|
||||||
onChange={this.handleEmailChange}
|
|
||||||
required autoFocus />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className="blue" type="submit">{reset ?
|
|
||||||
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" /> :
|
|
||||||
<FormattedMessage id="button_send_request" defaultMessage="Send request"
|
|
||||||
description="Button [Send request]" />
|
|
||||||
}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// Tinode config panel.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import HostSelector from '../widgets/host-selector.jsx';
|
|
||||||
|
|
||||||
export default class SettingsView extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
transport: props.transport || 'def',
|
|
||||||
serverAddress: props.serverAddress,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.handleTransportSelected = this.handleTransportSelected.bind(this);
|
|
||||||
this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onUpdate({
|
|
||||||
transport: this.state.transport,
|
|
||||||
serverAddress: this.state.serverAddress
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTransportSelected(e) {
|
|
||||||
this.setState({transport: e.currentTarget.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleServerAddressChange(name) {
|
|
||||||
this.setState({serverAddress: name});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const names = {def: "default", ws: "websocket", lp: "long polling"};
|
|
||||||
var transportOptions = [];
|
|
||||||
var instance = this;
|
|
||||||
['def', 'ws', 'lp'].map(function(item) {
|
|
||||||
var id = 'transport-' + item;
|
|
||||||
var name = names[item];
|
|
||||||
transportOptions.push(
|
|
||||||
<li key={item}>
|
|
||||||
<input type="radio" id={id} name="transport-select" value={item}
|
|
||||||
checked={instance.state.transport === item}
|
|
||||||
onChange={instance.handleTransportSelected} />
|
|
||||||
<label htmlFor={id}>{name}</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form id="settings-form" className="panel-form" onSubmit={this.handleSubmit}>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_server_to_use" defaultMessage="Server to use:"
|
|
||||||
description="Label for server selector in SettingsView" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<HostSelector serverAddress={this.state.serverAddress}
|
|
||||||
onServerAddressChange={this.handleServerAddressChange} />
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="label_wire_transport" defaultMessage="Wire transport:"
|
|
||||||
description="Label for wire transport selection in SettingsView" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<ul className="quoted">
|
|
||||||
{transportOptions}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button type="submit" className="blue">
|
|
||||||
<FormattedMessage id="button_update" defaultMessage="Update"
|
|
||||||
description="Button [Update]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
|
||||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
|
||||||
import SideNavbar from '../widgets/side-navbar.jsx';
|
|
||||||
|
|
||||||
import ContactsView from './contacts-view.jsx';
|
|
||||||
import CreateAccountView from './create-account-view.jsx';
|
|
||||||
import EditAccountView from './edit-account-view.jsx';
|
|
||||||
import LoginView from './login-view.jsx';
|
|
||||||
import NewTopicView from './new-topic-view.jsx';
|
|
||||||
import PasswordResetView from './password-reset-view.jsx';
|
|
||||||
import SettingsView from './settings-view.jsx';
|
|
||||||
import ValidationView from './validation-view.jsx';
|
|
||||||
|
|
||||||
// Panel titles for translation.
|
|
||||||
const messages = defineMessages({
|
|
||||||
'login': {
|
|
||||||
id: 'sidepanel_title_login',
|
|
||||||
description: 'Sidepanel title for LoginView.',
|
|
||||||
defaultMessage: 'Sign In'
|
|
||||||
},
|
|
||||||
'register': {
|
|
||||||
id: 'sidepanel_title_register',
|
|
||||||
description: 'Sidepanel title for CreateAccountView.',
|
|
||||||
defaultMessage: 'Create Account'
|
|
||||||
},
|
|
||||||
'settings': {
|
|
||||||
id: 'sidepanel_title_settings',
|
|
||||||
description: 'Sidepanel title for SettingsView.',
|
|
||||||
defaultMessage: 'Settings'
|
|
||||||
},
|
|
||||||
'edit': {
|
|
||||||
id: 'sidepanel_title_edit_account',
|
|
||||||
description: 'Sidepanel title for EditAccountView.',
|
|
||||||
defaultMessage: 'Edit Account'
|
|
||||||
},
|
|
||||||
'newtpk': {
|
|
||||||
id: 'sidepanel_title_newtpk',
|
|
||||||
description: 'Sidepanel title for NewTopicView.',
|
|
||||||
defaultMessage: 'Start New Chat'
|
|
||||||
},
|
|
||||||
'cred': {
|
|
||||||
id: 'sidepanel_title_cred',
|
|
||||||
description: 'Sidepanel title for ValidationView.',
|
|
||||||
defaultMessage: 'Confirm Credentials'
|
|
||||||
},
|
|
||||||
'reset': {
|
|
||||||
id: 'sidepanel_title_reset',
|
|
||||||
description: 'Sidepanel title for PasswordResetView.',
|
|
||||||
defaultMessage: 'Reset Password'
|
|
||||||
},
|
|
||||||
'archive': {
|
|
||||||
id: 'sidepanel_title_archive',
|
|
||||||
description: 'Sidepanel title for ContactsView-Archive.',
|
|
||||||
defaultMessage: 'Archived Chats'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class SidepanelView extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleLoginRequested = this.handleLoginRequested.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoginRequested(login, password) {
|
|
||||||
this.props.onLoginRequest(login, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
|
|
||||||
|
|
||||||
let title, avatar;
|
|
||||||
if (view == 'contacts') {
|
|
||||||
title = this.props.title;
|
|
||||||
avatar = this.props.avatar ? this.props.avatar : true;
|
|
||||||
} else {
|
|
||||||
title = formatMessage(messages[view]);
|
|
||||||
avatar = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let onCancel;
|
|
||||||
if (['login', 'contacts'].indexOf(view) == -1) {
|
|
||||||
onCancel = this.props.onCancel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
||||||
<SideNavbar
|
|
||||||
state={view}
|
|
||||||
title={title}
|
|
||||||
avatar={avatar}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
onSignUp={this.props.onSignUp}
|
|
||||||
onSettings={this.props.onSettings}
|
|
||||||
onNewTopic={this.props.onNewTopic}
|
|
||||||
onCancel={onCancel} />
|
|
||||||
|
|
||||||
<ErrorPanel
|
|
||||||
level={this.props.errorLevel}
|
|
||||||
text={this.props.errorText}
|
|
||||||
action={this.props.errorAction}
|
|
||||||
actionText={this.props.errorActionText}
|
|
||||||
onClearError={this.props.onError} />
|
|
||||||
|
|
||||||
<LoadSpinner show={this.props.loadSpinnerVisible} />
|
|
||||||
|
|
||||||
{view === 'login' ?
|
|
||||||
<LoginView
|
|
||||||
login={this.props.login}
|
|
||||||
disabled={this.props.loginDisabled}
|
|
||||||
onLogin={this.handleLoginRequested} /> :
|
|
||||||
|
|
||||||
view === 'register' ?
|
|
||||||
<CreateAccountView
|
|
||||||
onCreateAccount={this.props.onCreateAccount}
|
|
||||||
onCancel={this.props.onCancel}
|
|
||||||
onError={this.props.onError} /> :
|
|
||||||
|
|
||||||
view === 'settings' ?
|
|
||||||
<SettingsView
|
|
||||||
transport={this.props.transport}
|
|
||||||
serverAddress={this.props.serverAddress}
|
|
||||||
onCancel={this.props.onCancel}
|
|
||||||
onUpdate={this.props.onGlobalSettings} /> :
|
|
||||||
|
|
||||||
view === 'edit' ?
|
|
||||||
<EditAccountView
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
messageSounds={this.props.messageSounds}
|
|
||||||
desktopAlerts={this.props.desktopAlerts}
|
|
||||||
desktopAlertsEnabled={this.props.desktopAlertsEnabled}
|
|
||||||
onUpdateAccount={this.props.onUpdateAccount}
|
|
||||||
onUpdateTags={this.props.onUpdateAccountTags}
|
|
||||||
onTogglePushNotifications={this.props.onTogglePushNotifications}
|
|
||||||
onToggleMessageSounds={this.props.onToggleMessageSounds}
|
|
||||||
onCredAdd={this.props.onCredAdd}
|
|
||||||
onCredDelete={this.props.onCredDelete}
|
|
||||||
onCredConfirm={this.props.onCredConfirm}
|
|
||||||
onLogout={this.props.onLogout}
|
|
||||||
onCancel={this.props.onCancel}
|
|
||||||
onError={this.props.onError} /> :
|
|
||||||
|
|
||||||
(view === 'contacts' || view == 'archive') ?
|
|
||||||
<ContactsView
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
connected={this.props.connected}
|
|
||||||
topicSelected={this.props.topicSelected}
|
|
||||||
archive={view == 'archive'}
|
|
||||||
chatList={this.props.chatList}
|
|
||||||
showContextMenu={this.props.showContextMenu}
|
|
||||||
onTopicSelected={this.props.onTopicSelected}
|
|
||||||
onShowArchive={this.props.onShowArchive} /> :
|
|
||||||
|
|
||||||
view === 'newtpk' ?
|
|
||||||
<NewTopicView
|
|
||||||
searchResults={this.props.searchResults}
|
|
||||||
onInitFind={this.props.onInitFind}
|
|
||||||
onSearchContacts={this.props.onSearchContacts}
|
|
||||||
onCreateTopic={this.props.onCreateTopic}
|
|
||||||
onError={this.props.onError} /> :
|
|
||||||
|
|
||||||
view === 'cred' ?
|
|
||||||
<ValidationView
|
|
||||||
credCode={this.props.credCode}
|
|
||||||
credMethod={this.props.credMethod}
|
|
||||||
onSubmit={this.props.onValidateCredentials}
|
|
||||||
onCancel={this.props.onCancel}
|
|
||||||
onError={this.props.onError} /> :
|
|
||||||
|
|
||||||
view === 'reset' ?
|
|
||||||
<PasswordResetView
|
|
||||||
onRequest={this.props.onPasswordResetRequest}
|
|
||||||
onReset={this.props.onResetPassword} /> :
|
|
||||||
null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(SidepanelView);
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// ValidationView: panel for confirming credentials, like email or phone.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
phone: {
|
|
||||||
id: 'phone_dative',
|
|
||||||
defaultMessage: 'phone',
|
|
||||||
description: "Dative case of 'phone', i.e. 'phone' in 'by phone'",
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
id: 'email_dative',
|
|
||||||
defaultMessage: 'email',
|
|
||||||
description: "Dative case of 'email', i.e. 'email' in 'by email'",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class ValidationView extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
code: props.credCode || ''
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.handleCancel = this.handleCancel.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChange(e) {
|
|
||||||
this.setState({code: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyPress(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
this.handleSubmit(e);
|
|
||||||
} else if (e.key == 'Escape') {
|
|
||||||
this.handleCancel(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this.state.code && this.state.code.trim()) {
|
|
||||||
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onCancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { formatMessage } = this.props.intl;
|
|
||||||
const methods = {'email': formatMessage(messages.email), 'tel': formatMessage(messages.phone)};
|
|
||||||
let method = methods[this.props.credMethod] || this.props.credMethod;
|
|
||||||
return (
|
|
||||||
<div className="panel-form">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small" htmlFor="enter-confirmation-code">
|
|
||||||
<FormattedMessage id="enter_confirmation_code_prompt"
|
|
||||||
defaultMessage="Enter confirmation code sent to you by {method}:"
|
|
||||||
description="Request to enter confirmation code"
|
|
||||||
values={{method: method}} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<FormattedMessage id="numeric_confirmation_code_prompt"
|
|
||||||
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
|
|
||||||
(numbers_only) => <input type="text" id="enter-confirmation-code"
|
|
||||||
placeholder={numbers_only}
|
|
||||||
value={this.state.code} onChange={this.handleChange}
|
|
||||||
onKeyPress={this.handleKeyPress} required />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className="blue" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
|
|
||||||
</button>
|
|
||||||
<button className="white" onClick={this.handleCancel}>
|
|
||||||
<FormattedMessage id="button_cancel" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ValidationView);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
export default class Alert extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return this.props.visible ?
|
|
||||||
<div className="alert-container">
|
|
||||||
<div className="alert">
|
|
||||||
<div className="title">{this.props.title}</div>
|
|
||||||
<div className="content">{this.props.content}</div>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
{this.props.onReject ?
|
|
||||||
<button className="outline" onClick={this.props.onReject}>
|
|
||||||
{this.props.reject || <FormattedMessage id="button_cancel" />}
|
|
||||||
</button>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
<button className="blue" onClick={this.props.onConfirm}>
|
|
||||||
{this.props.confirm || <FormattedMessage id="button_ok" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import FileProgress from './file-progress.jsx';
|
|
||||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
|
||||||
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
|
|
||||||
|
|
||||||
export default class Attachment extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
downloader: null,
|
|
||||||
progress: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
this.downloadFile = this.downloadFile.bind(this);
|
|
||||||
this.handleCancel = this.handleCancel.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadFile(url, filename, mimetype) {
|
|
||||||
var downloader = this.props.tinode.getLargeFileHelper();
|
|
||||||
this.setState({downloader: downloader});
|
|
||||||
downloader.download(url, filename, mimetype, (loaded) => {
|
|
||||||
this.setState({progress: loaded / this.props.size});
|
|
||||||
}).then(() => {
|
|
||||||
this.setState({downloader: null, progress: 0});
|
|
||||||
}).catch((err) => {
|
|
||||||
if (err) {
|
|
||||||
this.props.onError("Error downloading file: " + err.message, 'err');
|
|
||||||
}
|
|
||||||
this.setState({downloader: null, progress: 0});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel() {
|
|
||||||
if (this.props.uploader) {
|
|
||||||
this.props.onCancelUpload();
|
|
||||||
} else if (this.state.downloader) {
|
|
||||||
this.state.downloader.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let filename = this.props.filename || 'file_attachment';
|
|
||||||
if (filename.length > 36) {
|
|
||||||
filename = filename.substr(0, 16) + '...' + filename.substr(-16);
|
|
||||||
}
|
|
||||||
let size = this.props.size > 0 ?
|
|
||||||
<span className="small gray">({bytesToHumanSize(this.props.size)})</span> :
|
|
||||||
null;
|
|
||||||
|
|
||||||
// Detect if the download URL is relative or absolute.
|
|
||||||
// If the URL is relative use LargeFileHelper to attach authentication
|
|
||||||
// credentials to the request.
|
|
||||||
let url, helperFunc;
|
|
||||||
if (!this.props.uploader && !this.state.downloader && isUrlRelative(this.props.downloadUrl)) {
|
|
||||||
// Relative URL. Use download helper.
|
|
||||||
url = '#';
|
|
||||||
helperFunc = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
url = sanitizeUrl(this.props.downloadUrl) || 'about:blank';
|
|
||||||
helperFunc = null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="attachment">
|
|
||||||
<div><i className="material-icons big gray">insert_drive_file</i></div>
|
|
||||||
<div className="flex-column">
|
|
||||||
<div>{filename} {size}</div>
|
|
||||||
{this.props.uploader || this.state.downloader ?
|
|
||||||
<FileProgress progress={this.props.uploader ? this.props.progress : this.state.progress}
|
|
||||||
onCancel={this.handleCancel} />
|
|
||||||
:
|
|
||||||
<div><a href={url} download={this.props.filename} onClick={helperFunc} >
|
|
||||||
<i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
|
|
||||||
defaultMessage="save" description="Call to save an attachment" />
|
|
||||||
</a></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
|
||||||
|
|
||||||
import { AVATAR_SIZE } from '../config.js';
|
|
||||||
import { imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
export default class AvatarUpload extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
dataUrl: props.avatar
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleFileUpload = this.handleFileUpload.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.avatar != prevProps.avatar) {
|
|
||||||
this.setState({dataUrl: this.props.avatar})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFileUpload(e) {
|
|
||||||
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
|
|
||||||
// Success
|
|
||||||
(base64bits, mime) => {
|
|
||||||
var du = makeImageUrl({data: base64bits, type: mime});
|
|
||||||
this.setState({dataUrl: du});
|
|
||||||
this.props.onImageChanged(du);
|
|
||||||
},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
|
||||||
});
|
|
||||||
// Clear the value so the same file can be uploaded again.
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
// Randomize id value in case more than one AvatarUpload is shown
|
|
||||||
// at the same time.
|
|
||||||
var randId = "file-input-avatar-" + (Math.random() + '').substr(2);
|
|
||||||
return (
|
|
||||||
<div className="avatar-upload">
|
|
||||||
{this.props.readOnly || !this.state.dataUrl ?
|
|
||||||
null :
|
|
||||||
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageChanged(null);}}>
|
|
||||||
<i className="material-icons">clear</i>
|
|
||||||
</a>}
|
|
||||||
{this.state.dataUrl ?
|
|
||||||
<img src={this.state.dataUrl} className="preview" /> :
|
|
||||||
this.props.readOnly && this.props.uid ?
|
|
||||||
<div className="avatar-box">
|
|
||||||
<LetterTile
|
|
||||||
avatar={true}
|
|
||||||
topic={this.props.uid}
|
|
||||||
title={this.props.title} />
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
|
||||||
{this.props.readOnly ? null :
|
|
||||||
<input type="file" id={randId} className="inputfile hidden"
|
|
||||||
accept="image/*" onChange={this.handleFileUpload} />}
|
|
||||||
{this.props.readOnly ? null :
|
|
||||||
<label htmlFor={randId} className="round">
|
|
||||||
<i className="material-icons">file_upload</i>
|
|
||||||
</label>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
// Single message, sent or received.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { Drafty } from 'tinode-sdk'
|
|
||||||
|
|
||||||
import Attachment from './attachment.jsx';
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
|
||||||
import ReceivedMarker from './received-marker.jsx'
|
|
||||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
|
||||||
|
|
||||||
export default class ChatMessage extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
progress: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (props.uploader) {
|
|
||||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handlePreviewImage = this.handlePreviewImage.bind(this);
|
|
||||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
|
||||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePreviewImage(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onImagePreview({
|
|
||||||
url: e.target.src,
|
|
||||||
filename: e.target.title,
|
|
||||||
width: e.target.dataset.width,
|
|
||||||
height: e.target.dataset.height,
|
|
||||||
size: e.target.dataset.size,
|
|
||||||
type: e.target.dataset.mime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFormButtonClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const data = {
|
|
||||||
seq: this.props.seq
|
|
||||||
};
|
|
||||||
data.resp = {};
|
|
||||||
if (e.target.dataset.name) {
|
|
||||||
data.resp[e.target.dataset.name] = e.target.dataset.val ? e.target.dataset.val :
|
|
||||||
e.target.dataset.val === undefined ? 1 : '' + e.target.dataset.val;
|
|
||||||
}
|
|
||||||
if (e.target.dataset.act == 'url') {
|
|
||||||
data.ref = sanitizeUrl(e.target.dataset.ref) || 'about:blank';
|
|
||||||
}
|
|
||||||
const text = e.target.dataset.title || 'unknown';
|
|
||||||
this.props.onFormResponse(e.target.dataset.act, text, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleProgress(ratio) {
|
|
||||||
this.setState({progress: ratio});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancelUpload() {
|
|
||||||
this.props.uploader.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
|
|
||||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ?
|
|
||||||
'bubble tip' : 'bubble';
|
|
||||||
const avatar = this.props.userAvatar || true;
|
|
||||||
const fullDisplay = (this.props.userFrom && this.props.response &&
|
|
||||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
|
||||||
|
|
||||||
let content = this.props.content;
|
|
||||||
const attachments = [];
|
|
||||||
if (this.props.mimeType == Drafty.getContentType()) {
|
|
||||||
Drafty.attachments(content, function(att, i) {
|
|
||||||
if (att.mime == 'application/json') {
|
|
||||||
// Don't show json objects as attachments.
|
|
||||||
// They are not meant for users.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attachments.push(<Attachment
|
|
||||||
tinode={this.props.tinode}
|
|
||||||
downloadUrl={Drafty.getDownloadUrl(att)}
|
|
||||||
filename={att.name} uploader={Drafty.isUploading(att)}
|
|
||||||
mimetype={att.mime} size={Drafty.getEntitySize(att)}
|
|
||||||
progress={this.state.progress}
|
|
||||||
onCancelUpload={this.handleCancelUpload}
|
|
||||||
onError={this.props.onError}
|
|
||||||
key={i} />);
|
|
||||||
}, this);
|
|
||||||
content = React.createElement('span', null, Drafty.format(content, draftyFormatter, this));
|
|
||||||
} else if (typeof content != 'string') {
|
|
||||||
content = <><i className="material-icons">error_outline</i> <i>
|
|
||||||
<FormattedMessage id="invalid_content"
|
|
||||||
defaultMessage="invalid content" description="Shown when message is unreadable" />
|
|
||||||
</i></>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={sideClass}>
|
|
||||||
{this.props.userFrom && this.props.response ?
|
|
||||||
<div className="avatar-box">
|
|
||||||
{fullDisplay ?
|
|
||||||
<LetterTile
|
|
||||||
topic={this.props.userFrom}
|
|
||||||
title={this.props.userName}
|
|
||||||
avatar={avatar} /> :
|
|
||||||
null}
|
|
||||||
</div> :
|
|
||||||
null}
|
|
||||||
<div>
|
|
||||||
<div className={bubbleClass}>
|
|
||||||
<div className="message-content">
|
|
||||||
{content}
|
|
||||||
{attachments}
|
|
||||||
<ReceivedMarker
|
|
||||||
timestamp={this.props.timestamp}
|
|
||||||
received={this.props.received} />
|
|
||||||
</div>
|
|
||||||
<span className="menuTrigger">
|
|
||||||
<a href="#" onClick={this.handleContextClick}>
|
|
||||||
<i className="material-icons">expand_more</i>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{fullDisplay ?
|
|
||||||
<div className="author">
|
|
||||||
<FormattedMessage id="user_not_found" defaultMessage="Not found"
|
|
||||||
description="In place of a user's full name when the user is not found.">{
|
|
||||||
(notFound) => {return this.props.userName || <i>{notFound}</i>}
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert Drafty object to a tree of React elements.
|
|
||||||
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
|
||||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
function draftyFormatter(style, data, values, key) {
|
|
||||||
let el = Drafty.tagName(style);
|
|
||||||
if (el) {
|
|
||||||
let attr = Drafty.attrValue(style, data) || {};
|
|
||||||
attr.key = key;
|
|
||||||
switch (style) {
|
|
||||||
case 'IM':
|
|
||||||
// Additional processing for images
|
|
||||||
if (data) {
|
|
||||||
attr.className = 'inline-image';
|
|
||||||
let dim = fitImageSize(data.width, data.height,
|
|
||||||
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false);
|
|
||||||
dim = dim || {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
|
||||||
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
|
|
||||||
attr.src = sanitizeImageUrl(attr.src);
|
|
||||||
if (attr.src) {
|
|
||||||
attr.onClick = this.handlePreviewImage;
|
|
||||||
attr.className += ' image-clickable';
|
|
||||||
} else {
|
|
||||||
attr.src = 'img/broken_image.png';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'BN':
|
|
||||||
// Button
|
|
||||||
attr.onClick = this.handleFormButtonClick;
|
|
||||||
let inner = React.Children.map(values, (child) => {
|
|
||||||
return typeof child == 'string' ? child : undefined;
|
|
||||||
});
|
|
||||||
if (!inner || inner.length == 0) {
|
|
||||||
inner = [attr.name]
|
|
||||||
}
|
|
||||||
// Get text which will be sent back when the button is clicked.
|
|
||||||
attr['data-title'] = inner.join('');
|
|
||||||
break;
|
|
||||||
case 'FM':
|
|
||||||
// Form
|
|
||||||
attr.className = 'bot-form';
|
|
||||||
break;
|
|
||||||
case 'FE':
|
|
||||||
// Form element formatting is dependent on element content.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return React.createElement(el, attr, values);
|
|
||||||
} else {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
/* BEGIN CheckBox: styled checkbox */
|
|
||||||
export default class CheckBox extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChange() {
|
|
||||||
this.props.onChange(this.props.name, !this.props.checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
this.props.onChange ? (
|
|
||||||
this.props.checked ?
|
|
||||||
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
|
|
||||||
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i>
|
|
||||||
) : (
|
|
||||||
this.props.checked ?
|
|
||||||
<i className="material-icons">check_box</i> :
|
|
||||||
<i className="material-icons">check_box_outline_blank</i>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* END CheckBox */
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import Chip from './chip.jsx';
|
|
||||||
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
/* BEGIN ChipInput: group membership widget */
|
|
||||||
export default class ChipInput extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = ChipInput.deriveStateFromProps(props);
|
|
||||||
this.state.input = '';
|
|
||||||
this.state.focused = false;
|
|
||||||
|
|
||||||
this.handleTextInput = this.handleTextInput.bind(this);
|
|
||||||
this.removeChipAt = this.removeChipAt.bind(this);
|
|
||||||
this.handleChipCancel = this.handleChipCancel.bind(this);
|
|
||||||
this.handleFocusGained = this.handleFocusGained.bind(this);
|
|
||||||
this.handleFocusLost = this.handleFocusLost.bind(this);
|
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
static deriveStateFromProps(props) {
|
|
||||||
return {
|
|
||||||
placeholder: props.chips ? '' : props.prompt,
|
|
||||||
sortedChips: ChipInput.sortChips(props.chips, props.staticMembers),
|
|
||||||
chipIndex: ChipInput.indexChips(props.chips)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
if (prevProps.chips != this.props.chips ||
|
|
||||||
prevProps.staticMembers != this.props.staticMembers ||
|
|
||||||
prevProps.prompt != this.props.prompt) {
|
|
||||||
this.setState(ChipInput.deriveStateFromProps(this.props));
|
|
||||||
}
|
|
||||||
if (!prevState || this.props.chips.length > prevState.sortedChips.length) {
|
|
||||||
this.setState({input: ''});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map chip index to user name
|
|
||||||
static indexChips(chips) {
|
|
||||||
const index = {};
|
|
||||||
let count = 0;
|
|
||||||
chips.map((item) => {
|
|
||||||
index[item.user] = count;
|
|
||||||
count ++;
|
|
||||||
});
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Have non-removable chips appear before all other chips.
|
|
||||||
static sortChips(chips, keep) {
|
|
||||||
const required = [];
|
|
||||||
const normal = [];
|
|
||||||
chips.map((item) => {
|
|
||||||
if (keep && keep.includes(item.user)) {
|
|
||||||
required.push(item);
|
|
||||||
} else {
|
|
||||||
normal.push(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return required.concat(normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTextInput(e) {
|
|
||||||
this.setState({input: e.target.value});
|
|
||||||
if (this.props.filterFunc) {
|
|
||||||
this.props.filterFunc(e.target.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeChipAt(idx) {
|
|
||||||
const removed = this.state.sortedChips[idx];
|
|
||||||
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChipCancel(item, idx) {
|
|
||||||
this.removeChipAt(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFocusGained() {
|
|
||||||
this.setState({focused: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFocusLost() {
|
|
||||||
this.setState({focused: false});
|
|
||||||
if (this.props.onFocusLost) {
|
|
||||||
this.props.onFocusLost(this.state.input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown(e) {
|
|
||||||
if (e.key === 'Backspace') {
|
|
||||||
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
|
|
||||||
const at = this.state.sortedChips.length - 1;
|
|
||||||
if (this.state.sortedChips[at].user !== this.props.staticMembers) {
|
|
||||||
this.removeChipAt(at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
if (this.props.onEnter) {
|
|
||||||
this.props.onEnter(this.state.input);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
if (this.props.onCancel) {
|
|
||||||
this.props.onCancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const chips = [];
|
|
||||||
let count = 0;
|
|
||||||
const staticMembers = this.props.staticMembers || [];
|
|
||||||
this.state.sortedChips.map((item) => {
|
|
||||||
chips.push(
|
|
||||||
<Chip
|
|
||||||
onCancel={this.handleChipCancel}
|
|
||||||
avatar={makeImageUrl(item.public ? item.public.photo : null)}
|
|
||||||
title={item.public ? item.public.fn : undefined}
|
|
||||||
noAvatar={this.props.avatarDisabled}
|
|
||||||
topic={item.user}
|
|
||||||
required={staticMembers.includes(item.user)}
|
|
||||||
invalid={item.invalid}
|
|
||||||
index={count}
|
|
||||||
key={item.user} />
|
|
||||||
);
|
|
||||||
count++;
|
|
||||||
});
|
|
||||||
const className = "chip-input" + (this.state.focused ? ' focused' : '');
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{chips}
|
|
||||||
<input type="text"
|
|
||||||
placeholder={this.state.placeholder}
|
|
||||||
onChange={this.handleTextInput}
|
|
||||||
onFocus={this.handleFocusGained}
|
|
||||||
onBlur={this.handleFocusLost}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
value={this.state.input}
|
|
||||||
autoFocus />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
|
||||||
|
|
||||||
export default class Chip extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleCancel = this.handleCancel.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.props.onCancel(this.props.topic, this.props.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const title = this.props.title || this.props.topic;
|
|
||||||
const className = this.props.invalid ? 'chip invalid' : 'chip';
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{this.props.noAvatar ?
|
|
||||||
<span className="spacer" /> :
|
|
||||||
<div className="avatar-box">
|
|
||||||
<LetterTile
|
|
||||||
avatar={this.props.avatar || true}
|
|
||||||
topic={this.props.topic}
|
|
||||||
title={this.props.title} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<span>{title}</span>
|
|
||||||
{this.props.onCancel && !this.props.required ?
|
|
||||||
<a href="#" onClick={this.handleCancel} >×</a>
|
|
||||||
: <span className="spacer" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// A panel included into a list of contacts with an action text.
|
|
||||||
import React from 'react';
|
|
||||||
import { injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
class ContactAction extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.onAction(this.props.action);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
return (
|
|
||||||
<li onClick={this.handleClick} className="action">
|
|
||||||
<div className="action-text">{formatMessage(this.props.title, this.props.values)}</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ContactAction);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block'};
|
|
||||||
|
|
||||||
/* Contact's labels: [you], or icons [muted], [blocked] */
|
|
||||||
export default class ContactBadges extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
let badges = null;
|
|
||||||
if (this.props.badges && this.props.badges.length > 0) {
|
|
||||||
badges = [];
|
|
||||||
this.props.badges.map(function(b) {
|
|
||||||
if (b.icon) {
|
|
||||||
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>);
|
|
||||||
} else {
|
|
||||||
const style = 'badge' + (b.color ? ' ' + b.color : '');
|
|
||||||
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return <>{badges}</>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
// ContactList: component for showing a list of contacts,
|
|
||||||
// such as a list of group members in a group chat.
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import Contact from './contact.jsx';
|
|
||||||
import ContactAction from './contact-action.jsx';
|
|
||||||
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
badge_you: {
|
|
||||||
id: 'badge_you',
|
|
||||||
defaultMessage: 'you',
|
|
||||||
description: 'Badge for indicating the current user'
|
|
||||||
},
|
|
||||||
badge_owner: {
|
|
||||||
id: 'badge_owner',
|
|
||||||
defaultMessage: 'owner',
|
|
||||||
description: 'Badge for indicating the owner'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class ContactList extends React.Component {
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
const showCheckmark = Array.isArray(this.props.topicSelected);
|
|
||||||
const contactNodes = [];
|
|
||||||
let contactsCount = 0;
|
|
||||||
if (this.props.contacts && this.props.contacts.length > 0) {
|
|
||||||
this.props.contacts.map((c) => {
|
|
||||||
if (c.action) {
|
|
||||||
// Action item
|
|
||||||
contactNodes.push(
|
|
||||||
<ContactAction
|
|
||||||
title={c.title} action={c.action} values={c.values}
|
|
||||||
key={c.action}
|
|
||||||
onAction={this.props.onAction}
|
|
||||||
/>);
|
|
||||||
} else {
|
|
||||||
// Normal contact
|
|
||||||
const key = c.topic ? c.topic : c.user;
|
|
||||||
// If filter function is provided, filter out the items
|
|
||||||
// which don't satisfy the condition.
|
|
||||||
if (this.props.filterFunc && this.props.filter) {
|
|
||||||
let content = [key];
|
|
||||||
if (c.private && c.private.comment) {
|
|
||||||
content.push(('' + c.private.comment).toLowerCase());
|
|
||||||
}
|
|
||||||
if (c.public && c.public.fn) {
|
|
||||||
content.push(('' + c.public.fn).toLowerCase());
|
|
||||||
}
|
|
||||||
if (!this.props.filterFunc(this.props.filter, content)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = showCheckmark ?
|
|
||||||
(this.props.topicSelected.indexOf(key) > -1) :
|
|
||||||
(this.props.topicSelected === key);
|
|
||||||
const badges = [];
|
|
||||||
if (this.props.showMode) {
|
|
||||||
if (key === this.props.myUserId) {
|
|
||||||
badges.push({name: formatMessage(messages.badge_you), color: 'green'});
|
|
||||||
}
|
|
||||||
if (c.acs && c.acs.isOwner()) {
|
|
||||||
badges.push({name: formatMessage(messages.badge_owner), color: 'blue'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const comment = Array.isArray(c.private) ?
|
|
||||||
c.private.join(',') : (c.private ? c.private.comment : null);
|
|
||||||
|
|
||||||
contactNodes.push(
|
|
||||||
<Contact
|
|
||||||
title={c.public ? c.public.fn : null}
|
|
||||||
avatar={makeImageUrl(c.public ? c.public.photo : null)}
|
|
||||||
comment={comment}
|
|
||||||
unread={this.props.showUnread ? c.unread : 0}
|
|
||||||
now={c.online && this.props.connected}
|
|
||||||
acs={c.acs}
|
|
||||||
showMode={this.props.showMode}
|
|
||||||
badges={badges}
|
|
||||||
showCheckmark={showCheckmark}
|
|
||||||
selected={selected}
|
|
||||||
showOnline={this.props.showOnline}
|
|
||||||
onSelected={this.props.onTopicSelected}
|
|
||||||
showContextMenu={this.props.showContextMenu}
|
|
||||||
item={key}
|
|
||||||
index={contactNodes.length}
|
|
||||||
key={key} />
|
|
||||||
);
|
|
||||||
contactsCount ++;
|
|
||||||
}
|
|
||||||
}, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={this.props.noScroll ? null : "scrollable-panel"}>
|
|
||||||
{contactsCount == 0 ?
|
|
||||||
<div className="center-medium-text"
|
|
||||||
dangerouslySetInnerHTML={{__html: this.props.emptyListMessage}} />
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
{contactNodes.length > 0 ?
|
|
||||||
<ul className="contact-box">
|
|
||||||
{contactNodes}
|
|
||||||
</ul>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ContactList);
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// A single topic or user.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
|
||||||
import ContactBadges from './contact-badges.jsx';
|
|
||||||
import UnreadBadge from './unread-badge.jsx';
|
|
||||||
|
|
||||||
export default class Contact extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
this.handleContextClick = this.handleContextClick.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (this.props.onSelected) {
|
|
||||||
this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContextClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let title = this.props.title;
|
|
||||||
if (!title) {
|
|
||||||
title = <i><FormattedMessage id="unnamed_topic" /></i>;
|
|
||||||
} else if (title.length > 30) {
|
|
||||||
// FIXME: this is probably wrong for RTL languages.
|
|
||||||
title = title.substring(0, 28) + '...';
|
|
||||||
}
|
|
||||||
const online = this.props.now ? 'online' : 'offline';
|
|
||||||
const avatar = this.props.avatar ? this.props.avatar : true;
|
|
||||||
const badges = this.props.badges ? this.props.badges.slice() : [];
|
|
||||||
const icon_badges = [];
|
|
||||||
if (this.props.acs) {
|
|
||||||
if (this.props.showMode) {
|
|
||||||
badges.push({name: this.props.acs.getMode(), key: 'mode'});
|
|
||||||
}
|
|
||||||
if (this.props.acs.isMuted()) {
|
|
||||||
icon_badges.push({icon: 'muted'});
|
|
||||||
}
|
|
||||||
if (!this.props.acs.isJoiner()) {
|
|
||||||
icon_badges.push({icon: 'banned'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={!this.props.showCheckmark && this.props.selected ? "selected" : null}
|
|
||||||
onClick={this.handleClick}>
|
|
||||||
<div className="avatar-box">
|
|
||||||
<LetterTile
|
|
||||||
avatar={avatar}
|
|
||||||
title={this.props.title}
|
|
||||||
topic={this.props.item} />
|
|
||||||
{this.props.showOnline ? <span className={online} /> :
|
|
||||||
(this.props.showCheckmark && this.props.selected ?
|
|
||||||
<i className="checkmark material-icons">check_circle</i>
|
|
||||||
: null)}
|
|
||||||
</div>
|
|
||||||
<div className="text-box">
|
|
||||||
<div><span className="contact-title">{title}</span>
|
|
||||||
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
|
|
||||||
</div>
|
|
||||||
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
|
|
||||||
<span><ContactBadges badges={badges} /></span>
|
|
||||||
</div>
|
|
||||||
{this.props.showContextMenu ?
|
|
||||||
<span className="menuTrigger">
|
|
||||||
<a href="#" onClick={this.handleContextClick}>
|
|
||||||
<i className="material-icons">expand_more</i>
|
|
||||||
</a>
|
|
||||||
</span> : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
// Context Menu: popup/dropdown menu.
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import { REM_SIZE } from '../config.js';
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
info: {
|
|
||||||
id: 'menu_item_info',
|
|
||||||
defaultMessage: 'Info',
|
|
||||||
description: 'Show extended topic information'
|
|
||||||
},
|
|
||||||
clear_messages: {
|
|
||||||
id: 'menu_item_clear_messages',
|
|
||||||
defaultMessage: 'Clear messages',
|
|
||||||
description: 'Delete all messages'
|
|
||||||
},
|
|
||||||
clear_for_all: {
|
|
||||||
id: 'menu_item_clear_messages_for_all',
|
|
||||||
defaultMessage: 'Clear for All',
|
|
||||||
description: 'Delete all message(s) for all members'
|
|
||||||
},
|
|
||||||
'delete': {
|
|
||||||
id: 'menu_item_delete',
|
|
||||||
defaultMessage: 'Delete',
|
|
||||||
description: 'Delete selected messages'
|
|
||||||
},
|
|
||||||
delete_for_all: {
|
|
||||||
id: 'menu_item_delete_for_all',
|
|
||||||
defaultMessage: 'Delete for All',
|
|
||||||
description: 'Delete selected message(s) for all members'
|
|
||||||
},
|
|
||||||
mute: {
|
|
||||||
id: 'menu_item_mute',
|
|
||||||
defaultMessage: 'Mute',
|
|
||||||
description: 'Turn off notifications'
|
|
||||||
},
|
|
||||||
unmute: {
|
|
||||||
id: 'menu_item_unmute',
|
|
||||||
defaultMessage: 'Unmute',
|
|
||||||
description: 'Turn notifications on'
|
|
||||||
},
|
|
||||||
topic_delete: {
|
|
||||||
id: 'menu_item_delete_topic',
|
|
||||||
defaultMessage: 'Delete',
|
|
||||||
description: 'Delete entire topic'
|
|
||||||
},
|
|
||||||
topic_delete_warning: {
|
|
||||||
id: 'topic_delete_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to delete this conversation?',
|
|
||||||
description: 'Alert warning when deleting entire topic'
|
|
||||||
},
|
|
||||||
unblock: {
|
|
||||||
id: 'menu_item_unblock',
|
|
||||||
defaultMessage: 'Unblock',
|
|
||||||
description: 'Unblock topic or user'
|
|
||||||
},
|
|
||||||
// Generic string suitable for either topic or user.
|
|
||||||
block: {
|
|
||||||
id: 'menu_item_block',
|
|
||||||
defaultMessage: 'Block',
|
|
||||||
description: 'Block topic or user'
|
|
||||||
},
|
|
||||||
topic_block_warning: {
|
|
||||||
id: 'topic_block_warning',
|
|
||||||
defaultMessage: 'Are you sure you want to block this conversation?',
|
|
||||||
description: 'Alert warning when blocking a topic.'
|
|
||||||
},
|
|
||||||
member_delete: {
|
|
||||||
id: 'menu_item_member_delete',
|
|
||||||
defaultMessage: 'Remove',
|
|
||||||
description: 'Remove user from topic'
|
|
||||||
},
|
|
||||||
archive: {
|
|
||||||
id: 'menu_item_archive_topic',
|
|
||||||
defaultMessage: 'Archive',
|
|
||||||
description: 'Move topic from the list of active chats to archive'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class ContextMenu extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const {formatMessage} = props.intl;
|
|
||||||
|
|
||||||
this.handlePageClick = this.handlePageClick.bind(this);
|
|
||||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
|
|
||||||
// Preconfigured menu items.
|
|
||||||
this.MenuItems = {
|
|
||||||
'topic_info': {
|
|
||||||
id: 'topic_info',
|
|
||||||
title: formatMessage(messages.info),
|
|
||||||
handler: null
|
|
||||||
},
|
|
||||||
'messages_clear': {
|
|
||||||
id: 'messages_clear',
|
|
||||||
title: formatMessage(messages.clear_messages),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
return props.onShowAlert(
|
|
||||||
formatMessage({id: 'menu_item_clear_messages'}), // title
|
|
||||||
formatMessage({id: 'clear_messages_warning'}), // content
|
|
||||||
(() => { this.deleteMessages(true, false, params, errorHandler); }), // onConfirm
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'messages_clear_hard': {
|
|
||||||
id: 'messages_clear_hard',
|
|
||||||
title: formatMessage(messages.clear_for_all),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
return props.onShowAlert(
|
|
||||||
formatMessage({id: 'menu_item_clear_messages_for_all'}), // title
|
|
||||||
formatMessage({id: 'delete_messages_warning'}), // content
|
|
||||||
(() => { return this.deleteMessages(true, true, params, errorHandler); }),
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'message_delete': {
|
|
||||||
id: 'message_delete',
|
|
||||||
title: formatMessage(messages.delete),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
return this.deleteMessages(false, false, params, errorHandler);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'message_delete_hard': {
|
|
||||||
id: 'message_delete_hard',
|
|
||||||
title: formatMessage(messages.delete_for_all),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
return this.deleteMessages(false, true, params, errorHandler);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'topic_unmute': {
|
|
||||||
id: 'topic_unmute',
|
|
||||||
title: formatMessage(messages.unmute),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '+P')
|
|
||||||
},
|
|
||||||
'topic_mute': {
|
|
||||||
id: 'topic_mute',
|
|
||||||
title: formatMessage(messages.mute),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '-P')
|
|
||||||
},
|
|
||||||
'topic_unblock': {
|
|
||||||
id: 'topic_unblock',
|
|
||||||
title: formatMessage(messages.unblock),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '+JP')
|
|
||||||
},
|
|
||||||
'topic_block': {
|
|
||||||
id: 'topic_block',
|
|
||||||
title: formatMessage(messages.block),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
return props.onShowAlert(
|
|
||||||
formatMessage({id: 'menu_item_block'}), // title
|
|
||||||
formatMessage(messages.topic_block_warning), // content
|
|
||||||
(() => {
|
|
||||||
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
|
|
||||||
this.props.onTopicRemoved(params.topicName);
|
|
||||||
return ctrl;
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'topic_delete': {
|
|
||||||
id: 'topic_delete',
|
|
||||||
title: formatMessage(messages.topic_delete),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
return props.onShowAlert(
|
|
||||||
formatMessage({id: 'menu_item_delete_topic'}), // title
|
|
||||||
formatMessage(messages.topic_delete_warning), // content
|
|
||||||
(() => {
|
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
|
||||||
if (!topic) {
|
|
||||||
console.log("Topic not found: ", params.topicName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return topic.delTopic().catch((err) => {
|
|
||||||
if (errorHandler) {
|
|
||||||
errorHandler(err.message, 'err');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
null, // "OK"
|
|
||||||
true, // Show Reject button
|
|
||||||
null // "Cancel"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'topic_archive': {
|
|
||||||
id: 'topic_archive',
|
|
||||||
title: formatMessage(messages.archive),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
|
||||||
if (!topic) {
|
|
||||||
console.log("Topic not found: ", params.topicName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return topic.archive(true).catch((err) => {
|
|
||||||
if (errorHandler) {
|
|
||||||
errorHandler(err.message, 'err');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// menu_item_edit_permissions is defined elsewhere.
|
|
||||||
'permissions': {
|
|
||||||
id: 'permissions',
|
|
||||||
title: formatMessage({id: 'menu_item_edit_permissions'}),
|
|
||||||
handler: null
|
|
||||||
},
|
|
||||||
'member_delete': {
|
|
||||||
id: 'member_delete',
|
|
||||||
title: formatMessage(messages.member_delete),
|
|
||||||
handler: (params, errorHandler) => {
|
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
|
||||||
if (!topic || !params.user) {
|
|
||||||
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return topic.delSubscription(params.user).catch((err) => {
|
|
||||||
if (errorHandler) {
|
|
||||||
errorHandler(err.message, 'err');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'member_mute': {
|
|
||||||
id: 'member_mute',
|
|
||||||
title: formatMessage(messages.mute),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '-P')
|
|
||||||
},
|
|
||||||
'member_unmute': {
|
|
||||||
id: 'member_unmute',
|
|
||||||
title: formatMessage(messages.unmute),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '+P')
|
|
||||||
},
|
|
||||||
'member_block': {
|
|
||||||
id: 'member_block',
|
|
||||||
title: formatMessage(messages.block),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '-J')
|
|
||||||
},
|
|
||||||
'member_unblock': {
|
|
||||||
id: 'member_unblock',
|
|
||||||
title: formatMessage(messages.unblock),
|
|
||||||
handler: this.topicPermissionSetter.bind(this, '+J')
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
document.addEventListener('mousedown', this.handlePageClick, false);
|
|
||||||
document.addEventListener('keyup', this.handleEscapeKey, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
document.removeEventListener('mousedown', this.handlePageClick, false);
|
|
||||||
document.removeEventListener('keyup', this.handleEscapeKey, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePageClick(e) {
|
|
||||||
if (ReactDOM.findDOMNode(this).contains(e.target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEscapeKey(e) {
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
this.props.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.props.hide();
|
|
||||||
let item = this.props.items[e.currentTarget.dataset.id];
|
|
||||||
if (typeof item == 'string') {
|
|
||||||
item = this.MenuItems[item];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
|
|
||||||
} else {
|
|
||||||
this.props.onAction(
|
|
||||||
item.id,
|
|
||||||
item.handler(this.props.params, this.props.onError),
|
|
||||||
this.props.params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu Actions
|
|
||||||
|
|
||||||
deleteMessages(all, hard, params, errorHandler) {
|
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
|
||||||
if (!topic) {
|
|
||||||
console.log("Topic not found: ", params.topicName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We don't know if the message is still pending (e.g. attachment is being uploaded),
|
|
||||||
// so try cancelling first. No harm if we can't cancel.
|
|
||||||
// The message can be cancelled if transmission to the server has not
|
|
||||||
// started yet or if the message send has failed.
|
|
||||||
if (!all && topic.cancelSend(params.seq)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Can't cancel. Delete instead.
|
|
||||||
const promise = all ?
|
|
||||||
topic.delMessagesAll(hard) :
|
|
||||||
topic.delMessagesList([params.seq], hard);
|
|
||||||
|
|
||||||
return promise.catch((err) => {
|
|
||||||
if (errorHandler) {
|
|
||||||
errorHandler(err.message, 'err');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function is used by context menu to set permissions.
|
|
||||||
topicPermissionSetter(mode, params, errorHandler) {
|
|
||||||
const topic = this.props.tinode.getTopic(params.topicName);
|
|
||||||
if (!topic) {
|
|
||||||
console.log("Topic not found", params.topicName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = topic.updateMode(params.user, mode);
|
|
||||||
if (errorHandler) {
|
|
||||||
result = result.catch((err) => {
|
|
||||||
errorHandler(err.message, 'err');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let count = 0;
|
|
||||||
let menu = [];
|
|
||||||
this.props.items.map((item) => {
|
|
||||||
if (typeof item == 'string') {
|
|
||||||
item = this.MenuItems[item];
|
|
||||||
}
|
|
||||||
if (item && item.title) {
|
|
||||||
menu.push(
|
|
||||||
item.title == '-' ?
|
|
||||||
<li className="separator" key={count} />
|
|
||||||
:
|
|
||||||
<li onClick={this.handleClick} data-id={count} key={count}>{item.title}</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
count++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure that menu is inside the app-container.
|
|
||||||
const hSize = 12 * REM_SIZE;
|
|
||||||
const vSize = REM_SIZE * (0.7 + menu.length * 2.5);
|
|
||||||
const left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
|
|
||||||
(this.props.clickAt.x - this.props.bounds.left - hSize) :
|
|
||||||
(this.props.clickAt.x - this.props.bounds.left);
|
|
||||||
const top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
|
|
||||||
(this.props.clickAt.y - this.props.bounds.top - vSize) :
|
|
||||||
(this.props.clickAt.y - this.props.bounds.top);
|
|
||||||
|
|
||||||
const position = {
|
|
||||||
left: left + 'px',
|
|
||||||
top: top + 'px'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="menu" style={position}>
|
|
||||||
{menu}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(ContextMenu);
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import MenuCancel from './menu-cancel.jsx';
|
|
||||||
|
|
||||||
export default class ErrorPanel extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
show: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.hide = this.hide.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (prevProps.level !== this.props.level) {
|
|
||||||
this.setState({
|
|
||||||
show: !(!this.props.level)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
this.setState({show: false});
|
|
||||||
if (this.props.onClearError) {
|
|
||||||
this.props.onClearError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const icons = {err: 'error', warn: 'warning', info: 'info'}
|
|
||||||
const level = icons[this.props.level] || '';
|
|
||||||
const className = 'info-box ' + level;
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<div className="icon"><i className="material-icons">{level}</i></div>
|
|
||||||
<span>
|
|
||||||
<span dangerouslySetInnerHTML={{__html: this.props.text}} />
|
|
||||||
{this.props.action ?
|
|
||||||
<>
|
|
||||||
 <a href="#"
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
onClick={(e) => {e.preventDefault(); this.props.action();}}>
|
|
||||||
{this.props.actionText}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
: null}
|
|
||||||
</span>
|
|
||||||
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// File uload/download progress indicator with a cancel inside.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
export default class FileProgress extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="uploader">
|
|
||||||
<div><span style={{width: (this.props.progress * 100) + "%"}}></span></div>
|
|
||||||
{this.props.progress < 0.999 ?
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}>
|
|
||||||
<i className="material-icons">close</i> <FormattedMessage id="action_cancel"
|
|
||||||
defaultMessage="cancel" description="Call to action [cancel]" />
|
|
||||||
</a>
|
|
||||||
:
|
|
||||||
<FormattedMessage id="upload_finishing" defaultMessage="finishing..."
|
|
||||||
description="Notification that upload is finishing" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// GroupMembers: control for managing a list of group members.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import ChipInput from './chip-input.jsx';
|
|
||||||
import ContactList from './contact-list.jsx';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
no_contacts: {
|
|
||||||
id: 'no_contacts',
|
|
||||||
defaultMessage: 'You have no contacts :-(',
|
|
||||||
description: 'Shown in ContactsView when the user has no contacts'
|
|
||||||
},
|
|
||||||
contacts_not_found: {
|
|
||||||
id: 'contacts_not_found_short',
|
|
||||||
defaultMessage: "No contacts match '{query}'",
|
|
||||||
description: 'Shown in ContactsView when search returned no results'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class GroupManager extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
// Array of topic members
|
|
||||||
members: props.members,
|
|
||||||
index: GroupManager.indexMembers(props.members),
|
|
||||||
staticMembers: GroupManager.staticMembers(props.members, props.keepInitialMembers, props.requiredMember),
|
|
||||||
contactFilter: '',
|
|
||||||
noContactsMessage: props.intl.formatMessage(messages.no_contacts),
|
|
||||||
selectedContacts: GroupManager.selectedContacts(props.members)
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleContactSelected = this.handleContactSelected.bind(this);
|
|
||||||
this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
|
|
||||||
this.handleContactFilter = this.handleContactFilter.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.handleCancel = this.handleCancel.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
static indexMembers(members) {
|
|
||||||
let index = {};
|
|
||||||
members.map((m) => {
|
|
||||||
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
|
|
||||||
});
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
static staticMembers(members, keepInitial, requiredMember) {
|
|
||||||
let stat = [];
|
|
||||||
members.map((m) => {
|
|
||||||
if (keepInitial || m.user == requiredMember) {
|
|
||||||
stat.push(m.user);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return stat;
|
|
||||||
}
|
|
||||||
|
|
||||||
static selectedContacts(members) {
|
|
||||||
let sel = [];
|
|
||||||
members.map((m) => {
|
|
||||||
sel.push(m.user);
|
|
||||||
});
|
|
||||||
return sel;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContactSelected(userId, index) {
|
|
||||||
let status = this.state.index[userId];
|
|
||||||
if (status) {
|
|
||||||
if (status.present) {
|
|
||||||
// Prevent duplicate members
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.delta += 1;
|
|
||||||
status.present = true;
|
|
||||||
} else {
|
|
||||||
status = {delta: 1, present: true};
|
|
||||||
}
|
|
||||||
|
|
||||||
let m = this.state.members.slice();
|
|
||||||
m.push(this.props.contacts[index]);
|
|
||||||
|
|
||||||
const sel = GroupManager.selectedContacts(m);
|
|
||||||
|
|
||||||
const i = this.state.index;
|
|
||||||
i[userId] = status;
|
|
||||||
|
|
||||||
this.setState({members: m, index: i, selectedContacts: sel});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMemberRemoved(userId, index) {
|
|
||||||
const status = this.state.index[userId];
|
|
||||||
if (!status || !status.present) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.present = false;
|
|
||||||
status.delta -= 1;
|
|
||||||
|
|
||||||
let m = this.state.members.slice();
|
|
||||||
m.splice(index, 1);
|
|
||||||
|
|
||||||
const sel = GroupManager.selectedContacts(m);
|
|
||||||
|
|
||||||
const i = this.state.index;
|
|
||||||
i[userId] = status;
|
|
||||||
|
|
||||||
this.setState({members: m, index: i, selectedContacts: sel});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleContactFilter(val) {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
const msg = !val ?
|
|
||||||
formatMessage(messages.no_contacts) :
|
|
||||||
formatMessage(messages.contacts_not_found, {query: val});
|
|
||||||
|
|
||||||
this.setState({contactFilter: val, noContactsMessage: msg});
|
|
||||||
}
|
|
||||||
|
|
||||||
static doContactFiltering(filter, values) {
|
|
||||||
if (filter) {
|
|
||||||
for (let i=0; i<values.length; i++) {
|
|
||||||
if (values[i].indexOf(filter) >= 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit() {
|
|
||||||
var instance = this;
|
|
||||||
var members = [];
|
|
||||||
var added = [];
|
|
||||||
var removed = [];
|
|
||||||
|
|
||||||
var keys = Object.keys(this.state.index);
|
|
||||||
keys.map(function(k) {
|
|
||||||
if (instance.state.index[k].present) {
|
|
||||||
members.push(k);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance.state.index[k].delta > 0) {
|
|
||||||
added.push(k);
|
|
||||||
} else if (instance.state.index[k].delta < 0) {
|
|
||||||
removed.push(k);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.props.onSubmit(members, added, removed);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel() {
|
|
||||||
this.props.onCancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
return (
|
|
||||||
<div id="group-manager">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="title_group_members" defaultMessage="Group Members" description="Section title" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<ChipInput
|
|
||||||
chips={this.state.members}
|
|
||||||
staticMembers={this.state.staticMembers}
|
|
||||||
prompt="add members"
|
|
||||||
filterFunc={this.handleContactFilter}
|
|
||||||
onChipRemoved={this.handleMemberRemoved} />
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small">
|
|
||||||
<FormattedMessage id="title_all_contacts" defaultMessage="All Contacts"
|
|
||||||
description="Section title [All Contacts]" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<ContactList
|
|
||||||
contacts={this.props.contacts}
|
|
||||||
myUserId={this.props.myUserId}
|
|
||||||
topicSelected={this.state.selectedContacts}
|
|
||||||
filter={this.state.contactFilter}
|
|
||||||
filterFunc={GroupManager.doContactFiltering}
|
|
||||||
emptyListMessage={this.state.noContactsMessage}
|
|
||||||
showOnline={false}
|
|
||||||
showUnread={false}
|
|
||||||
onTopicSelected={this.handleContactSelected} />
|
|
||||||
<div id="group-manager-buttons" className="panel-form-row">
|
|
||||||
<button className="blue" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
|
||||||
</button>
|
|
||||||
<button className="white" onClick={this.handleCancel}>
|
|
||||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(GroupManager);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// GroupSubs: a list of group subscribers currently online.
|
|
||||||
// Show in the topic title bar
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
|
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
export default class GroupSubs extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const usersOnline = [];
|
|
||||||
const totalCount = (this.props.subscribers || []).length;
|
|
||||||
const countToShow = Math.min(MAX_ONLINE_IN_TOPIC, totalCount);
|
|
||||||
|
|
||||||
(this.props.subscribers || []).some((sub) => {
|
|
||||||
usersOnline.push(
|
|
||||||
<div className="avatar-box" key={sub.user}>
|
|
||||||
<LetterTile
|
|
||||||
topic={sub.user}
|
|
||||||
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
|
|
||||||
title={sub.public ? sub.public.fn : null} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return usersOnline.length == countToShow;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="topic-users">{usersOnline} {totalCount > countToShow ?
|
|
||||||
<span>
|
|
||||||
<FormattedMessage id="more_online_members" defaultMessage="+{overflow} more"
|
|
||||||
description="Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
|
|
||||||
values={{ overflow: (totalCount - countToShow) }} />
|
|
||||||
</span> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { KNOWN_HOSTS } from '../config.js';
|
|
||||||
|
|
||||||
/* Combobox for selecting host name */
|
|
||||||
|
|
||||||
export default class HostSelector extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
hostName: props.serverAddress,
|
|
||||||
changed: false
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleHostNameChange = this.handleHostNameChange.bind(this);
|
|
||||||
this.handleEditingFinished = this.handleEditingFinished.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHostNameChange(e) {
|
|
||||||
this.setState({hostName: e.target.value, changed: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEditingFinished() {
|
|
||||||
if (this.state.changed) {
|
|
||||||
this.setState({changed: false});
|
|
||||||
this.props.onServerAddressChange(this.state.hostName.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
var hostOptions = [];
|
|
||||||
for (let key in KNOWN_HOSTS) {
|
|
||||||
let item = KNOWN_HOSTS[key];
|
|
||||||
hostOptions.push(
|
|
||||||
<option key={item} value={item} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
|
|
||||||
className="quoted" value={this.state.hostName} onChange={this.handleHostNameChange}
|
|
||||||
onBlur={this.handleEditingFinished} required />
|
|
||||||
<datalist id="known-hosts">
|
|
||||||
{hostOptions}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { REM_SIZE } from '../config.js';
|
|
||||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
|
||||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
|
||||||
|
|
||||||
export default class ImagePreview extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
width: 0,
|
|
||||||
height: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
assignWidth(node) {
|
|
||||||
if (node && !this.state.width) {
|
|
||||||
const bounds = node.getBoundingClientRect();
|
|
||||||
this.setState({
|
|
||||||
width: bounds.width | 0,
|
|
||||||
height: bounds.height | 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.props.content) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dim = fitImageSize(this.props.content.width, this.props.content.height,
|
|
||||||
this.state.width, this.state.height, false);
|
|
||||||
const size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
|
|
||||||
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
|
|
||||||
size.maxWidth = '100%';
|
|
||||||
size.maxHeight = '100%';
|
|
||||||
|
|
||||||
let filename = this.props.content.filename;
|
|
||||||
// Averate font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
|
||||||
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
|
||||||
if (filename.length > maxlength) {
|
|
||||||
filename = filename.slice(0, maxlength/2 - 1) + '...' + filename.slice(1 - maxlength/2);
|
|
||||||
}
|
|
||||||
const width = this.props.content.width || '-';
|
|
||||||
const height = this.props.content.height || '-';
|
|
||||||
return (
|
|
||||||
<div id="image-preview" onClick={this.props.onClose}>
|
|
||||||
<div id="image-preview-caption-panel">
|
|
||||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
|
||||||
<i className="material-icons">file_download</i> <FormattedMessage
|
|
||||||
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
|
||||||
</a>
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
|
||||||
</div>
|
|
||||||
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
|
||||||
<img src={this.props.content.url} style={size} />
|
|
||||||
</div>
|
|
||||||
<div id="image-preview-footer">
|
|
||||||
<div>
|
|
||||||
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
|
||||||
description="Label for a file name" /></b></div>
|
|
||||||
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
|
||||||
description="Label for file content type (mime)" /></b></div>
|
|
||||||
<div>{this.props.content.type}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
|
||||||
description="Label for file size" /></b></div>
|
|
||||||
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// In-place text editor. Shows text with an icon which toggles it into an input field.
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
import VisiblePassword from './visible-password.jsx';
|
|
||||||
|
|
||||||
export default class InPlaceEdit extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
active: props.active,
|
|
||||||
initialValue: props.value || '',
|
|
||||||
value: props.value || ''
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handeTextChange = this.handeTextChange.bind(this);
|
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
this.handleStartEditing = this.handleStartEditing.bind(this);
|
|
||||||
this.handleEditingFinished = this.handleEditingFinished.bind(this);
|
|
||||||
this.handlePasswordFinished = this.handlePasswordFinished.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
// If text has changed while in read mode, update text and discard changes.
|
|
||||||
// Ignore update if in edit mode.
|
|
||||||
const newValue = this.props.value || '';
|
|
||||||
if (prevState.initialValue != newValue && !prevState.active) {
|
|
||||||
this.setState({
|
|
||||||
initialValue: newValue,
|
|
||||||
value: newValue
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handeTextChange(e) {
|
|
||||||
this.setState({value: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown(e) {
|
|
||||||
if (e.keyCode === 27) {
|
|
||||||
// Escape pressed
|
|
||||||
this.setState({value: this.props.value, active: false});
|
|
||||||
} else if (e.keyCode === 13) {
|
|
||||||
// Enter pressed
|
|
||||||
this.handleEditingFinished(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleStartEditing() {
|
|
||||||
if (!this.props.readOnly) {
|
|
||||||
ReactDOM.findDOMNode(this).focus();
|
|
||||||
this.setState({active: true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEditingFinished(event) {
|
|
||||||
if (this.props.required && !event.target.checkValidity()) {
|
|
||||||
// Empty input
|
|
||||||
this.setState({value: this.props.value, active: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({active: false});
|
|
||||||
let value = this.state.value.trim();
|
|
||||||
if ((value || this.props.value) && (value !== this.props.value)) {
|
|
||||||
this.props.onFinished(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePasswordFinished(value) {
|
|
||||||
this.setState({active: false});
|
|
||||||
if (value && (value !== this.props.value)) {
|
|
||||||
this.props.onFinished(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.active) {
|
|
||||||
var fieldType = this.props.type || 'text';
|
|
||||||
} else {
|
|
||||||
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
|
|
||||||
var spanClass = 'in-place-edit' +
|
|
||||||
(this.props.readOnly ? ' disabled' : '');
|
|
||||||
if (!spanText) {
|
|
||||||
spanText = this.props.placeholder;
|
|
||||||
spanClass += ' placeholder';
|
|
||||||
}
|
|
||||||
if (spanText.length > 20) {
|
|
||||||
// FIXME: this is wrong for RTL languages.
|
|
||||||
spanText = spanText.substring(0, 19) + '...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
this.state.active ?
|
|
||||||
(fieldType == 'password' ?
|
|
||||||
<VisiblePassword
|
|
||||||
value={this.state.value}
|
|
||||||
placeholder={this.props.placeholder}
|
|
||||||
required={this.props.required ? 'required' : ''}
|
|
||||||
autoComplete={this.props.autoComplete}
|
|
||||||
autoFocus={true}
|
|
||||||
onFinished={this.handlePasswordFinished}/>
|
|
||||||
:
|
|
||||||
<input type={fieldType}
|
|
||||||
value={this.state.value}
|
|
||||||
placeholder={this.props.placeholder}
|
|
||||||
required={this.props.required ? 'required' : ''}
|
|
||||||
autoComplete={this.props.autoComplete}
|
|
||||||
autoFocus
|
|
||||||
onChange={this.handeTextChange}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
onBlur={this.handleEditingFinished} />
|
|
||||||
)
|
|
||||||
:
|
|
||||||
<span className={spanClass} onClick={this.handleStartEditing}>
|
|
||||||
<span className="content">{spanText}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// A single topic or user.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
export default class Invitation extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.handleButtonAction = this.handleButtonAction.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleButtonAction(evt, data) {
|
|
||||||
evt.preventDefault();
|
|
||||||
this.props.onAction(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="accept-invite-panel">
|
|
||||||
<div className="title">
|
|
||||||
<FormattedMessage id="chat_invitation"
|
|
||||||
defaultMessage="You are invited to start a new chat. What would you like to do?"
|
|
||||||
description="New chat invitation message: [Accept] [Ignore] [Block]." />
|
|
||||||
</div>
|
|
||||||
<div className="footer">
|
|
||||||
<button className="blue" onClick={event => { this.handleButtonAction(event, "accept"); }}>
|
|
||||||
<FormattedMessage id="chat_invitation_accept"
|
|
||||||
defaultMessage="Accept" description="Action [Accept] for chat invitation." />
|
|
||||||
</button>
|
|
||||||
<button className="white" onClick={event => { this.handleButtonAction(event, "delete"); }}>
|
|
||||||
<FormattedMessage id="chat_invitation_ignore"
|
|
||||||
defaultMessage="Ignore" description="Action [Ignore] for chat invitation." />
|
|
||||||
</button>
|
|
||||||
<button className="white" onClick={event => { this.handleButtonAction(event, "block"); }}>
|
|
||||||
<FormattedMessage id="chat_invitation_block"
|
|
||||||
defaultMessage="Block" description="Action [Block] for chat invitation." />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import { stringHash } from '../lib/strformat.js';
|
|
||||||
|
|
||||||
export default class LetterTile extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
let avatar;
|
|
||||||
if (this.props.avatar === true) {
|
|
||||||
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
|
||||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
|
||||||
const letter = this.props.title.trim().charAt(0);
|
|
||||||
const color = 'lettertile '
|
|
||||||
+ (isGroup ? 'color' : 'dark-color')
|
|
||||||
+ (Math.abs(stringHash(this.props.topic)) % 16);
|
|
||||||
avatar = (<div className={color}><div>{letter}</div></div>)
|
|
||||||
} else {
|
|
||||||
avatar = isGroup ?
|
|
||||||
<i className="material-icons">group</i> :
|
|
||||||
<i className="material-icons">person</i>;
|
|
||||||
}
|
|
||||||
} else if (this.props.avatar) {
|
|
||||||
// If avatar image is invalid, show a placeholder.
|
|
||||||
avatar = <img className="avatar" alt="avatar" src={this.props.avatar}
|
|
||||||
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />;
|
|
||||||
} else {
|
|
||||||
avatar = null;
|
|
||||||
}
|
|
||||||
return avatar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class LoadSpinner extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (this.props.show ?
|
|
||||||
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// The X menu to be displayed in title bars.
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class MenuCancel extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}><i className="material-icons">close</i></a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class MenuContacts extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onNewTopic();}}><i className="material-icons">chat</i></a>
|
|
||||||
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSettings();}}><i className="material-icons">settings</i></a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class MenuStart extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSignUp();}}><i className="material-icons">person_add</i></a>
|
|
||||||
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSettings();}}><i className="material-icons">settings</i></a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Toggle [Title text >] -> [Title text v]
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class MoreButton extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
open: props.open
|
|
||||||
};
|
|
||||||
this.handleToggle = this.handleToggle.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggle() {
|
|
||||||
const open = !this.state.open;
|
|
||||||
this.setState({open: open});
|
|
||||||
if (this.props.onToggle) {
|
|
||||||
this.props.onToggle(open);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (<label className="small clean-clickable" onClick={this.handleToggle}>{this.props.title}...
|
|
||||||
{this.state.open ? <i className="material-icons">expand_more</i> :
|
|
||||||
<i className="material-icons">chevron_right</i>}
|
|
||||||
</label>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
invalid_id: {
|
|
||||||
id: 'error_invalid_id',
|
|
||||||
defaultMessage: 'Invalid ID',
|
|
||||||
description: 'Error message'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class NewTopicById extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
groupId: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChange(e) {
|
|
||||||
this.setState({groupId: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyPress(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
this.handleSubmit(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this.state.groupId) {
|
|
||||||
var name = this.state.groupId.trim();
|
|
||||||
if (name.length > 3 && (name.substr(0, 3) == 'usr' || name.substr(0, 3) == 'grp')) {
|
|
||||||
this.props.onSubmit(name);
|
|
||||||
} else {
|
|
||||||
this.props.onError(this.props.intl.formatMessage(messages.invalid_id), 'err');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="panel-form">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<FormattedMessage id="group_user_id_prompt" defaultMessage="Group or User ID"
|
|
||||||
description="Prompt for entering user or group ID">{
|
|
||||||
(prompt) => <input type="text" placeholder={prompt}
|
|
||||||
value={this.state.groupId} onChange={this.handleChange}
|
|
||||||
onKeyPress={this.handleKeyPress} required />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className="blue" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_subscribe" defaultMessage="Subscribe"
|
|
||||||
description="Button [Subscribe]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(NewTopicById);
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import AvatarUpload from './avatar-upload.jsx';
|
|
||||||
import TagManager from './tag-manager.jsx';
|
|
||||||
|
|
||||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
|
||||||
|
|
||||||
export default class NewTopicGroup extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
fn: '', // full/formatted name
|
|
||||||
private: '',
|
|
||||||
imageDataUrl: null,
|
|
||||||
tags: []
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleFnChange = this.handleFnChange.bind(this);
|
|
||||||
this.handlePrivateChange = this.handlePrivateChange.bind(this);
|
|
||||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
|
||||||
this.handleTagsChanged = this.handleTagsChanged.bind(this);
|
|
||||||
this.handleTagsChanged = this.handleTagsChanged.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFnChange(e) {
|
|
||||||
this.setState({fn: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePrivateChange(e) {
|
|
||||||
this.setState({private: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageChanged(img) {
|
|
||||||
this.setState({imageDataUrl: img});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTagsChanged(tags) {
|
|
||||||
this.setState({tags: tags});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
|
|
||||||
if (fn) {
|
|
||||||
this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
var submitClasses = 'blue';
|
|
||||||
if (this.props.disabled) {
|
|
||||||
submitClasses += ' disabled';
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<form className="panel-form" onSubmit={this.handleSubmit}>
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<label className="small" htmlFor="new-topic-fn">
|
|
||||||
<FormattedMessage id="label_topic_name" />
|
|
||||||
</label>
|
|
||||||
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
|
|
||||||
description="Prompt for entering topic name">{
|
|
||||||
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
|
|
||||||
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<br />
|
|
||||||
<label className="small" htmlFor="new-topic-priv">
|
|
||||||
<FormattedMessage id="label_private" />
|
|
||||||
</label>
|
|
||||||
<FormattedMessage id="private_editing_placeholder">{
|
|
||||||
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
|
|
||||||
value={this.state.private} onChange={this.handlePrivateChange} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
</div>
|
|
||||||
<AvatarUpload
|
|
||||||
onError={this.props.onError}
|
|
||||||
onImageChanged={this.handleImageChanged} />
|
|
||||||
</div>
|
|
||||||
<FormattedMessage id="title_tag_manager">{
|
|
||||||
(title) => <TagManager
|
|
||||||
tags={this.state.tags}
|
|
||||||
activated={true}
|
|
||||||
onTagsChanged={this.handleTagsChanged}
|
|
||||||
title={title} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className={submitClasses}>
|
|
||||||
<FormattedMessage id="button_create" defaultMessage="Create"
|
|
||||||
description="Button [Create]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
// PermissionsEditor: Component for editing permissions
|
|
||||||
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import CheckBox from './checkbox.jsx';
|
|
||||||
import Contact from './contact.jsx';
|
|
||||||
|
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
|
||||||
|
|
||||||
// Translatable permission names.
|
|
||||||
const messages = defineMessages({
|
|
||||||
'joiner': {
|
|
||||||
id: 'permission_join',
|
|
||||||
defaultMessage: "Join ({val})",
|
|
||||||
description: 'Name of J permission'
|
|
||||||
},
|
|
||||||
'reader': {
|
|
||||||
id: 'permission_read',
|
|
||||||
defaultMessage: "Read ({val})",
|
|
||||||
description: 'Name of R permission'
|
|
||||||
},
|
|
||||||
'writer': {
|
|
||||||
id: 'permission_write',
|
|
||||||
defaultMessage: "Write ({val})",
|
|
||||||
description: 'Name of W permission'
|
|
||||||
},
|
|
||||||
'preser': {
|
|
||||||
id: 'permission_pres',
|
|
||||||
defaultMessage: "Get notified ({val})",
|
|
||||||
description: 'Name of P permission'
|
|
||||||
},
|
|
||||||
'approver': {
|
|
||||||
id: 'permission_admin',
|
|
||||||
defaultMessage: "Approve ({val})",
|
|
||||||
description: 'Name of A permission'
|
|
||||||
},
|
|
||||||
'sharer': {
|
|
||||||
id: 'permission_share',
|
|
||||||
defaultMessage: "Share ({val})",
|
|
||||||
description: 'Name of S permission'
|
|
||||||
},
|
|
||||||
'deleter': {
|
|
||||||
id: 'permission_delete',
|
|
||||||
defaultMessage: "Delete ({val})",
|
|
||||||
description: 'Name of D permission'
|
|
||||||
},
|
|
||||||
'owner': {
|
|
||||||
id: 'permission_owner',
|
|
||||||
defaultMessage: "Owner ({val})",
|
|
||||||
description: 'Name of O permission'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class PermissionsEditor extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
mode: (props.mode || '').replace('N', '')
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.handleCancel = this.handleCancel.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChange(val) {
|
|
||||||
let mode = this.state.mode;
|
|
||||||
let idx = mode.indexOf(val);
|
|
||||||
if (idx == -1) {
|
|
||||||
mode += val;
|
|
||||||
} else {
|
|
||||||
mode = mode.replace(val, '');
|
|
||||||
}
|
|
||||||
this.setState({mode: mode});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit() {
|
|
||||||
// Normalize string, otherwise cannot check if mode has changed.
|
|
||||||
var mode = (this.state.mode || 'N').split('').sort().join('');
|
|
||||||
var before = (this.props.mode || 'N').split('').sort().join('')
|
|
||||||
if (mode !== before) {
|
|
||||||
this.props.onSubmit(mode);
|
|
||||||
} else {
|
|
||||||
this.props.onCancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel() {
|
|
||||||
this.props.onCancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
const all = 'JRWPASDO';
|
|
||||||
const names = {
|
|
||||||
'J': formatMessage(messages.joiner, {val: 'J'}),
|
|
||||||
'R': formatMessage(messages.reader, {val: 'R'}),
|
|
||||||
'W': formatMessage(messages.writer, {val: 'W'}),
|
|
||||||
'P': formatMessage(messages.preser, {val: 'P'}),
|
|
||||||
'A': formatMessage(messages.approver, {val: 'A'}),
|
|
||||||
'S': formatMessage(messages.sharer, {val: 'S'}),
|
|
||||||
'D': formatMessage(messages.deleter, {val: 'D'}),
|
|
||||||
'O': formatMessage(messages.owner, {val: 'O'})
|
|
||||||
};
|
|
||||||
|
|
||||||
let skip = this.props.skip || '';
|
|
||||||
let mode = this.state.mode;
|
|
||||||
let compare = (this.props.compare || '').replace('N', '');
|
|
||||||
let items = [];
|
|
||||||
for (let i=0; i<all.length; i++) {
|
|
||||||
let c = all.charAt(i);
|
|
||||||
if (skip.indexOf(c) >= 0 && mode.indexOf(c) < 0) {
|
|
||||||
// Permission is marked as inactive: hide unchecked permissions, disable checked permissions
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<tr key={c}>
|
|
||||||
<td>{names[c]}</td>
|
|
||||||
<td className="checkbox">{skip.indexOf(c) < 0 ?
|
|
||||||
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} onChange={this.handleChange}/>
|
|
||||||
:
|
|
||||||
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} />
|
|
||||||
}</td>{this.props.compare ? <td className="checkbox">
|
|
||||||
<CheckBox name={c} checked={(compare.indexOf(c) >= 0)}/>
|
|
||||||
</td> : null}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="panel-form-column">
|
|
||||||
{this.props.userTitle ?
|
|
||||||
<ul className="contact-box"><Contact
|
|
||||||
item={this.props.item}
|
|
||||||
title={this.props.userTitle}
|
|
||||||
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
|
|
||||||
<label className="small"><FormattedMessage id="title_permissions"
|
|
||||||
defaultMessage="Permissions" description="Section title"/></label>
|
|
||||||
<table className="permission-editor">
|
|
||||||
{this.props.compare ?
|
|
||||||
<thead><tr>
|
|
||||||
<th></th><th>{this.props.modeTitle}</th>
|
|
||||||
<th>{this.props.compareTitle}</th>
|
|
||||||
</tr></thead> :
|
|
||||||
null}
|
|
||||||
<tbody>
|
|
||||||
{items}
|
|
||||||
</tbody></table>
|
|
||||||
<br />
|
|
||||||
<div className="dialog-buttons">
|
|
||||||
<button className="outline" onClick={this.handleCancel}>
|
|
||||||
<FormattedMessage id="button_cancel" />
|
|
||||||
</button>
|
|
||||||
<button className="blue" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_ok" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(PermissionsEditor);
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// Received/read indicator.
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
import { shortDateFormat } from '../lib/strformat.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
'sending': {
|
|
||||||
'id': 'message_sending',
|
|
||||||
'defaultMessage': 'sending...',
|
|
||||||
'description': 'Message being sent, in place of time stamp'
|
|
||||||
},
|
|
||||||
'failed': {
|
|
||||||
'id': 'message_sending_failed',
|
|
||||||
'defaultMessage': 'failed',
|
|
||||||
'description': 'Failed to send message, in place of time stamp'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class ReceivedMarker extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
let timestamp;
|
|
||||||
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
|
|
||||||
timestamp = formatMessage(messages.sending);
|
|
||||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
|
||||||
timestamp = formatMessage(messages.failed);
|
|
||||||
} else {
|
|
||||||
timestamp = shortDateFormat(this.props.timestamp, this.props.intl.locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
let marker = null;
|
|
||||||
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
|
|
||||||
marker = (<i className="material-icons small">access_time</i>); // watch face
|
|
||||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
|
||||||
marker = (<i className="material-icons small amber">warning</i>); // yellow icon /!\
|
|
||||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
|
|
||||||
marker = (<i className="material-icons small">done</i>); // checkmark
|
|
||||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
|
|
||||||
marker = (<i className="material-icons small">done_all</i>); // double checkmark
|
|
||||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
|
|
||||||
marker = (<i className="material-icons small blue">done_all</i>); // blue double checkmark
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="timestamp">
|
|
||||||
{timestamp}{'\u00a0'}{marker}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ReceivedMarker);
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import Tinode from 'tinode-sdk';
|
|
||||||
|
|
||||||
export default class SearchContacts extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
edited: false,
|
|
||||||
search: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleSearchChange = this.handleSearchChange.bind(this);
|
|
||||||
this.handleSearch = this.handleSearch.bind(this);
|
|
||||||
this.handleClear = this.handleClear.bind(this);
|
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this.state.edited) {
|
|
||||||
this.setState({search: '', edited: false});
|
|
||||||
this.props.onSearchContacts(Tinode.DEL_CHAR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSearchChange(e) {
|
|
||||||
this.setState({search: e.target.value});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSearch(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var query = this.state.search.trim();
|
|
||||||
this.setState({edited: (query.length > 0)});
|
|
||||||
this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClear(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this.state.edited) {
|
|
||||||
this.props.onSearchContacts(Tinode.DEL_CHAR);
|
|
||||||
}
|
|
||||||
this.setState({search: '', edited: false});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown(e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
this.handleSearch(e);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
this.handleClear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="panel-form">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<i className="material-icons search">search</i>
|
|
||||||
<FormattedMessage id="search_placeholder" defaultMessage="List like email:alice@example.com, tel:17025550003..."
|
|
||||||
description="Placeholder in contacts search field">{
|
|
||||||
(search_placeholder) => <input className="search" type="text"
|
|
||||||
placeholder={search_placeholder}
|
|
||||||
value={this.state.search} onChange={this.handleSearchChange}
|
|
||||||
onKeyDown={this.handleKeyDown} required autoFocus />
|
|
||||||
}</FormattedMessage>
|
|
||||||
<a href="#" onClick={this.handleClear}>
|
|
||||||
<i className="material-icons">close</i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
// Send message form.
|
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
import { Drafty } from 'tinode-sdk';
|
|
||||||
|
|
||||||
import { KEYPRESS_DELAY, MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE } from '../config.js';
|
|
||||||
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64, imageFileScaledToBase64 } from '../lib/blob-helpers.js';
|
|
||||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
'messaging_disabled': {
|
|
||||||
id: 'messaging_disabled_prompt',
|
|
||||||
defaultMessage: 'Messaging disabled',
|
|
||||||
description: 'Prompt in SendMessage in read-only topic'
|
|
||||||
},
|
|
||||||
'type_new_message': {
|
|
||||||
id: 'new_message_prompt',
|
|
||||||
defaultMessage: 'New message',
|
|
||||||
description: 'Prompt in SendMessage in read-only topic'
|
|
||||||
},
|
|
||||||
'file_attachment_too_large': {
|
|
||||||
id: 'file_attachment_too_large',
|
|
||||||
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
|
|
||||||
description: 'Error message when attachment is too large'
|
|
||||||
},
|
|
||||||
'cannot_initiate_upload': {
|
|
||||||
id: 'cannot_initiate_file_upload',
|
|
||||||
defaultMessage: 'Cannot initiate file upload.',
|
|
||||||
description: 'Generic error messagewhen attachment fails'
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
class SendMessage extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
message: '',
|
|
||||||
// Make initial keypress time as if it happened 5001 milliseconds in the past.
|
|
||||||
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handlePasteEvent = this.handlePasteEvent.bind(this);
|
|
||||||
this.handleAttachImage = this.handleAttachImage.bind(this);
|
|
||||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
|
||||||
this.handleSend = this.handleSend.bind(this);
|
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
|
||||||
this.handleMessageTyping = this.handleMessageTyping.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
this.messageEditArea.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePasteEvent(e) {
|
|
||||||
if (this.props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// FIXME: handle large files too.
|
|
||||||
if (filePasted(e,
|
|
||||||
(bits, mime, width, height, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.insertImage(null,
|
|
||||||
0, mime, bits, width, height, fname));
|
|
||||||
},
|
|
||||||
(mime, bits, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
|
||||||
},
|
|
||||||
this.props.onError)) {
|
|
||||||
|
|
||||||
// If a file was pasted, don't paste base64 data into input field.
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAttachImage(e) {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
// Check if the uploaded file is indeed an image and if it isn't too large.
|
|
||||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
|
||||||
// Convert image for size or format.
|
|
||||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
|
|
||||||
// Success
|
|
||||||
(bits, mime, width, height, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.insertImage(null,
|
|
||||||
0, mime, bits, width, height, fname));
|
|
||||||
},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Image can be uploaded as is. No conversion is needed.
|
|
||||||
imageFileToBase64(file,
|
|
||||||
// Success
|
|
||||||
(bits, mime, width, height, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.insertImage(null,
|
|
||||||
0, mime, bits, width, height, fname));
|
|
||||||
},
|
|
||||||
// Failure
|
|
||||||
(err) => {
|
|
||||||
this.props.onError(err, 'err');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear the value so the same file can be uploaded again.
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAttachFile(e) {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
|
|
||||||
// Too large.
|
|
||||||
this.props.onError(formatMessage(messages.file_attachment_too_large,
|
|
||||||
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
|
|
||||||
} else if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
|
|
||||||
// Too large to send inband - uploading out of band and sending as a link.
|
|
||||||
const uploader = this.props.tinode.getLargeFileHelper();
|
|
||||||
if (!uploader) {
|
|
||||||
this.props.onError(formatMessage(messages.cannot_initiate_upload));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Format data and initiate upload.
|
|
||||||
const uploadCompletionPromise = uploader.upload(file);
|
|
||||||
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
|
|
||||||
// Pass data and the uploader to the TinodeWeb.
|
|
||||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
|
||||||
} else {
|
|
||||||
// Small enough to send inband.
|
|
||||||
fileToBase64(file,
|
|
||||||
(mime, bits, fname) => {
|
|
||||||
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
|
|
||||||
},
|
|
||||||
this.props.onError
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear the value so the same file can be uploaded again.
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSend(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const message = this.state.message.trim();
|
|
||||||
if (message) {
|
|
||||||
this.props.sendMessage(this.state.message.trim());
|
|
||||||
this.setState({message: ''});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Send on Enter key */
|
|
||||||
handleKeyPress(e) {
|
|
||||||
// Remove this if you don't want Enter to trigger send
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
// Have Shift-Enter insert a line break instead
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
this.handleSend(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessageTyping(e) {
|
|
||||||
const newState = {message: e.target.value};
|
|
||||||
const now = new Date().getTime();
|
|
||||||
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
|
||||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
|
||||||
if (topic.isSubscribed()) {
|
|
||||||
topic.noteKeyPress();
|
|
||||||
}
|
|
||||||
newState.keypressTimestamp = now;
|
|
||||||
}
|
|
||||||
this.setState(newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {formatMessage} = this.props.intl;
|
|
||||||
const prompt = this.props.disabled ?
|
|
||||||
formatMessage(messages.messaging_disabled) :
|
|
||||||
formatMessage(messages.type_new_message);
|
|
||||||
return (
|
|
||||||
<div id="send-message-panel">
|
|
||||||
{this.props.disabled ?
|
|
||||||
<i className="material-icons disabled">photo</i> :
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
|
|
||||||
<i className="material-icons secondary">photo</i>
|
|
||||||
</a>}
|
|
||||||
{this.props.disabled ?
|
|
||||||
<i className="material-icons disabled">attach_file</i> :
|
|
||||||
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
|
|
||||||
<i className="material-icons secondary">attach_file</i>
|
|
||||||
</a>}
|
|
||||||
<textarea id="sendMessage" placeholder={prompt}
|
|
||||||
disabled={this.props.disabled} value={this.state.message}
|
|
||||||
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
|
|
||||||
ref={(ref) => {this.messageEditArea = ref;}}
|
|
||||||
autoFocus />
|
|
||||||
{this.props.disabled ?
|
|
||||||
<i className="material-icons disabled">send</i> :
|
|
||||||
<a href="#" onClick={this.handleSend} title="Send">
|
|
||||||
<i className="material-icons">send</i>
|
|
||||||
</a>}
|
|
||||||
<input type="file" ref={(ref) => {this.attachFile = ref;}}
|
|
||||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
|
||||||
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
|
|
||||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(SendMessage);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import LetterTile from './letter-tile.jsx';
|
|
||||||
import MenuCancel from './menu-cancel.jsx';
|
|
||||||
import MenuContacts from './menu-contacts.jsx';
|
|
||||||
import MenuStart from './menu-start.jsx';
|
|
||||||
|
|
||||||
export default class SideNavbar extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div id="side-caption-panel" className="caption-panel">
|
|
||||||
<div id="self-avatar" className="avatar-box">
|
|
||||||
<LetterTile
|
|
||||||
avatar={this.props.avatar}
|
|
||||||
topic={this.props.myUserId}
|
|
||||||
title={this.props.title} />
|
|
||||||
</div>
|
|
||||||
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
|
|
||||||
{this.props.state === 'login' ?
|
|
||||||
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
|
||||||
this.props.state === 'contacts' ?
|
|
||||||
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
|
||||||
null}
|
|
||||||
{this.props.onCancel ?
|
|
||||||
<MenuCancel onCancel={this.props.onCancel} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
// TagManager: edit topic or user tags.
|
|
||||||
import React from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ChipInput from './chip-input.jsx';
|
|
||||||
|
|
||||||
import { MIN_TAG_LENGTH } from '../config.js';
|
|
||||||
import { arrayEqual } from '../lib/utils.js';
|
|
||||||
|
|
||||||
export default class TagManager extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
tags: this.props.tags,
|
|
||||||
tagInput: '',
|
|
||||||
activated: this.props.activated
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleTagInput = this.handleTagInput.bind(this);
|
|
||||||
this.handleAddTag = this.handleAddTag.bind(this);
|
|
||||||
this.handleRemoveTag = this.handleRemoveTag.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
|
||||||
this.handleCancel = this.handleCancel.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromProps(nextProps, prevState) {
|
|
||||||
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
|
|
||||||
return {tags: nextProps.tags};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTagInput(text) {
|
|
||||||
this.setState({tagInput: text});
|
|
||||||
if (text.length > 0) {
|
|
||||||
const last = text[text.length-1];
|
|
||||||
if (text[0] == '"') {
|
|
||||||
// This is a quoted string.
|
|
||||||
if (text.length > 1 && last == '"') {
|
|
||||||
this.handleAddTag(text.substring(1, text.length-1));
|
|
||||||
}
|
|
||||||
} else if (last == ',' || last == ' ' || last == ';' || last == '"') {
|
|
||||||
// User entered ',', ' ' or ';'
|
|
||||||
this.handleAddTag(text.substring(0, text.length-1).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAddTag(tag) {
|
|
||||||
if (tag.length > 0) {
|
|
||||||
const tags = this.state.tags.slice(0);
|
|
||||||
tags.push(tag);
|
|
||||||
|
|
||||||
this.setState({tags: tags, tagInput: ''});
|
|
||||||
if (this.props.onTagsChanged) {
|
|
||||||
this.props.onTagsChanged(tags);
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
return this.state.tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRemoveTag(tag, index) {
|
|
||||||
const tags = this.state.tags.slice(0);
|
|
||||||
tags.splice(index, 1);
|
|
||||||
this.setState({tags: tags});
|
|
||||||
if (this.props.onTagsChanged) {
|
|
||||||
this.props.onTagsChanged(tags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit() {
|
|
||||||
// Add unprocessed input to tags, submit the list.
|
|
||||||
this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim()));
|
|
||||||
this.setState({activated: false, tags: this.props.tags});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel() {
|
|
||||||
this.setState({activated: false, tagInput: '', tags: this.props.tags});
|
|
||||||
if (this.props.onCancel) {
|
|
||||||
this.props.onCancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let tags = [];
|
|
||||||
if (this.state.activated) {
|
|
||||||
this.state.tags.map((tag) => {
|
|
||||||
tags.push({user: tag, invalid: (tag.length < MIN_TAG_LENGTH)});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.state.tags.map((tag) => {
|
|
||||||
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
|
|
||||||
});
|
|
||||||
if (tags.length == 0) {
|
|
||||||
tags = (
|
|
||||||
<i>
|
|
||||||
<FormattedMessage id="tags_not_found" defaultMessage="No tags defined. Add some." description="" />
|
|
||||||
</i>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="panel-form-column">
|
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small">{this.props.title}</label>
|
|
||||||
</div>
|
|
||||||
{this.state.activated ?
|
|
||||||
<div>
|
|
||||||
<FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags"
|
|
||||||
description="Tag editor prompt when no tags are found.">{
|
|
||||||
(add_tags_prompt) => <ChipInput
|
|
||||||
chips={tags}
|
|
||||||
avatarDisabled={true}
|
|
||||||
prompt={add_tags_prompt}
|
|
||||||
onEnter={this.handleAddTag}
|
|
||||||
onFocusLost={this.handleAddTag}
|
|
||||||
onCancel={this.handleCancel}
|
|
||||||
onChipRemoved={this.handleRemoveTag}
|
|
||||||
filterFunc={this.handleTagInput} />
|
|
||||||
}</FormattedMessage>
|
|
||||||
{this.props.onSubmit || this.props.onCancel ?
|
|
||||||
<div id="tag-manager-buttons" className="dialog-buttons panel-form-row">
|
|
||||||
<button className="outline" onClick={this.handleCancel}>
|
|
||||||
<FormattedMessage id="button_cancel" defautMessage="Cancel" description="Rejection button [Cancel]" />
|
|
||||||
</button>
|
|
||||||
<button className="blue" onClick={this.handleSubmit}>
|
|
||||||
<FormattedMessage id="button_ok" defautMessage="OK" description="Confirmation button [OK]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
<div className="quoted">
|
|
||||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.setState({activated: true});}}>
|
|
||||||
<i className="material-icons">edit</i> <FormattedMessage id="title_manage_tags" defaultMessage="Manage"
|
|
||||||
description="Section title for the list of tags" />
|
|
||||||
</a>
|
|
||||||
<>{tags}</>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// The counter of unread messages in the topic.
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class UnreadBadge extends React.PureComponent {
|
|
||||||
render() {
|
|
||||||
return (this.props.count > 0 ?
|
|
||||||
<span className="unread">{this.props.count > 9 ? "9+" : this.props.count}</span>
|
|
||||||
: null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// Password with a visiblity toggle.
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class VisiblePassword extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
value: this.props.value,
|
|
||||||
visible: false
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleVisibility = this.handleVisibility.bind(this);
|
|
||||||
this.handeTextChange = this.handeTextChange.bind(this);
|
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
||||||
this.handleEditingFinished = this.handleEditingFinished.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handeTextChange(e) {
|
|
||||||
this.setState({value: e.target.value});
|
|
||||||
if (this.props.onChange) {
|
|
||||||
this.props.onChange(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleVisibility(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.setState({visible: !this.state.visible});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown(e) {
|
|
||||||
if (e.keyCode == 27) {
|
|
||||||
// Escape pressed
|
|
||||||
this.setState({value: this.props.value, visible: false});
|
|
||||||
if (this.props.onFinished) {
|
|
||||||
this.props.onFinished();
|
|
||||||
}
|
|
||||||
} else if (e.keyCode == 13) {
|
|
||||||
// Enter pressed
|
|
||||||
this.handleEditingFinished();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEditingFinished(e) {
|
|
||||||
if (e) {
|
|
||||||
let currentTarget = e.currentTarget;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!currentTarget.contains(document.activeElement)) {
|
|
||||||
if (this.props.onFinished) {
|
|
||||||
this.props.onFinished(this.state.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
} else if (this.props.onFinished) {
|
|
||||||
this.props.onFinished(this.state.value.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div tabIndex="-1" className="group-focus"
|
|
||||||
onBlur={this.handleEditingFinished}>
|
|
||||||
<input className="with-visibility"
|
|
||||||
type={this.state.visible ? "text" : "password"}
|
|
||||||
value={this.state.value}
|
|
||||||
placeholder={this.props.placeholder}
|
|
||||||
required={this.props.required ? 'required' : ''}
|
|
||||||
autoFocus={this.props.autoFocus ? 'autoFocus' : ''}
|
|
||||||
autoComplete={this.props.autoComplete}
|
|
||||||
onChange={this.handeTextChange}
|
|
||||||
onKeyDown={this.handleKeyDown} />
|
|
||||||
<span onClick={this.handleVisibility}>
|
|
||||||
<i className="material-icons clickable light-gray">
|
|
||||||
{this.state.visible ? 'visibility' : 'visibility_off'}
|
|
||||||
</i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 621 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 343 KiB |