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,5 +0,0 @@
|
||||
index.dev.js binary
|
||||
index.dev.js.map binary
|
||||
index.prod.js binary
|
||||
index.prod.js.map binary
|
||||
base.min.css 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.tindroid",
|
||||
"sha256_cert_fingerprints":
|
||||
["AA:0A:FF:C4:F3:1F:58:83:F3:8E:69:03:2E:B9:AA:1A:C4:62:65:F1:1E:50:F9:7E:AD:35:56:55:32:B7:1A:C6"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"relation": ["delegate_permission/common.get_login_creds"],
|
||||
"target": {
|
||||
"namespace": "web",
|
||||
"site": "https://api.tinode.co"
|
||||
}
|
||||
},
|
||||
{
|
||||
"relation": ["delegate_permission/common.get_login_creds"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "co.tinode.tindroid",
|
||||
"sha256_cert_fingerprints":
|
||||
["AA:0A:FF:C4:F3:1F:58:83:F3:8E:69:03:2E:B9:AA:1A:C4:62:65:F1:1E:50:F9:7E:AD:35:56:55:32:B7:1A:C6"]
|
||||
}
|
||||
}]
|
||||
@@ -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,49 +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://api.tinode.co/. For passwords for demo accounts and other instructions [see here](https://github.com/tinode/chat/#demo).
|
||||
|
||||
Demo avatar photos are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
|
||||
|
||||
Background pattern from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
|
||||
|
||||
## 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/yahoo/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://api.tinode.co/#?hl=ru.
|
||||
|
||||
As of the time of this writing there are only two translations. More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json) and [Russian](/src/i18n/ru.json) translations for guidance.
|
||||
|
||||
|
||||
## Missing functionality
|
||||
|
||||
* Previews not generated for videos, audio, links or docs.
|
||||
* No end-to-end encryption.
|
||||
* Emoji support is weak.
|
||||
|
||||
## 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,305 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="192"
|
||||
height="192"
|
||||
viewBox="0 0 192 192"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="0.91 r13725"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:export-xdpi="120"
|
||||
inkscape:export-ydpi="120">
|
||||
<defs
|
||||
id="defs4">
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient16239">
|
||||
<stop
|
||||
style="stop-color:#000000;stop-opacity:0.25098041"
|
||||
offset="0"
|
||||
id="stop16241" />
|
||||
<stop
|
||||
style="stop-color:#ffffff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop16243" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient16239"
|
||||
id="linearGradient16249"
|
||||
x1="102.81445"
|
||||
y1="97.075966"
|
||||
x2="219.01871"
|
||||
y2="170.4807"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.293059,0,0,1.0059594,-43.958501,0.80696392)" />
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter16588">
|
||||
<feFlood
|
||||
flood-opacity="0.25098"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood16590" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite16592" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0"
|
||||
result="blur"
|
||||
id="feGaussianBlur16594" />
|
||||
<feOffset
|
||||
dx="-0"
|
||||
dy="-1"
|
||||
result="offset"
|
||||
id="feOffset16596" />
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="fbSourceGraphic"
|
||||
id="feComposite16598" />
|
||||
<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"
|
||||
id="feColorMatrix4300" />
|
||||
<feFlood
|
||||
id="feFlood4302"
|
||||
flood-opacity="0.25098"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
in="fbSourceGraphic" />
|
||||
<feComposite
|
||||
id="feComposite4304"
|
||||
in2="fbSourceGraphic"
|
||||
in="flood"
|
||||
operator="in"
|
||||
result="composite1" />
|
||||
<feGaussianBlur
|
||||
id="feGaussianBlur4306"
|
||||
in="composite1"
|
||||
stdDeviation="0"
|
||||
result="blur" />
|
||||
<feOffset
|
||||
id="feOffset4308"
|
||||
dx="0"
|
||||
dy="-3"
|
||||
result="offset" />
|
||||
<feComposite
|
||||
id="feComposite4310"
|
||||
in2="offset"
|
||||
in="fbSourceGraphic"
|
||||
operator="over"
|
||||
result="composite2" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4539">
|
||||
<feFlood
|
||||
flood-opacity="0.196078"
|
||||
flood-color="rgb(255,255,255)"
|
||||
result="flood"
|
||||
id="feFlood4541" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="out"
|
||||
result="composite1"
|
||||
id="feComposite4543" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0"
|
||||
result="blur"
|
||||
id="feGaussianBlur4545" />
|
||||
<feOffset
|
||||
dx="0"
|
||||
dy="2"
|
||||
result="offset"
|
||||
id="feOffset4547" />
|
||||
<feComposite
|
||||
in="offset"
|
||||
in2="SourceGraphic"
|
||||
operator="atop"
|
||||
result="composite2"
|
||||
id="feComposite4549" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4779">
|
||||
<feFlood
|
||||
flood-opacity="0.196078"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood4781" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="out"
|
||||
result="composite1"
|
||||
id="feComposite4783" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0"
|
||||
result="blur"
|
||||
id="feGaussianBlur4785" />
|
||||
<feOffset
|
||||
dx="0"
|
||||
dy="-2"
|
||||
result="offset"
|
||||
id="feOffset4787" />
|
||||
<feComposite
|
||||
in="offset"
|
||||
in2="SourceGraphic"
|
||||
operator="atop"
|
||||
result="composite2"
|
||||
id="feComposite4789" />
|
||||
</filter>
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB;"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter4791">
|
||||
<feFlood
|
||||
flood-opacity="0.196078"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood4793" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="out"
|
||||
result="composite1"
|
||||
id="feComposite4795" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="0"
|
||||
result="blur"
|
||||
id="feGaussianBlur4797" />
|
||||
<feOffset
|
||||
dx="0"
|
||||
dy="-2"
|
||||
result="offset"
|
||||
id="feOffset4799" />
|
||||
<feComposite
|
||||
in="offset"
|
||||
in2="SourceGraphic"
|
||||
operator="atop"
|
||||
result="composite2"
|
||||
id="feComposite4801" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4.1822917"
|
||||
inkscape:cx="39.691158"
|
||||
inkscape:cy="95.079953"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer6"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1680"
|
||||
inkscape:window-height="1005"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="1"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
style="display:inline"
|
||||
inkscape:label="Colors copy"
|
||||
id="g16151"
|
||||
inkscape:groupmode="layer">
|
||||
<path
|
||||
style="opacity:1;fill:#fc5d3e;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4779)"
|
||||
d="M 14.101562,64 A 87.999993,87.924608 0 0 0 8,95.923828 C 7.98792,140.37931 26.484895,166.06204 9.8632812,175.74609 -2.8697567,183.16463 53.015901,184.00441 80,183.99414 L 80,64 14.101562,64 Z"
|
||||
id="rect16153"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
style="opacity:1;fill:#45ae49;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4791)"
|
||||
d="m 80,64 0,119.99414 c 8.240743,-0.003 14.054688,-0.0801 14.054688,-0.0801 L 96,183.84961 A 87.999993,87.924608 0 0 0 184,95.923828 87.999993,87.924608 0 0 0 177.99023,64 L 80,64 Z"
|
||||
id="rect16157"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
style="opacity:1;fill:#2d526b;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4539)"
|
||||
d="M 96,8 A 87.999993,87.924608 0 0 0 9.5195312,80 L 182.49805,80 A 87.999993,87.924608 0 0 0 96,8 Z"
|
||||
id="rect16159"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Letter t"
|
||||
id="g16167"
|
||||
inkscape:groupmode="layer"
|
||||
style="display:inline">
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer6"
|
||||
inkscape:label="Shadow"
|
||||
style="display:inline;opacity:0.5">
|
||||
<path
|
||||
style="opacity:1;fill:url(#linearGradient16249);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
d="M 88.001953,40.974609 79.402344,153.7832 119.19336,180.72266 c 34.16846,-9.36841 59.38095,-38.29738 63.9707,-73.40039 z"
|
||||
id="rect16225"
|
||||
inkscape:connector-curvature="0"
|
||||
sodipodi:nodetypes="ccccc" />
|
||||
</g>
|
||||
<text
|
||||
sodipodi:linespacing="125%"
|
||||
id="text16169"
|
||||
y="153.19533"
|
||||
x="53.125"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:160.00001526px;line-height:125%;font-family:Arial;-inkscape-font-specification:'Arial, Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
xml:space="preserve"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:160.00001526px;line-height:125%;font-family:Arial;-inkscape-font-specification:'Arial, Bold';text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1"
|
||||
y="153.19533"
|
||||
x="53.125"
|
||||
id="tspan16171"
|
||||
sodipodi:role="line">t</tspan></text>
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Bubble"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-860.36222)"
|
||||
style="display:none">
|
||||
<path
|
||||
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter16588)"
|
||||
d="M 0,0 0,192 192,192 192,0 0,0 Z m 96,8 a 87.999998,87.924613 0 0 1 88,87.923828 87.999998,87.924613 0 0 1 -88,87.925782 l -1.945312,0.0644 c 0,0 -100.813021,1.51613 -84.1914068,-8.16797 C 26.484896,166.06199 7.98792,140.37931 8,95.923828 A 87.999998,87.924613 0 0 1 96,8 Z"
|
||||
transform="translate(0,860.36222)"
|
||||
id="rect16110"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-xdpi="120.00001"
|
||||
inkscape:export-ydpi="120.00001" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 10 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 |
@@ -1,48 +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="TinodeWeb">
|
||||
<meta name="description" content="Tinode instant messenger">
|
||||
<meta name="application-url" content="https://api.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">
|
||||
|
||||
<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://api.tinode.co/" />
|
||||
<!-- Google's Roboto font -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700" />
|
||||
<!-- 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 scripts -->
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react@16/umd/react.development.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
|
||||
<!-- React-Intl -->
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15/prop-types.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react-intl@2/dist/react-intl.js"></script>
|
||||
<!-- Firebase scripts -->
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-messaging.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript><div id="noscript-alertbox"><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/index.dev.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +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="TinodeWeb">
|
||||
<meta name="description" content="Tinode instant messaging">
|
||||
<meta name="application-url" content="https://api.tinode.co/">
|
||||
<meta name="theme-color" content="#3949AB">
|
||||
<meta name="google" value="notranslate">
|
||||
<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://api.tinode.co/" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700" />
|
||||
<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://unpkg.com/react@16.8.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.1/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15.6.2/prop-types.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/react-intl@2.8.0/dist/react-intl.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-messaging.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript-alertbox"><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/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,86 +0,0 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.15.13-rc3",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.2.2",
|
||||
"@babel/preset-env": "^7.3.1",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"babel-loader": "^8.0.5",
|
||||
"babel-plugin-react-intl": "^3.0.1",
|
||||
"browserslist": "^4.4.1",
|
||||
"cssnano": "^4.1.8",
|
||||
"postcss": "^7.0.6",
|
||||
"uglifyjs-webpack-plugin": "^2.1.1",
|
||||
"webpack": "^4.29.3",
|
||||
"webpack-cli": "^3.2.3"
|
||||
},
|
||||
"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 --output ./umd/index.dev.js",
|
||||
"build:prod": "webpack --mode production --output ./umd/index.prod.js",
|
||||
"build:i18n": "node scripts/flatten-messages.js",
|
||||
"vers": "node scripts/gen-version.js"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"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": "^5.8.1",
|
||||
"react": "^16.8.1",
|
||||
"react-dom": "^16.8.1",
|
||||
"react-intl": "^2.8.0",
|
||||
"tinode-sdk": "^0.15.12"
|
||||
}
|
||||
}
|
||||
@@ -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://unpkg.com/firebase@5.8.1/firebase-app.js');
|
||||
importScripts('https://unpkg.com/firebase@5.8.1/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,55 +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.15');
|
||||
|
||||
// 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: 'api.tinode.co', local: 'localhost:6060'};
|
||||
|
||||
// Default host name and port to connect to.
|
||||
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
@@ -1,133 +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",
|
||||
"label_your_name": "Your name",
|
||||
"label_password": "Password",
|
||||
"password_unchanged_prompt": "Unchanged",
|
||||
"label_user_id": "Address:",
|
||||
"label_default_access_mode": "Default access mode:",
|
||||
"label_message_sound": "Message sound:",
|
||||
"label_push_notifications": "Notification alerts:",
|
||||
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
|
||||
"title_tag_manager": "Tags (user discovery)",
|
||||
"button_logout": "Logout",
|
||||
"requested_permissions": "Requested",
|
||||
"granted_permissions": "Granted",
|
||||
"menu_item_edit_permissions": "Edit permissions",
|
||||
"label_other_user": "Other",
|
||||
"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",
|
||||
"button_leave": "Leave",
|
||||
"group_has_no_members": "No members",
|
||||
"action_leave_chat": "Leave",
|
||||
"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",
|
||||
"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}s…",
|
||||
"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",
|
||||
"menu_item_unblock": "Unblock",
|
||||
"menu_item_block": "Block",
|
||||
"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",
|
||||
"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 tags"
|
||||
}
|
||||
@@ -1,133 +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": "Добавить",
|
||||
"button_leave": "Отписаться",
|
||||
"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": "Заблокировать"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Put all packages together.
|
||||
// Used to generate umd/index.prod.js
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import ruData from 'react-intl/locale-data/ru';
|
||||
addLocaleData(ruData);
|
||||
import allMessages from './messages.json';
|
||||
|
||||
import TinodeWeb from './views/tinode-web.jsx';
|
||||
|
||||
import HashNavigation from './lib/navigation.js';
|
||||
|
||||
const {params} = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const language = (params && params.hl) ||
|
||||
(navigator.languages && navigator.languages[0]) ||
|
||||
navigator.language ||
|
||||
navigator.userLanguage;
|
||||
|
||||
const baseLanguage = language.toLowerCase().split(/[-_]+/)[0];
|
||||
|
||||
// Try full locale, try locale without 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,40 +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 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,63 +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 = {
|
||||
data: imageDataUrl.substring(dataStart+1),
|
||||
type: 'jpg'
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,268 +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",
|
||||
"label_your_name": "Your name",
|
||||
"label_password": "Password",
|
||||
"password_unchanged_prompt": "Unchanged",
|
||||
"label_user_id": "Address:",
|
||||
"label_default_access_mode": "Default access mode:",
|
||||
"label_message_sound": "Message sound:",
|
||||
"label_push_notifications": "Notification alerts:",
|
||||
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
|
||||
"title_tag_manager": "Tags (user discovery)",
|
||||
"button_logout": "Logout",
|
||||
"requested_permissions": "Requested",
|
||||
"granted_permissions": "Granted",
|
||||
"menu_item_edit_permissions": "Edit permissions",
|
||||
"label_other_user": "Other",
|
||||
"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",
|
||||
"button_leave": "Leave",
|
||||
"group_has_no_members": "No members",
|
||||
"action_leave_chat": "Leave",
|
||||
"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",
|
||||
"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}s…",
|
||||
"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",
|
||||
"menu_item_unblock": "Unblock",
|
||||
"menu_item_block": "Block",
|
||||
"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",
|
||||
"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 tags"
|
||||
},
|
||||
"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": "Добавить",
|
||||
"button_leave": "Отписаться",
|
||||
"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": "Заблокировать"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.15.13-rc3";
|
||||
@@ -1,85 +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.getDerivedStateFromProps(props, {});
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
const contacts = [];
|
||||
let unreadThreads = 0;
|
||||
let archivedCount = 0;
|
||||
nextProps.chatList.map((c) => {
|
||||
if (c.private && c.private.arch) {
|
||||
if (nextProps.archive) {
|
||||
contacts.push(c);
|
||||
} else {
|
||||
archivedCount ++;
|
||||
}
|
||||
} else if (!nextProps.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}
|
||||
});
|
||||
}
|
||||
|
||||
updateFavicon(unreadThreads);
|
||||
|
||||
return {
|
||||
contactList: contacts,
|
||||
archivedCount: archivedCount
|
||||
};
|
||||
}
|
||||
|
||||
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,125 +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';
|
||||
|
||||
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();
|
||||
// TODO: check email for validity
|
||||
this.setState({errorCleared: false});
|
||||
this.props.onCreateAccount(
|
||||
this.state.login.trim(),
|
||||
this.state.password.trim(),
|
||||
vcard(this.state.fn, 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,228 +0,0 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
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 { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { vcard } from '../lib/utils.js';
|
||||
|
||||
export default class EditAccountView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
let me = this.props.tinode.getMeTopic();
|
||||
let defacs = me.getDefaultAccess();
|
||||
let fnd = this.props.tinode.getFndTopic();
|
||||
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: fnd.tags(),
|
||||
showPermissionEditorFor: undefined,
|
||||
previousOnTags: fnd.onTagsUpdated
|
||||
};
|
||||
|
||||
this.tnNewTags = this.tnNewTags.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.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let fnd = this.props.tinode.getFndTopic();
|
||||
fnd.onTagsUpdated = this.tnNewTags;
|
||||
if (!fnd.isSubscribed()) {
|
||||
fnd.subscribe(fnd.startMetaQuery().withLaterDesc().withTags().build()).catch((err) => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
var fnd = this.props.tinode.getFndTopic();
|
||||
fnd.onTagsUpdated = this.state.previousOnTags;
|
||||
}
|
||||
|
||||
tnNewTags(tags) {
|
||||
this.setState({tags: tags});
|
||||
}
|
||||
|
||||
handleFullNameUpdate(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));
|
||||
}
|
||||
|
||||
handleCheckboxClick(what, checked) {
|
||||
if (what == 'sound') {
|
||||
this.props.onToggleMessageSounds(checked);
|
||||
} else if (what == 'alert') {
|
||||
this.props.onTogglePushNotifications(checked);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
var tags = [];
|
||||
this.state.tags.map(function(tag) {
|
||||
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
|
||||
});
|
||||
if (tags.length == 0) {
|
||||
tags = <i><FormattedMessage id="tags_not_found" /></i>;
|
||||
}
|
||||
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-column">
|
||||
<div className="panel-form-row">
|
||||
<label><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-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 (disabled)" />
|
||||
}
|
||||
</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">
|
||||
<a href="javascript:;" className="red flat-button" onClick={this.props.onLogout}>
|
||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
||||
defaultMessage="Logout" description="Button [Logout]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,635 +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 { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { 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'
|
||||
}
|
||||
});
|
||||
|
||||
class InfoView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
topic: null,
|
||||
owner: false,
|
||||
admin: false,
|
||||
sharer: 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.handleLeave = this.handleLeave.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 {
|
||||
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(),
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
onTagsUpdated(tags) {
|
||||
this.setState({tags: tags});
|
||||
|
||||
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
|
||||
this.previousTagsUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
handleFullNameUpdate(fn) {
|
||||
fn = fn.trim();
|
||||
if (this.state.fullName !== fn) {
|
||||
this.setState({fullName: fn});
|
||||
this.props.onTopicDescUpdate(this.props.topic, vcard(fn, null), null);
|
||||
}
|
||||
}
|
||||
|
||||
handlePrivateUpdate(priv) {
|
||||
if (this.state.priv !== priv) {
|
||||
this.setState({private: priv});
|
||||
this.props.onTopicDescUpdate(this.props.topic, null, priv || Tinode.DEL_CHAR);
|
||||
}
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
this.setState({avatar: img});
|
||||
this.props.onTopicDescUpdate(this.props.topic, vcard(null, img), 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() {
|
||||
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});
|
||||
}
|
||||
|
||||
handleLeave() {
|
||||
this.props.onLeaveTopic(this.props.topic);
|
||||
}
|
||||
|
||||
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}
|
||||
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>
|
||||
:
|
||||
null
|
||||
}
|
||||
{this.state.owner ? <div className="hr" /> : null }
|
||||
{this.state.groupTopic ?
|
||||
<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="javascript:;" 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}
|
||||
{!this.state.owner ?
|
||||
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
|
||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_leave"
|
||||
defaultMessage="Leave" description="Flat button [Leave] (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>
|
||||
:
|
||||
<div className="panel-form-row">
|
||||
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
|
||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="action_leave_chat"
|
||||
defaultMessage="Leave" description="Action [Leave] chat" />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(InfoView);
|
||||
@@ -1,96 +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"
|
||||
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,606 +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 { 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) {
|
||||
const ex = acs ? acs.getExcessive() || '' : '';
|
||||
return (ex.includes('R') || ex.includes('W'));
|
||||
}
|
||||
|
||||
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.handleBackNavigation = this.handleBackNavigation.bind(this);
|
||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// this.propsChange(this.props, this.state);
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.leave(this.state.topic);
|
||||
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) {
|
||||
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() &&
|
||||
((!prevProps.connected && this.props.connected) || (this.state.topic != prevState.topic))) {
|
||||
// Don't request the tags. They are useless unless the user
|
||||
// is the owner and is editing the topic.
|
||||
const getQuery = topic.startMetaQuery()
|
||||
.withLaterDesc()
|
||||
.withLaterSub()
|
||||
.withLaterData(MESSAGES_PAGE)
|
||||
.withLaterDel();
|
||||
const setQuery = Tinode.isNewGroupTopicName(this.props.topic) ?
|
||||
this.props.newGroupTopicParams : undefined;
|
||||
// Show "loading" spinner.
|
||||
this.setState({ fetchingMessages: true });
|
||||
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
|
||||
};
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
nextState = Object.assign(nextState, {
|
||||
messages: msgs,
|
||||
onlineSubs: subs
|
||||
});
|
||||
|
||||
if (topic.public) {
|
||||
nextState = Object.assign(nextState, {
|
||||
title: topic.public.fn,
|
||||
avatar: makeImageUrl(topic.public.photo)
|
||||
});
|
||||
} else {
|
||||
nextState = Object.assign(nextState, {
|
||||
title: '',
|
||||
avatar: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Invalid topic.
|
||||
nextState = Object.assign(nextState, {
|
||||
messages: [],
|
||||
onlineSubs: [],
|
||||
title: '',
|
||||
avatar: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let val = nextProps.acs ? !nextProps.acs.isWriter() : true;
|
||||
if (val != prevState.readOnly) {
|
||||
nextState.readOnly = val;
|
||||
}
|
||||
val = nextProps.acs ? !nextProps.acs.isReader() : true;
|
||||
if (val != prevState.writeOnly) {
|
||||
nextState.writeOnly = val;
|
||||
}
|
||||
val = isUnconfirmed(nextProps.acs);
|
||||
if (val != prevState.unconformed) {
|
||||
nextState.unconfirmed = val;
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
let newState = {};
|
||||
if (!prevState.fetchingMessages) {
|
||||
let 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({
|
||||
readOnly: !desc.acs.isWriter(),
|
||||
writeOnly: !desc.acs.isReader(),
|
||||
unconfirmed: isUnconfirmed(desc.acs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleSubsUpdated() {
|
||||
console.log("handleSubsUpdated");
|
||||
|
||||
if (this.state.topic) {
|
||||
var subs = [];
|
||||
var topic = this.props.tinode.getTopic(this.state.topic);
|
||||
topic.subscribers((sub) => {
|
||||
if (sub.online && sub.user != this.props.myUserId) {
|
||||
subs.push(sub);
|
||||
}
|
||||
});
|
||||
this.setState({onlineSubs: subs});
|
||||
}
|
||||
}
|
||||
|
||||
handleNewMessage(msg) {
|
||||
// Regenerate messages list
|
||||
var topic = this.props.tinode.getTopic(this.state.topic);
|
||||
var newState = {messages: []};
|
||||
topic.messages(function(m) {
|
||||
if (!m.deleted) {
|
||||
newState.messages = newState.messages.concat(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 all messages, including own messages.
|
||||
let status = topic.msgStatus(msg);
|
||||
if (status >= Tinode.MESSAGE_STATUS_SENT) {
|
||||
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);
|
||||
let 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);
|
||||
}
|
||||
|
||||
handleBackNavigation() {
|
||||
this.props.onHideMessagesView();
|
||||
}
|
||||
|
||||
handleNewChatAcceptance(action) {
|
||||
this.props.onNewChat(this.state.topic, action);
|
||||
}
|
||||
|
||||
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;
|
||||
let 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
|
||||
}
|
||||
}
|
||||
let avatar = this.state.avatar || true;
|
||||
let 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="javascript:;" id="hide-message-view" onClick={this.handleBackNavigation}>
|
||||
<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="javascript:;" 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>Typing notifications here.</div>
|
||||
</div>
|
||||
{this.state.writeOnly ?
|
||||
<div id="write-only-background">
|
||||
<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>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
{this.state.unconfirmed ?
|
||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||
:
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topic={this.props.topic}
|
||||
disabled={this.state.readOnly}
|
||||
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="javascript:;" 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="javascript:;" 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="javascript:;" 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>
|
||||
:
|
||||
<React.Fragment>
|
||||
<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>
|
||||
</React.Fragment>
|
||||
}
|
||||
<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,180 +0,0 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import SideNavbar from '../widgets/side-navbar.jsx';
|
||||
import ErrorPanel from '../widgets/error-panel.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} />
|
||||
|
||||
{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}
|
||||
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,81 +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);
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
this.setState({code: e.target.value});
|
||||
}
|
||||
|
||||
handleKeyPress(e) {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleSubmit(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.code && this.state.code.trim()) {
|
||||
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(ValidationView);
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FileProgress from './file-progress.jsx';
|
||||
import { bytesToHumanSize } from '../lib/strformat.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 &&
|
||||
!(/^(?:(?:[a-z]+:)?\/\/)/i.test(this.props.downloadUrl))) {
|
||||
// Relative URL. Use download helper.
|
||||
url = "javascript:;";
|
||||
helperFunc = (e) => {
|
||||
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
|
||||
};
|
||||
} else {
|
||||
url = this.props.downloadUrl;
|
||||
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,81 +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);
|
||||
this.handleClear = this.handleClear.bind(this);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (prevState.dataUrl != nextProps.avatar) {
|
||||
return {
|
||||
dataUrl: nextProps.avatar
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
this.props.onImageChanged(null);
|
||||
}
|
||||
|
||||
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="javascript:;" className="clear-avatar" onClick={this.handleClear}>
|
||||
<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,198 +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'
|
||||
|
||||
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 = '' + e.target.dataset.ref;
|
||||
}
|
||||
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 = <span><i className="material-icons">error_outline</i> <i>
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when message is unreadable" />
|
||||
</i></span>
|
||||
}
|
||||
|
||||
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="javascript:;" 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' };
|
||||
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,141 +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.getDerivedStateFromProps(props);
|
||||
|
||||
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 getDerivedStateFromProps(nextProps, prevState) {
|
||||
const state = {
|
||||
placeholder: nextProps.chips ? '' : nextProps.prompt,
|
||||
sortedChips: ChipInput.sortChips(nextProps.chips, nextProps.required),
|
||||
chipIndex: ChipInput.indexChips(nextProps.chips),
|
||||
focused: prevState && prevState.focused
|
||||
};
|
||||
|
||||
if (!prevState || nextProps.chips.length > prevState.sortedChips.length) {
|
||||
// Chip added: clear input.
|
||||
state.input ='';
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// Map chip index to user name
|
||||
static indexChips(chips) {
|
||||
const index = {};
|
||||
let count = 0;
|
||||
chips.map(function(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(function(item) {
|
||||
if (item.user === keep) {
|
||||
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) {
|
||||
var at = this.state.sortedChips.length - 1;
|
||||
if (this.state.sortedChips[at].user !== this.props.required) {
|
||||
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;
|
||||
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={item.user === this.props.required}
|
||||
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,36 +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() {
|
||||
this.props.onCancel(this.props.topic, this.props.index);
|
||||
}
|
||||
|
||||
render() {
|
||||
const title = this.props.title || this.props.topic;
|
||||
return (
|
||||
<div className="chip">
|
||||
{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="javascript:;" 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,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Contact's labels: [you], [muted], [blocked], etc */
|
||||
// FIXME: this class is unused.
|
||||
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) {
|
||||
let style = 'badge' + (b.color ? ' ' + b.color : '');
|
||||
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
|
||||
});
|
||||
}
|
||||
return <span>{badges}</span>;
|
||||
}
|
||||
};
|
||||
@@ -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,75 +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() : [];
|
||||
if (this.props.showMode && this.props.acs) {
|
||||
badges.push({name: this.props.acs.getMode(), key: 'mode'});
|
||||
}
|
||||
|
||||
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>
|
||||
{this.props.unread > 0 ? <UnreadBadge count={this.props.unread} /> : null}
|
||||
</div>
|
||||
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
|
||||
<ContactBadges badges={badges} />
|
||||
</div>
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
<a href="javascript:;" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,353 +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';
|
||||
|
||||
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'
|
||||
},
|
||||
unblock: {
|
||||
id: 'menu_item_unblock',
|
||||
defaultMessage: 'Unblock',
|
||||
description: 'Unblock user'
|
||||
},
|
||||
block: {
|
||||
id: 'menu_item_block',
|
||||
defaultMessage: 'Block',
|
||||
description: 'Block user'
|
||||
},
|
||||
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 this.deleteMessages(true, false, params, errorHandler);
|
||||
}
|
||||
},
|
||||
'messages_clear_hard': {
|
||||
id: 'messages_clear_hard',
|
||||
title: formatMessage(messages.clear_for_all),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.deleteMessages(true, true, params, errorHandler);
|
||||
}
|
||||
},
|
||||
'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, '+J')
|
||||
},
|
||||
'topic_block': {
|
||||
id: 'topic_block',
|
||||
title: formatMessage(messages.block),
|
||||
handler: this.topicPermissionSetter.bind(this, '-J')
|
||||
},
|
||||
'topic_delete': {
|
||||
id: 'topic_delete',
|
||||
title: formatMessage(messages.topic_delete),
|
||||
handler: (params, errorHandler) => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
'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.
|
||||
let 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 am, user;
|
||||
if (params.user) {
|
||||
user = topic.subscriber(params.user);
|
||||
if (!user) {
|
||||
console.log("Subscriber not found", params.topicName + "[" + params.user + "]");
|
||||
return;
|
||||
}
|
||||
am = user.acs.updateGiven(mode).getGiven();
|
||||
} else {
|
||||
am = topic.getAccessMode().updateWant(mode).getWant();
|
||||
}
|
||||
|
||||
return topic.setMeta({sub: {user: params.user, mode: am}}).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,48 +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);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
return {
|
||||
show: !(!nextProps.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 = 'alert-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="javascript:;" onClick={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="javascript:;" onClick={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,191 +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 = {
|
||||
members: props.members,
|
||||
index: GroupManager.indexMembers(props.members),
|
||||
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(function(m) {
|
||||
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
static selectedContacts(members) {
|
||||
let sel = [];
|
||||
members.map(function(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};
|
||||
}
|
||||
|
||||
var m = this.state.members.slice();
|
||||
m.push(this.props.contacts[index]);
|
||||
|
||||
var sel = GroupManager.selectedContacts(m);
|
||||
|
||||
var i = this.state.index;
|
||||
i[userId] = status;
|
||||
|
||||
this.setState({members: m, index: i, selectedContacts: sel});
|
||||
}
|
||||
|
||||
handleMemberRemoved(userId, index) {
|
||||
var status = this.state.index[userId];
|
||||
if (!status || !status.present) {
|
||||
return;
|
||||
}
|
||||
status.present = false;
|
||||
status.delta -= 1;
|
||||
|
||||
var m = this.state.members.slice();
|
||||
m.splice(index, 1);
|
||||
|
||||
var sel = GroupManager.selectedContacts(m);
|
||||
|
||||
var 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 (var 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}
|
||||
required={this.props.requiredMember}
|
||||
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,29 +0,0 @@
|
||||
/* GroupSubs: a list of group subscribers currently online */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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 = [];
|
||||
(this.props.subscribers || []).map((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 (
|
||||
<div id="topic-users">{usersOnline}</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,73 +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 = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
width: this.container.clientWidth,
|
||||
height: this.container.clientHeight
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
}
|
||||
const instance = this;
|
||||
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;
|
||||
const maxlength = (this.props.content.width / REM_SIZE) | 0;
|
||||
if (filename.length > maxlength) {
|
||||
filename = filename.slice(0, maxlength-2) + '...' + filename.slice(2-maxlength);
|
||||
}
|
||||
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="javascript:;" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container" ref={function(ref) {instance.container = ref;}}>
|
||||
<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);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
// If text has changed while in read mode, update text and discard changes.
|
||||
// Ignore update if in edit mode.
|
||||
if (prevState.initialValue != nextProps.value && !prevState.active) {
|
||||
return {
|
||||
initialValue: nextProps.value || '',
|
||||
value: nextProps.value || ''
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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,29 +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) {
|
||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||
let letter = this.props.title.trim().charAt(0);
|
||||
let color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16);
|
||||
avatar = (<div className={color}><div>{letter}</div></div>)
|
||||
} else {
|
||||
avatar = (Tinode.topicType(this.props.topic) == 'grp') ?
|
||||
<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,14 +0,0 @@
|
||||
// The X menu to be displayed in title bars.
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuCancel extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<a href="javascript:;" onClick={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="javascript:;" onClick={this.props.onNewTopic}><i className="material-icons">chat</i></a>
|
||||
|
||||
<a href="javascript:;" onClick={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="javascript:;" onClick={this.props.onSignUp}><i className="material-icons">person_add</i></a>
|
||||
|
||||
<a href="javascript:;" onClick={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() {
|
||||
let open = !this.state.open;
|
||||
this.setState({open: open});
|
||||
if (this.props.onToggle) {
|
||||
this.props.onToggle(open);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<label className="small" 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,97 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import AvatarUpload from './avatar-upload.jsx';
|
||||
import TagManager from './tag-manager.jsx';
|
||||
|
||||
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();
|
||||
if (this.state.fn && this.state.fn.trim()) {
|
||||
this.props.onSubmit(this.state.fn.trim(),
|
||||
this.state.imageDataUrl, this.state.private.trim(),
|
||||
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="blue" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_ok" />
|
||||
</button>
|
||||
<button className="white" onClick={this.handleCancel}>
|
||||
<FormattedMessage id="button_cancel" />
|
||||
</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 red">error_outline</i>); // 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,73 +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() {
|
||||
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="javascript:;" onClick={this.handleClear}>
|
||||
<i className="material-icons">close</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,223 +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() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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="javascript:;" onClick={(e) => {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="javascript:;" onClick={(e) => {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="javascript:;" 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,152 +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.handleShowTagManager = this.handleShowTagManager.bind(this);
|
||||
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;
|
||||
}
|
||||
|
||||
handleShowTagManager() {
|
||||
this.setState({activated: true});
|
||||
}
|
||||
|
||||
handleTagInput(text) {
|
||||
this.setState({tagInput: text});
|
||||
// Check if user entered ',', ' ' or ';'
|
||||
if (text.length > 0) {
|
||||
var last = text[text.length-1];
|
||||
if (last == ',' || last == ' ' || last == ';') {
|
||||
var tag = text.substr(0, text.length-1).trim();
|
||||
if (tag.length >= MIN_TAG_LENGTH) {
|
||||
this.handleAddTag(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleAddTag(tag) {
|
||||
tag = tag.trim();
|
||||
if (tag.length > 0) {
|
||||
let tags = this.state.tags.slice(0);
|
||||
tags.push(tag);
|
||||
this.setState({tags: tags, tagInput: ''});
|
||||
if (this.props.onTagsChanged) {
|
||||
this.props.onTagsChanged(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveTag(tag, index) {
|
||||
var tags = this.state.tags.slice(0);
|
||||
tags.splice(index, 1);
|
||||
this.setState({tags: tags});
|
||||
if (this.props.onTagsChanged) {
|
||||
this.props.onTagsChanged(tags);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
let tags = this.state.tags.slice(0);
|
||||
let inp = this.state.tagInput.trim();
|
||||
if (inp.length > 0) {
|
||||
tags.push(inp);
|
||||
if (this.props.onTagsChanged) {
|
||||
this.props.onTagsChanged(tags);
|
||||
}
|
||||
}
|
||||
this.props.onSubmit(tags);
|
||||
this.setState({activated: false, tagInput: '', tags: this.props.tags});
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
this.setState({activated: false, tagInput: '', tags: this.props.tags});
|
||||
if (this.props.onCancel) {
|
||||
this.props.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
var tags = [];
|
||||
if (this.state.activated) {
|
||||
this.state.tags.map(function(tag) {
|
||||
tags.push({user: tag});
|
||||
});
|
||||
} else {
|
||||
this.state.tags.map(function(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="panel-form-row">
|
||||
<button className="blue" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_ok" defautMessage="OK" description="Confirmation button [OK]" />
|
||||
</button>
|
||||
<button className="white" onClick={this.handleCancel}>
|
||||
<FormattedMessage id="button_cancel" defautMessage="Cancel" description="Rejection button [Cancel]" />
|
||||
</button>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<a href="javascript:;" className="flat-button" onClick={this.handleShowTagManager}>
|
||||
<i className="material-icons">edit</i> <FormattedMessage id="title_manage_tags" defaultMessage="Manage tags"
|
||||
description="Section title for the list of tags" />
|
||||
</a>
|
||||
<span>{tags}</span>
|
||||
</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 |
|
Before Width: | Height: | Size: 319 KiB |
@@ -1,44 +0,0 @@
|
||||
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: [
|
||||
'./src/index.js',
|
||||
],
|
||||
devtool: 'source-map',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /src\/.*\.(js|jsx)?$/,
|
||||
loaders: [
|
||||
'babel-loader',
|
||||
],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
// we specify a custom UglifyJsPlugin here to get source maps in production.
|
||||
new UglifyJsPlugin({
|
||||
cache: true,
|
||||
parallel: true,
|
||||
uglifyOptions: {
|
||||
compress: true,
|
||||
mangle: true
|
||||
},
|
||||
sourceMap: true
|
||||
})
|
||||
]
|
||||
},
|
||||
performance: {
|
||||
maxEntrypointSize: 280000,
|
||||
maxAssetSize: 280000
|
||||
},
|
||||
externals: {
|
||||
'react': 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react-intl': 'ReactIntl',
|
||||
'firebase/app': 'firebase',
|
||||
'firebase/messaging': ['firebase', 'messaging']
|
||||
},
|
||||
};
|
||||