Compare commits

..
1 Commits
Author SHA1 Message Date
dev c7948236eb Initial commit 2026-10-18 12:19:40 +08:00
94 changed files with 139 additions and 28970 deletions
-9
View File
@@ -1,9 +0,0 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"comments": false,
"plugins": [
["react-intl", {
"messagesDir": "./src/i18n/"
}]
]
}
+138
View File
@@ -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.*
-25
View File
@@ -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"]
}
}]
-201
View File
@@ -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 -48
View File
@@ -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>
BIN
View File
Binary file not shown.
-1846
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-9
View File
@@ -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"
};
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

-305
View File
@@ -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

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

-48
View File
@@ -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.5.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.5.6/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>
-35
View File
@@ -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.6.0/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.6.0/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.7.2/dist/react-intl.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.5.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.5.6/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>
-26
View File
@@ -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"
}
-8794
View File
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.15.12-rc3",
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.4",
"babel-plugin-react-intl": "^3.0.1",
"browserslist": "^4.4.1",
"cssnano": "^4.0.5",
"webpack": "^4.23.1",
"webpack-cli": "^3.1.2"
},
"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.5.5",
"react": "^16.5.2",
"react-dom": "^16.5.2",
"react-intl": "^2.7.1",
"tinode-sdk": "^0.15.11-rc2"
}
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
plugins: [
require('cssnano')({
preset: 'default',
}),
],
};
-97
View File
@@ -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");
-13
View File
@@ -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);
}
}
);
-84
View File
@@ -1,84 +0,0 @@
// Must be located at the root.
importScripts('https://unpkg.com/firebase@5.5.6/firebase-app.js');
importScripts('https://unpkg.com/firebase@5.5.6/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);
});
// 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', function(event) {
const notification = event.notification;
notification.close();
const urlHash = '#/' + notification.data.topic;
const promises = self.clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then((windowClients) => {
let matchingClient = null;
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)) {
matchingClient = windowClients[i];
break;
} else {
anyClient = windowClients[i];
}
}
// Found browser tab with the topic already open.
if (matchingClient) {
return matchingClient.focus();
}
// Found browser tab with Tinode on a different topic,
// navigate to the right topic.
if (anyClient) {
const url = new URL(anyClient.url);
url.hash = '#/' + notification.data.topic;
return anyClient.navigate(url).then(() => { return anyClient.focus(); });
}
// Did not find a Tinode browser tab. Open one.
const url = new URL(self.location.origin);
url.hash = '#/' + notification.data.topic;
return clients.openWindow(url);
});
event.waitUntil(promises);
});
// This is needed for 'Add to Home Screen'.
self.addEventListener('fetch', function(event) {
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);
})
);
});
-55
View File
@@ -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;
-126
View File
@@ -1,126 +0,0 @@
{
"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",
"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",
"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:",
"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"
}
-126
View File
@@ -1,126 +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": "сохранить"
}
-34
View File
@@ -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')
);
-219
View File
@@ -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;
}
-29
View File
@@ -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;
}
-26
View File
@@ -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) {
let value = localStorage.getItem(key);
return value && JSON.parse(value);
}
// Partially or wholly update stored object.
static updateObject(key, value) {
let oldVal = this.getObject(key);
this.setObject(key, Object.assign(oldVal || {}, value));
}
// Just a wrapper.
static removeItem(key) {
localStorage.removeItem(key);
}
}
-68
View File
@@ -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);
}
}
-40
View File
@@ -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;
}
-54
View File
@@ -1,54 +0,0 @@
// Odds and ends
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
var oldIcon = document.getElementById('shortcut-icon');
if (oldIcon) {
var head = document.head || document.getElementsByTagName('head')[0];
var newIcon = document.createElement('link');
newIcon.type = 'image/png';
newIcon.id = 'shortcut-icon';
newIcon.rel = 'shortcut icon';
newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png';
head.removeChild(oldIcon);
head.appendChild(newIcon);
}
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
}
// Create VCard which represents topic 'public' info
export function vcard(fn, imageDataUrl) {
var card = null;
if ((fn && fn.trim()) || imageDataUrl) {
card = {};
if (fn) {
card.fn = fn.trim();
}
if (imageDataUrl) {
var 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) {
// Compare lengths first.
if (a.length != b.length) {
return false;
}
// Order of elements is ignored.
a.sort();
b.sort();
for (var i = 0, l = a.length; i < l; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
-254
View File
@@ -1,254 +0,0 @@
{
"en": {
"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",
"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",
"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:",
"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": "сохранить"
}
}
-3
View File
@@ -1,3 +0,0 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.15.12-rc3";
-50
View File
@@ -1,50 +0,0 @@
/* ContactsView holds all contacts-related stuff */
import React from 'react';
import { FormattedHTMLMessage } from 'react-intl';
import ContactList from '../widgets/contact-list.jsx';
import { updateFavicon } from '../lib/utils.js';
export default class ContactsView extends React.Component {
constructor(props) {
super(props);
this.state = ContactsView.getDerivedStateFromProps(props, {});
}
static getDerivedStateFromProps(nextProps, prevState) {
let contacts = [];
let unreadThreads = 0;
nextProps.chatList.map((c) => {
unreadThreads += c.unread > 0 ? 1 : 0;
contacts.push(c);
});
contacts.sort(function(a, b){
return (b.touched || 0) - (a.touched || 0);
});
updateFavicon(unreadThreads);
return {contactList: contacts};
}
render() {
return (
<FormattedHTMLMessage id="contacts_not_found"
defaultMesage="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} />
}</FormattedHTMLMessage>
);
}
};
-125
View File
@@ -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">&nbsp;{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>
);
}
};
-228
View File
@@ -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, this.state.avatar));
}
handlePasswordUpdate(pwd) {
this.setState({password: pwd});
this.props.onUpdateAccount(pwd);
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onUpdateAccount(undefined, vcard(this.state.fullName, 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>
);
}
};
-641
View File
@@ -1,641 +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) {
let 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() {
var 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) {
var 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() {
var 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, this.state.avatar),
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(this.state.fullName, 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" /> &nbsp;<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)}:
&nbsp;<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);
-96
View File
@@ -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">&nbsp;
<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 */
-25
View File
@@ -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>
);
}
};
-563
View File
@@ -1,563 +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 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'
}
});
class MessagesView extends React.Component {
constructor(props) {
super(props);
this.state = {
messages: [],
onlineSubs: [],
topic: '',
title: '',
avatar: null,
scrollPosition: 0,
readOnly: props.acs ? !props.acs.isWriter() : true,
writeOnly: props.acs ? !props.acs.isReader() : true,
typingIndicator: false,
imagePreview: null
};
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);
}
// Scroll last message into view on component update e.g. on message received
// or vertical shrinking.
componentDidUpdate(prevProps, prevState) {
if (this.messagesScroller) {
if (prevState.title != this.state.title || 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;
}
}
}
componentDidMount() {
this.propsChange(this.props);
if (this.messagesScroller) {
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
}
}
componentWillUnmount() {
this.leave(this.state.topic);
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.propsChange(nextProps);
}
propsChange(props) {
if (!props || !props.topic) {
let oldTopic = this.state.topic;
this.setState({messages: [], onlineSubs: [], topic: null}, this.leave(oldTopic));
return;
}
if (!props.connected) {
// connection lost, clear online subs
this.setState({onlineSubs: []});
return;
}
let topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
let newGroupTopic = Tinode.isNewGroupTopicName(props.topic);
let tryToResubscribe = !this.props.connected && props.connected;
if (props.topic != this.state.topic) {
let msgs = [];
let subs = [];
// Bind the new topic to component.
topic.onData = this.handleNewMessage;
topic.onAllMessagesReceived = this.handleAllMessagesReceived;
topic.onInfo = this.handleInfoReceipt;
topic.onMetaDesc = this.handleDescChange;
topic.onSubsUpdated = this.handleSubsUpdated;
topic.onPres = this.handleSubsUpdated;
// Unbind the previous topic from this component.
this.leave(this.state.topic);
this.handleDescChange(topic);
topic.subscribers((sub) => {
if (sub.online && sub.user != this.props.myUserId) {
subs.push(sub);
}
});
topic.messages(function(msg) {
if (!msg.deleted) {
msgs = msgs.concat(msg);
}
});
this.setState({
messages: msgs,
onlineSubs: subs,
topic: props.topic,
imagePreview: null,
scrollPosition: 0
});
tryToResubscribe = true;
// The user switched to the new topic before the timer for
// the previous topic has triggered, kill it.
this.props.readTimerHandler(null);
}
this.setState({
readOnly: props.acs ? !props.acs.isWriter() : true,
writeOnly: props.acs ? !props.acs.isReader() : true
});
if (!topic.isSubscribed() && tryToResubscribe) {
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery()
.withLaterDesc()
.withLaterSub()
.withLaterData(MESSAGES_PAGE)
.withLaterDel();
let setQuery = newGroupTopic ? props.newGroupTopicParams : undefined;
// Show "loading" spinner.
this.setState({ fetchingMessages: true });
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
this.setState({topic: ctrl.topic});
this.props.onNewTopicCreated(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) => {
this.props.onError(err.message, 'err');
const {formatMessage} = this.props.intl;
this.setState({
title: formatMessage(messages.not_found),
avatar: null,
readOnly: true,
writeOnly: true,
fetchingMessages: false,
messages: [],
onlineSubs: [],
typingIndicator: false,
imagePreview: null
});
});
}
}
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()
});
}
}
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;
var menuItems = ['message_delete'];
var topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
var acs = topic.getAccessMode();
if (acs && acs.isDeleter()) {
menuItems.push('message_delete_hard');
}
}
this.props.showContextMenu(params, menuItems);
}
handleBackNavigation() {
this.props.onHideMessagesView();
}
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>
{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>
<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);
-101
View File
@@ -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>
);
}
};
-87
View File
@@ -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>
);
}
}
-84
View File
@@ -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>
);
}
};
-173
View File
@@ -1,173 +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'
}
});
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' ?
<ContactsView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
connected={this.props.connected}
topicSelected={this.props.topicSelected}
chatList={this.props.chatList}
showContextMenu={this.props.showContextMenu}
onTopicSelected={this.props.onTopicSelected} /> :
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);
File diff suppressed because it is too large Load Diff
-81
View File
@@ -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);
-85
View File
@@ -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>
);
}
};
-81
View File
@@ -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}&times;{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>
);
}
};
-199
View File
@@ -1,199 +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';
// console.log(data, attr);
break;
case 'FE':
// Form element formatting is dependent on element content.
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
-29
View File
@@ -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 */
-142
View File
@@ -1,142 +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 = {
placeholder: props.chips ? '' : props.prompt,
sortedChips: ChipInput.sortChips(props.chips, props.required),
chipIndex: ChipInput.indexChips(props.chips),
input: '',
focused: false
};
this.handleTextInput = this.handleTextInput.bind(this);
this.removeChipAt = this.removeChipAt.bind(this);
this.handleChipCancel = this.handleChipCancel.bind(this);
this.handleFocusGained = this.handleFocusGained.bind(this);
this.handleFocusLost = this.handleFocusLost.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.setState({
sortedChips: ChipInput.sortChips(nextProps.chips, nextProps.required),
chipIndex: ChipInput.indexChips(nextProps.chips)
});
if (nextProps.chips.length > this.props.chips.length) {
// Chip added: clear input.
this.setState({input: ''});
}
}
// Map chip index to user name
static indexChips(chips) {
var index = {};
var 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) {
var required = [];
var 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) {
var 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() {
var chips = [];
var 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++;
});
var 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>
);
}
};
-36
View File
@@ -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() {
var 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} >&times;</a>
: <span className="spacer" />}
</div>
);
}
};
-17
View File
@@ -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>;
}
};
-97
View File
@@ -1,97 +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 { 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 = [];
if (this.props.contacts && this.props.contacts.length > 0) {
this.props.contacts.map((c) => {
let 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;
}
}
let selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
let 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'});
}
}
let 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} />
);
}, this);
}
return (
<div className={this.props.noScroll ? null : "scrollable-panel"}>
{contactNodes.length > 0 ?
<ul className="contact-box">
{contactNodes}
</ul>
:
<div className="center-medium-text" dangerouslySetInnerHTML={{__html: this.props.emptyListMessage}} />
}
</div>
);
}
};
export default injectIntl(ContactList);
-75
View File
@@ -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>
);
}
};
-261
View File
@@ -1,261 +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'
}
});
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': {title: formatMessage(messages.info), handler: null},
'messages_clear': {title: formatMessage(messages.clear_messages), handler: (params, errorHandler) => {
this.deleteMessages(true, false, params, errorHandler);
}},
'messages_clear_hard': {title: formatMessage(messages.clear_for_all), handler: (params, errorHandler) => {
this.deleteMessages(true, true, params, errorHandler);
}},
'message_delete': {title: formatMessage(messages.delete), handler: (params, errorHandler) => {
this.deleteMessages(false, false, params, errorHandler);
}},
'message_delete_hard': {title: formatMessage(messages.delete_for_all), handler: (params, errorHandler) => {
this.deleteMessages(false, true, params, errorHandler);
}},
'topic_unmute': {title: formatMessage(messages.unmute), handler: this.topicPermissionSetter.bind(this, '+P')},
'topic_mute': {title: formatMessage(messages.mute), handler: this.topicPermissionSetter.bind(this, '-P')},
'topic_unblock': {title: formatMessage(messages.unblock), handler: this.topicPermissionSetter.bind(this, '+J')},
'topic_block': {title: formatMessage(messages.block), handler: this.topicPermissionSetter.bind(this, '-J')},
'topic_delete': {title: formatMessage(messages.topic_delete), handler: (params, errorHandler) => {
let topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
topic.delTopic().catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}},
// menu_item_edit_permissions is defined elsewhere.
'permissions': {title: formatMessage({id: 'menu_item_edit_permissions'}), handler: null},
'member_delete': {title: formatMessage(messages.member_delete), handler: (params, errorHandler) => {
let topic = this.props.tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
topic.delSubscription(params.user).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}},
'member_mute': {title: formatMessage(messages.mute), handler: this.topicPermissionSetter.bind(this, '-P')},
'member_unmute': {title: formatMessage(messages.unmute), handler: this.topicPermissionSetter.bind(this, '+P')},
'member_block': {title: formatMessage(messages.block), handler: this.topicPermissionSetter.bind(this, '-J')},
'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];
}
item.handler(this.props.params, this.props.onError);
}
// 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);
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();
}
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);
-48
View File
@@ -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>
);
}
};
-22
View File
@@ -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>
);
}
}
-191
View File
@@ -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);
-29
View File
@@ -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>
);
}
};
-50
View File
@@ -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>
);
}
}
-73
View File
@@ -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} &times; {height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>
</div>
);
}
};
-120
View File
@@ -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>
);
}
};
-29
View File
@@ -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;
}
}
-8
View File
@@ -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);
}
}
-14
View File
@@ -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>
);
}
}
-13
View File
@@ -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>
&nbsp;
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
</div>
);
}
};
-13
View File
@@ -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>
&nbsp;
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
</div>
);
}
};
-29
View File
@@ -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>);
}
}
-69
View File
@@ -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);
-97
View File
@@ -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>
);
}
};
-165
View File
@@ -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);
-55
View File
@@ -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);
-73
View File
@@ -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>
);
}
};
-223
View File
@@ -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);
-29
View File
@@ -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>
);
}
};
-152
View File
@@ -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>
);
}
};
-13
View File
@@ -1,13 +0,0 @@
// The counter of unread messages in the topic.
import React from 'react';
export default class UnreadBadge extends React.PureComponent {
render() {
var showUnreadBadge = null;
if (this.props.count > 0) {
var count = this.props.count > 9 ? "9+" : this.props.count;
showUnreadBadge = <span className="unread">{count}</span>;
}
return showUnreadBadge;
}
};
-80
View File
@@ -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>
);
}
}
-10158
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

-40
View File
@@ -1,40 +0,0 @@
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: [
'./src/index.js',
],
devtool: 'source-map',
module: {
rules: [
{
test: /src\/.*\.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
})
]
},
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
'react-intl': 'ReactIntl',
'firebase/app': 'firebase',
'firebase/messaging': ['firebase', 'messaging']
},
};