Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bbddc08e6 | ||
|
|
b0be1fd012 | ||
|
|
21303346b1 | ||
|
|
bd7531e5b7 | ||
|
|
a0184ac002 | ||
|
|
4d438ccab4 | ||
|
|
48e6144568 | ||
|
|
e028503c9e | ||
|
|
fcb3d78cc4 | ||
|
|
b976a3caa8 | ||
|
|
cc6a4cd125 | ||
|
|
6d7f98c55b | ||
|
|
d80ddcabea | ||
|
|
4c44b61a9d | ||
|
|
9eb7bebe80 | ||
|
|
1f634f5877 | ||
|
|
f78d673e02 | ||
|
|
a5d405b083 | ||
|
|
405ce756a2 | ||
|
|
31190ec826 | ||
|
|
9b3c714390 | ||
|
|
2e76b122a2 | ||
|
|
14d75223e2 | ||
|
|
bc3e82bcbf | ||
|
|
10d88da558 | ||
|
|
4dde45b6be | ||
|
|
00eeb2503a | ||
|
|
4f70de3aa9 | ||
|
|
486cf2e355 | ||
|
|
21841c0022 | ||
|
|
d1f7f02fa0 | ||
|
|
fbbab3e7d6 | ||
|
|
7248b5c398 | ||
|
|
96de18ea7b | ||
|
|
198bd9a64c | ||
|
|
9324faa32f | ||
|
|
769c25170f | ||
|
|
9e073356b2 | ||
|
|
14ff124674 | ||
|
|
f959aa24cc | ||
|
|
7d058df54d | ||
|
|
c72819de38 | ||
|
|
bad6232b9a | ||
|
|
d0e4a52ba9 | ||
|
|
2b9883c254 | ||
|
|
00e48b7c93 | ||
|
|
20cf802d3d | ||
|
|
b07a739619 | ||
|
|
3a09df6c14 | ||
|
|
aa1b55c182 | ||
|
|
e8dba6bd5e | ||
|
|
c76a7dcb6b | ||
|
|
e7f3b424e4 | ||
|
|
0198f446d4 | ||
|
|
c2f261b143 | ||
|
|
8c4e0e9068 | ||
|
|
954b0079a4 | ||
|
|
c8506eb143 | ||
|
|
a48be912b4 | ||
|
|
639755ea2b | ||
|
|
dc838776ec | ||
|
|
d7fb701983 | ||
|
|
92d4ba8a86 | ||
|
|
48d5732c16 | ||
|
|
71bbfb4fdd | ||
|
|
782f2d1fea | ||
|
|
25021a866d | ||
|
|
3e3dbd7fdb | ||
|
|
53d545e114 | ||
|
|
00a54ac0ee | ||
|
|
cbc89617dc | ||
|
|
e139c1371e | ||
|
|
358f9eb970 | ||
|
|
765a6e5d84 | ||
|
|
d4ee2fb7bc | ||
|
|
5958dec09b | ||
|
|
99dfb0afeb | ||
|
|
5367c71784 | ||
|
|
7dead5b14d | ||
|
|
2bb60ea7a2 | ||
|
|
4b7d76c150 | ||
|
|
8b02f39557 | ||
|
|
a2f075f4fb | ||
|
|
8a70f37446 | ||
|
|
47abebd1f2 | ||
|
|
2e02f8a1c8 | ||
|
|
49c0fbfd78 | ||
|
|
37bad29350 | ||
|
|
58c29a8248 | ||
|
|
cbc3875288 | ||
|
|
e43e3ffcbc | ||
|
|
57ca428a65 | ||
|
|
b2ffb626da | ||
|
|
a1cb605f37 | ||
|
|
c7b2a30f24 | ||
|
|
da24e28a92 | ||
|
|
c52b661c87 | ||
|
|
ca212fb038 | ||
|
|
44039f9a82 | ||
|
|
bcee90ef06 | ||
|
|
e712bff7b3 | ||
|
|
57fa378f4d | ||
|
|
6cbf536fa8 | ||
|
|
13f492285e | ||
|
|
60968b84c6 | ||
|
|
114bfc409f | ||
|
|
871a1e5b5b | ||
|
|
7a3212cf36 | ||
|
|
82dda02b12 | ||
|
|
76620cc9b5 | ||
|
|
dba9500825 | ||
|
|
62cac29071 | ||
|
|
07e095aa9a | ||
|
|
c8a47efde2 | ||
|
|
3ad8274ad8 | ||
|
|
64219b9343 | ||
|
|
a6fe3e59d4 | ||
|
|
0231de6086 | ||
|
|
22a2954eb8 | ||
|
|
a884f6fea2 | ||
|
|
6ac0ad6fbf | ||
|
|
15393198be | ||
|
|
c56c91afce | ||
|
|
4a66c1ee73 | ||
|
|
b9bdeeae19 | ||
|
|
c14a2044cd | ||
|
|
72aef53c7e | ||
|
|
c567a2678a | ||
|
|
242b30cee0 | ||
|
|
6c9baf3cba | ||
|
|
534a2fded7 | ||
|
|
2fc6bebacc | ||
|
|
e761c5e0f6 | ||
|
|
1a00f4b844 | ||
|
|
ab7a049512 | ||
|
|
bffa6eb6ce | ||
|
|
cefde649ba | ||
|
|
89c41818f0 | ||
|
|
f53cef6262 | ||
|
|
3064c0ec2a | ||
|
|
1e268b3eae | ||
|
|
7228053261 | ||
|
|
d6d9340304 | ||
|
|
2bf4a0fe1f | ||
|
|
f9725cb06a | ||
|
|
3c20bcf6f2 | ||
|
|
03db847d48 | ||
|
|
8b71444341 | ||
|
|
ce20a86072 | ||
|
|
c768755ae4 | ||
|
|
ef3d799411 | ||
|
|
0ba4b85a91 | ||
|
|
ad016b2a8e | ||
|
|
18c7a82ce6 | ||
|
|
dd9fd8e345 | ||
|
|
1318f399e6 | ||
|
|
6ed768cacc | ||
|
|
7d01356be7 | ||
|
|
72e51b65a2 | ||
|
|
b282f789fb | ||
|
|
a578e27c82 | ||
|
|
e23b3172c9 | ||
|
|
5e0713fb3e | ||
|
|
aafa0f74d5 | ||
|
|
40000cfa44 | ||
|
|
3865eb8928 | ||
|
|
7bf26a55be | ||
|
|
15e5dacc51 | ||
|
|
2de87accf5 | ||
|
|
78764319c0 | ||
|
|
cf86af1284 | ||
|
|
22c86747a1 | ||
|
|
39b18ff6dd | ||
|
|
f833ec6960 | ||
|
|
cb4a0655a3 | ||
|
|
8354d921f3 | ||
|
|
d178803d86 | ||
|
|
4aadff8ab3 | ||
|
|
cf849d6412 | ||
|
|
5c4429858b | ||
|
|
996dad96c1 | ||
|
|
55c08373d0 | ||
|
|
aca9286d63 | ||
|
|
c7325e3c25 | ||
|
|
61b3bcbb4f | ||
|
|
758b5b54cf | ||
|
|
94c8bab4ec | ||
|
|
7ac3041493 | ||
|
|
4be41e47f4 | ||
|
|
f782bc11cc | ||
|
|
be8d64e29e | ||
|
|
e22a4de007 | ||
|
|
fde40f4f51 | ||
|
|
e2b19d72bc | ||
|
|
a0eac497c6 | ||
|
|
f2ef14c239 | ||
|
|
2b8514b495 | ||
|
|
e9e8d9881f | ||
|
|
3975401205 | ||
|
|
6da0952e5d | ||
|
|
45acee4617 | ||
|
|
ff5096cd47 | ||
|
|
4cd97e7ff8 | ||
|
|
df86411cb7 | ||
|
|
905501f22f | ||
|
|
c48e5f1dbf | ||
|
|
0a956f0234 | ||
|
|
ae73764173 | ||
|
|
2f8e5c4b33 | ||
|
|
a542afd194 | ||
|
|
a8c9347591 | ||
|
|
dc46f8f166 | ||
|
|
6eb3551665 | ||
|
|
3829b26cc8 | ||
|
|
973b71eb80 | ||
|
|
1b7fd6a00b | ||
|
|
daa1ee3e0c | ||
|
|
35eb23e3af | ||
|
|
0c16a5c991 | ||
|
|
926dd43ce6 | ||
|
|
499bf1ee14 | ||
|
|
9c507ba631 | ||
|
|
2f307e2803 | ||
|
|
2c21a34d07 | ||
|
|
2e3ecb1c4e | ||
|
|
e21bf82de1 | ||
|
|
d837d8950c | ||
|
|
b17e0d45ae | ||
|
|
f5af1ff193 | ||
|
|
e77513d44b | ||
|
|
556e249b78 | ||
|
|
ce0e83070a | ||
|
|
d0747b9f7b | ||
|
|
eb3ccf7b1e | ||
|
|
2e619d3fb8 | ||
|
|
9d128e505f | ||
|
|
77479a3e2d | ||
|
|
353bd1d5fe | ||
|
|
ed11dd0d22 | ||
|
|
189fc0e3f2 | ||
|
|
d5c7464570 | ||
|
|
ce4c803b97 | ||
|
|
c6dbe3830e | ||
|
|
09a1b85e86 | ||
|
|
82cc9ebb09 | ||
|
|
820a4081a1 | ||
|
|
09eb4bdde8 | ||
|
|
c9c6bf1cef | ||
|
|
ff67a6d3d2 | ||
|
|
c110c4bb5e | ||
|
|
e2be203088 | ||
|
|
316de3e875 | ||
|
|
22c92043a1 | ||
|
|
cd4388d574 | ||
|
|
9449606adc | ||
|
|
4101ae21d1 | ||
|
|
15ffade573 | ||
|
|
8148d42ba7 | ||
|
|
0f08e63b6e | ||
|
|
39b5ce8210 | ||
|
|
a364aa7dea | ||
|
|
e5fc770369 | ||
|
|
da05994d0f | ||
|
|
18ef1e88fd | ||
|
|
7e5f7dd30b | ||
|
|
decad8f65c | ||
|
|
6edaf16db8 | ||
|
|
cd8f5a1b94 | ||
|
|
586b0f152b | ||
|
|
d06ca5cdbd | ||
|
|
ae1f08e506 | ||
|
|
53ef0f83d6 | ||
|
|
efcf2680ec | ||
|
|
ba4f976e3b | ||
|
|
757b23356c | ||
|
|
fa90ff5bef | ||
|
|
60262291ce | ||
|
|
515e27b489 | ||
|
|
ed268053f3 | ||
|
|
f68d4d3e24 | ||
|
|
00dd18302f | ||
|
|
3cba22ae22 | ||
|
|
ac89af9850 | ||
|
|
83e17861bd | ||
|
|
67c5cbf77f | ||
|
|
1e69fabe2c | ||
|
|
9283c4ede2 | ||
|
|
95b9f1a1fd | ||
|
|
99bab6cae9 | ||
|
|
57490cbe92 | ||
|
|
6d8b49c36c | ||
|
|
b7a4efa6b6 | ||
|
|
dac21cf807 | ||
|
|
bacaadfd86 | ||
|
|
ea55bde395 | ||
|
|
b277b020f7 | ||
|
|
1415f21c51 | ||
|
|
576d81a7ec | ||
|
|
0e5fb5b468 | ||
|
|
8daf217795 | ||
|
|
29a55c1e37 | ||
|
|
964a423879 | ||
|
|
addd27108f | ||
|
|
dc91e9d7b7 | ||
|
|
c80e09d956 | ||
|
|
6a2681ab56 | ||
|
|
e5c98ba2d1 | ||
|
|
1b9b7a2671 | ||
|
|
1a3362f1cd | ||
|
|
9009667c5a | ||
|
|
a59023575b | ||
|
|
b2b1ca2934 | ||
|
|
afd1bbb58a | ||
|
|
de655a105a | ||
|
|
2c1c0b2544 | ||
|
|
ccf5318e4c | ||
|
|
7322abfd55 | ||
|
|
09518bbac2 | ||
|
|
b6a831ec91 | ||
|
|
378cd622c4 | ||
|
|
939e3e59cf | ||
|
|
22e82b1c7f | ||
|
|
e495917356 | ||
|
|
cf4b58cab2 | ||
|
|
0a96014484 | ||
|
|
3e8fd9d6e6 | ||
|
|
a06d29863d | ||
|
|
de24e23127 | ||
|
|
9417e1aa49 | ||
|
|
1f8d26a215 | ||
|
|
e4a0b60f1e | ||
|
|
19612e4236 | ||
|
|
af1e9c609b | ||
|
|
5d18c71475 | ||
|
|
20fc9ae343 | ||
|
|
2e8b9145b7 | ||
|
|
859dec8410 | ||
|
|
8f13d2c856 | ||
|
|
d2a226852b | ||
|
|
9c7a05f616 | ||
|
|
a6e59936e2 | ||
|
|
e3d95eafe1 | ||
|
|
e30568ecd5 | ||
|
|
9ac8ea8f93 | ||
|
|
1bac018961 | ||
|
|
1aba91d6f3 | ||
|
|
fd750558ac | ||
|
|
f2cb7d1e8b | ||
|
|
7870fb06a2 | ||
|
|
9adff7572e | ||
|
|
d50da89403 | ||
|
|
8ddfb93f7a | ||
|
|
b718aa9513 | ||
|
|
7782021c12 | ||
|
|
73b2a85eea | ||
|
|
d34f51a9d0 | ||
|
|
0a2abc080f | ||
|
|
9d1786ab67 | ||
|
|
fb6b4899cd | ||
|
|
0c7549819a | ||
|
|
fd8c58e837 | ||
|
|
5c74be339a | ||
|
|
c88c821002 | ||
|
|
f20f83b936 | ||
|
|
88cc3145c4 | ||
|
|
5d1bf5f624 | ||
|
|
9fa998709d | ||
|
|
0f90626a49 | ||
|
|
40108d5201 | ||
|
|
0dde03cf6f | ||
|
|
83fe8ea02b | ||
|
|
17876fc4b0 | ||
|
|
eca16c8474 | ||
|
|
ff0dfed2e8 | ||
|
|
db8e718002 | ||
|
|
f49f0424d5 | ||
|
|
998ddfd54b | ||
|
|
53e67de669 | ||
|
|
897bb0719a | ||
|
|
d051c84737 | ||
|
|
578df160d1 | ||
|
|
ac335a7a5c | ||
|
|
5868915f2c | ||
|
|
1054c81de3 | ||
|
|
79756602a7 | ||
|
|
8d5f446ccd | ||
|
|
b689d08e71 | ||
|
|
58ae90b574 | ||
|
|
e79444e5f7 | ||
|
|
45eb91765b | ||
|
|
933ede1220 | ||
|
|
506bd42201 | ||
|
|
b3ef0a856b | ||
|
|
732c17ad76 | ||
|
|
8ee22694f1 | ||
|
|
e3909c96bd | ||
|
|
ac97f1d61d | ||
|
|
61fbc69356 | ||
|
|
67cd659712 | ||
|
|
895a81799f | ||
|
|
097bf7d193 | ||
|
|
40cccfdfa9 | ||
|
|
71fc855940 | ||
|
|
9fc05d97d9 | ||
|
|
0a3dfa8d9a | ||
|
|
43016e389a | ||
|
|
8c4284a435 | ||
|
|
3f13c56fb9 | ||
|
|
73e2be8ac8 | ||
|
|
86f706dee7 | ||
|
|
1372a68ecf | ||
|
|
dc37ab3d7a | ||
|
|
e9de7d89fd | ||
|
|
2e4b165b42 | ||
|
|
d03b340985 | ||
|
|
90d579bbcb | ||
|
|
8067985edd | ||
|
|
2d5ed1bb83 | ||
|
|
85bcc107d4 | ||
|
|
3d4285d2dc | ||
|
|
c7282f8c04 | ||
|
|
e06612f5b3 | ||
|
|
e28655a64c | ||
|
|
1db21526e1 | ||
|
|
d709739dc0 | ||
|
|
d9a1e2fd0c | ||
|
|
ac86e415e9 | ||
|
|
5b89abb646 | ||
|
|
ed49b8b10f | ||
|
|
58d02cf4bc | ||
|
|
0ed884f5ad | ||
|
|
e278ebda5d | ||
|
|
103184c8da | ||
|
|
3159ddb0a6 | ||
|
|
fc7369f586 | ||
|
|
11c78eb85e | ||
|
|
5449728cc6 | ||
|
|
7d8e58fb4d | ||
|
|
1d46fd0279 | ||
|
|
cc99b267bb | ||
|
|
cf38f55291 | ||
|
|
1483740189 | ||
|
|
f5e91a6366 | ||
|
|
0e539c0a98 | ||
|
|
d1265c21ee | ||
|
|
9a855a7288 | ||
|
|
d331367223 | ||
|
|
156f1f50bc | ||
|
|
3acd66e5b3 | ||
|
|
876a7c1b1f | ||
|
|
444d7c006d | ||
|
|
10353c9a1d | ||
|
|
22e2be2dc8 | ||
|
|
d3e2436527 | ||
|
|
984f255d8a | ||
|
|
3f508da8e6 | ||
|
|
295ffd640b | ||
|
|
b344f9d7e4 | ||
|
|
ca49c83073 | ||
|
|
d28d74a77a | ||
|
|
1b52fde005 | ||
|
|
31d5320922 | ||
|
|
e871dad863 | ||
|
|
eee2db628a | ||
|
|
4c759913c7 | ||
|
|
4442cbae9d | ||
|
|
6d0d75ab1d | ||
|
|
3f2746cdb7 | ||
|
|
d28c696190 | ||
|
|
eba7510be8 | ||
|
|
d1442bfc93 | ||
|
|
743d941204 | ||
|
|
f82bd966e0 | ||
|
|
aedc9854c1 | ||
|
|
87c5b5bc6b | ||
|
|
9f50eff10a | ||
|
|
3b56e7ca2f | ||
|
|
f64c8082ff | ||
|
|
20be763dc1 | ||
|
|
391a956af8 | ||
|
|
2121a6a4f2 | ||
|
|
dc8f82961a | ||
|
|
d9015804c8 | ||
|
|
135bec17a8 | ||
|
|
35624786ab | ||
|
|
0688a360c7 |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||||
|
"comments": false,
|
||||||
|
"plugins": [
|
||||||
|
["react-intl", {
|
||||||
|
"messagesDir": "./src/i18n/"
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# ---> 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.*
|
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}]
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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,2 +1,49 @@
|
|||||||
# im_webapp
|
# Single-page chat with Tinode
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// 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"
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,305 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('cssnano')({
|
||||||
|
preset: 'default',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 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");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"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": "сохранить"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 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')
|
||||||
|
);
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"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": "сохранить"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This is a generated file. Don't edit.
|
||||||
|
|
||||||
|
export const PACKAGE_VERSION = "0.15.12-rc3";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Account registration.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||||
|
import CheckBox from '../widgets/checkbox.jsx';
|
||||||
|
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||||
|
|
||||||
|
import LocalStorageUtil from '../lib/local-storage.js';
|
||||||
|
import { vcard } from '../lib/utils.js';
|
||||||
|
|
||||||
|
export default class CreateAccountView extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
login: '',
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
fn: '', // full/formatted name
|
||||||
|
imageDataUrl: null,
|
||||||
|
errorCleared: false,
|
||||||
|
saveToken: LocalStorageUtil.getObject('keep-logged-in')
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleLoginChange = this.handleLoginChange.bind(this);
|
||||||
|
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||||
|
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||||
|
this.handleFnChange = this.handleFnChange.bind(this);
|
||||||
|
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||||
|
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoginChange(e) {
|
||||||
|
this.setState({login: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePasswordChange(password) {
|
||||||
|
this.setState({password: password});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmailChange(e) {
|
||||||
|
this.setState({email: e.target.value})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFnChange(e) {
|
||||||
|
this.setState({fn: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageChanged(img) {
|
||||||
|
this.setState({imageDataUrl: img});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleSaveToken() {
|
||||||
|
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
|
||||||
|
this.setState({saveToken: !this.state.saveToken});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
// TODO: check email for validity
|
||||||
|
this.setState({errorCleared: false});
|
||||||
|
this.props.onCreateAccount(
|
||||||
|
this.state.login.trim(),
|
||||||
|
this.state.password.trim(),
|
||||||
|
vcard(this.state.fn, this.state.imageDataUrl),
|
||||||
|
{'meth': 'email', 'val': this.state.email});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let submitClasses = 'blue';
|
||||||
|
if (this.props.disabled) {
|
||||||
|
submitClasses += ' disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="panel-form-column" onSubmit={this.handleSubmit}>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<div className="panel-form-column">
|
||||||
|
<FormattedMessage id="login_prompt">{
|
||||||
|
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
|
||||||
|
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
|
||||||
|
}</FormattedMessage>
|
||||||
|
<FormattedMessage id="password_prompt">{
|
||||||
|
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
|
||||||
|
value={this.state.password} onFinished={this.handlePasswordChange}
|
||||||
|
required={true} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
<AvatarUpload
|
||||||
|
onImageChanged={this.handleImageChanged}
|
||||||
|
onError={this.props.onError} />
|
||||||
|
</div>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
|
||||||
|
description="Input placeholder for person's full name">{
|
||||||
|
(full_name_prompt) => <input type="text" placeholder={full_name_prompt} autoComplete="name"
|
||||||
|
value={this.state.fn} onChange={this.handleFnChange} required/>
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
|
||||||
|
description="Input placeholder for email entry">{
|
||||||
|
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
|
||||||
|
value={this.state.email} onChange={this.handleEmailChange} required/>
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
||||||
|
onChange={this.handleToggleSaveToken} />
|
||||||
|
<FormattedMessage id="stay_logged_in">{
|
||||||
|
(stay_logged_in) => <label htmlFor="save-token"> {stay_logged_in}</label>
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-buttons">
|
||||||
|
<button className={submitClasses} type="submit">
|
||||||
|
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
|
||||||
|
description="Create account button [Sign Up]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
// 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" /> <tt className="clickable"
|
||||||
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
||||||
|
{this.state.access}
|
||||||
|
</tt></div>
|
||||||
|
<div>{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}:
|
||||||
|
<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
|
||||||
|
{this.state.modeGiven2}
|
||||||
|
</tt>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{this.state.sharer && (this.state.auth || this.state.anon) ?
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_default_access" defaultMessage="Default access mode:"
|
||||||
|
description="Section title" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="quoted">
|
||||||
|
<div>Auth: {this.state.admin ?
|
||||||
|
<tt className="clickable"
|
||||||
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
|
||||||
|
{this.state.auth}
|
||||||
|
</tt>
|
||||||
|
:
|
||||||
|
<tt>{this.state.auth}</tt>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>Anon: {this.state.admin ?
|
||||||
|
<tt className="clickable"
|
||||||
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
|
||||||
|
{this.state.anon}
|
||||||
|
</tt>
|
||||||
|
:
|
||||||
|
<tt>{this.state.anon}</tt>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="hr" />
|
||||||
|
{this.state.owner ?
|
||||||
|
<FormattedMessage id="title_tag_manager">{
|
||||||
|
(tags) => <TagManager
|
||||||
|
title={tags}
|
||||||
|
tags={this.state.tags}
|
||||||
|
activated={false}
|
||||||
|
onSubmit={this.handleTagsUpdated} />
|
||||||
|
}</FormattedMessage>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{this.state.owner ? <div className="hr" /> : null }
|
||||||
|
{this.state.groupTopic ?
|
||||||
|
<div className="panel-form-column">
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<label className="small">
|
||||||
|
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
|
||||||
|
description="Section title or label" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
{this.state.sharer ?
|
||||||
|
<a href="javascript:;" className="flat-button" onClick={this.handleShowAddMembers}>
|
||||||
|
<i className="material-icons">person_add</i> <FormattedMessage id="button_add_members"
|
||||||
|
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
|
||||||
|
</a>
|
||||||
|
: null}
|
||||||
|
{!this.state.owner ?
|
||||||
|
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
|
||||||
|
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_leave"
|
||||||
|
defaultMessage="Leave" description="Flat button [Leave] (topic)" />
|
||||||
|
</a>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
|
||||||
|
description="Shown in place of group members">{
|
||||||
|
(no_members) => <ContactList
|
||||||
|
contacts={this.state.contactList}
|
||||||
|
myUserId={this.props.myUserId}
|
||||||
|
emptyListMessage={no_members}
|
||||||
|
topicSelected={this.state.selectedContact}
|
||||||
|
showOnline={false}
|
||||||
|
showUnread={false}
|
||||||
|
showMode={true}
|
||||||
|
noScroll={true}
|
||||||
|
onTopicSelected={this.handleMemberSelected}
|
||||||
|
showContextMenu={this.state.admin ? this.handleContextMenu : false}
|
||||||
|
/>
|
||||||
|
}</FormattedMessage>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
|
||||||
|
<i className="material-icons">exit_to_app</i> <FormattedMessage id="action_leave_chat"
|
||||||
|
defaultMessage="Leave" description="Action [Leave] chat" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(InfoView);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Login form.
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import CheckBox from '../widgets/checkbox.jsx';
|
||||||
|
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||||
|
|
||||||
|
import LocalStorageUtil from '../lib/local-storage.js';
|
||||||
|
|
||||||
|
export default class LoginView extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
login: props.login,
|
||||||
|
password: '',
|
||||||
|
hostName: props.serverAddress,
|
||||||
|
saveToken: LocalStorageUtil.getObject('keep-logged-in')
|
||||||
|
};
|
||||||
|
this.handleLoginChange = this.handleLoginChange.bind(this);
|
||||||
|
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||||
|
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoginChange(e) {
|
||||||
|
this.setState({login: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePasswordChange(e) {
|
||||||
|
this.setState({password: e.target.value});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleSaveToken() {
|
||||||
|
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
|
||||||
|
this.setState({saveToken: !this.state.saveToken});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var submitClasses = 'blue';
|
||||||
|
if (this.props.disabled) {
|
||||||
|
submitClasses += ' disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="login-form" onSubmit={this.handleSubmit}>
|
||||||
|
<FormattedMessage id="login_prompt" defaultMessage="Login"
|
||||||
|
description="Login placeholder in LoginView">
|
||||||
|
{
|
||||||
|
(login_prompt) => <input type="text" id="inputLogin"
|
||||||
|
placeholder={login_prompt}
|
||||||
|
autoComplete="username"
|
||||||
|
value={this.state.login}
|
||||||
|
onChange={this.handleLoginChange}
|
||||||
|
required autoFocus />
|
||||||
|
}
|
||||||
|
</FormattedMessage>
|
||||||
|
<FormattedMessage id="password_prompt" defaultMessage="Password"
|
||||||
|
description="Password placeholder in LoginView">
|
||||||
|
{
|
||||||
|
(password_prompt) => <VisiblePassword type="password" id="inputPassword"
|
||||||
|
placeholder={password_prompt}
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={this.state.password}
|
||||||
|
onChange={this.handlePasswordChange}
|
||||||
|
required={true} />
|
||||||
|
}
|
||||||
|
</FormattedMessage>
|
||||||
|
<div className="panel-form-row">
|
||||||
|
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
||||||
|
onChange={this.handleToggleSaveToken} />
|
||||||
|
<label htmlFor="save-token">
|
||||||
|
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
|
||||||
|
description="Label for a checkbox" />
|
||||||
|
</label>
|
||||||
|
<a href="#reset">
|
||||||
|
<FormattedMessage id="forgot_password_link" defaultMessage="Forgot password?"
|
||||||
|
description="Link to Reset password form" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-buttons">
|
||||||
|
<button className={submitClasses} type="submit">
|
||||||
|
<FormattedMessage id="button_sign_in" defaultMessage="Sign in"
|
||||||
|
description="Button [Sign In]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* END Login */
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import LetterTile from './letter-tile.jsx';
|
||||||
|
|
||||||
|
import { AVATAR_SIZE } from '../config.js';
|
||||||
|
import { imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
|
|
||||||
|
export default class AvatarUpload extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
dataUrl: props.avatar
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleFileUpload = this.handleFileUpload.bind(this);
|
||||||
|
this.handleClear = this.handleClear.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(nextProps, prevState) {
|
||||||
|
if (prevState.dataUrl != nextProps.avatar) {
|
||||||
|
return {
|
||||||
|
dataUrl: nextProps.avatar
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileUpload(e) {
|
||||||
|
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
|
||||||
|
// Success
|
||||||
|
(base64bits, mime) => {
|
||||||
|
var du = makeImageUrl({data: base64bits, type: mime});
|
||||||
|
this.setState({dataUrl: du});
|
||||||
|
this.props.onImageChanged(du);
|
||||||
|
},
|
||||||
|
// Failure
|
||||||
|
(err) => {
|
||||||
|
this.props.onError(err, 'err');
|
||||||
|
});
|
||||||
|
// Clear the value so the same file can be uploaded again.
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClear() {
|
||||||
|
this.props.onImageChanged(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
// Randomize id value in case more than one AvatarUpload is shown
|
||||||
|
// at the same time.
|
||||||
|
var randId = "file-input-avatar-" + (Math.random() + '').substr(2);
|
||||||
|
return (
|
||||||
|
<div className="avatar-upload">
|
||||||
|
{this.props.readOnly || !this.state.dataUrl ?
|
||||||
|
null :
|
||||||
|
<a href="javascript:;" className="clear-avatar" onClick={this.handleClear}>
|
||||||
|
<i className="material-icons">clear</i>
|
||||||
|
</a>}
|
||||||
|
{this.state.dataUrl ?
|
||||||
|
<img src={this.state.dataUrl} className="preview" /> :
|
||||||
|
this.props.readOnly && this.props.uid ?
|
||||||
|
<div className="avatar-box">
|
||||||
|
<LetterTile
|
||||||
|
avatar={true}
|
||||||
|
topic={this.props.uid}
|
||||||
|
title={this.props.title} />
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
||||||
|
{this.props.readOnly ? null :
|
||||||
|
<input type="file" id={randId} className="inputfile hidden"
|
||||||
|
accept="image/*" onChange={this.handleFileUpload} />}
|
||||||
|
{this.props.readOnly ? null :
|
||||||
|
<label htmlFor={randId} className="round">
|
||||||
|
<i className="material-icons">file_upload</i>
|
||||||
|
</label>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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 */
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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} >×</a>
|
||||||
|
: <span className="spacer" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { REM_SIZE } from '../config.js';
|
||||||
|
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||||
|
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||||
|
|
||||||
|
export default class ImagePreview extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.setState({
|
||||||
|
width: this.container.clientWidth,
|
||||||
|
height: this.container.clientHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.props.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const instance = this;
|
||||||
|
const dim = fitImageSize(this.props.content.width, this.props.content.height,
|
||||||
|
this.state.width, this.state.height, false);
|
||||||
|
const size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
|
||||||
|
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
|
||||||
|
size.maxWidth = '100%';
|
||||||
|
size.maxHeight = '100%';
|
||||||
|
|
||||||
|
let filename = this.props.content.filename;
|
||||||
|
const maxlength = (this.props.content.width / REM_SIZE) | 0;
|
||||||
|
if (filename.length > maxlength) {
|
||||||
|
filename = filename.slice(0, maxlength-2) + '...' + filename.slice(2-maxlength);
|
||||||
|
}
|
||||||
|
const width = this.props.content.width || '-';
|
||||||
|
const height = this.props.content.height || '-';
|
||||||
|
return (
|
||||||
|
<div id="image-preview" onClick={this.props.onClose}>
|
||||||
|
<div id="image-preview-caption-panel">
|
||||||
|
<a href={this.props.content.url} download={this.props.content.filename}>
|
||||||
|
<i className="material-icons">file_download</i> <FormattedMessage
|
||||||
|
id="download_action" defaultMessage="download" description="Call to action [download]" />
|
||||||
|
</a>
|
||||||
|
<a href="javascript:;" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
|
||||||
|
</div>
|
||||||
|
<div id="image-preview-container" ref={function(ref) {instance.container = ref;}}>
|
||||||
|
<img src={this.props.content.url} style={size} />
|
||||||
|
</div>
|
||||||
|
<div id="image-preview-footer">
|
||||||
|
<div>
|
||||||
|
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
|
||||||
|
description="Label for a file name" /></b></div>
|
||||||
|
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||||
|
description="Label for file content type (mime)" /></b></div>
|
||||||
|
<div>{this.props.content.type}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||||
|
description="Label for file size" /></b></div>
|
||||||
|
<div>{width} × {height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default class MenuContacts extends React.PureComponent {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<a href="javascript:;" onClick={this.props.onNewTopic}><i className="material-icons">chat</i></a>
|
||||||
|
|
||||||
|
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default class MenuStart extends React.PureComponent {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<a href="javascript:;" onClick={this.props.onSignUp}><i className="material-icons">person_add</i></a>
|
||||||
|
|
||||||
|
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 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>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 621 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 319 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
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']
|
||||||
|
},
|
||||||
|
};
|
||||||