Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c73869af | ||
|
|
763a525292 | ||
|
|
f3e54e46b5 | ||
|
|
e73a920154 | ||
|
|
9c4db41b76 | ||
|
|
7ba20c78b1 | ||
|
|
a6c29e3e5d | ||
|
|
1d0c7e1c73 | ||
|
|
36aa610b8e | ||
|
|
8822c2892e | ||
|
|
ddfbfdcbb3 | ||
|
|
faf644d9dd | ||
|
|
c34ba46563 | ||
|
|
b783ec2949 | ||
|
|
0840a79024 | ||
|
|
4399428fdf | ||
|
|
e0e25fc6d3 | ||
|
|
25e9c1a885 | ||
|
|
b59fd06381 | ||
|
|
b540ac44df | ||
|
|
6c739d9c43 | ||
|
|
b2daf6b58e | ||
|
|
063245e665 | ||
|
|
e3bdd5bb45 | ||
|
|
122fd018d0 | ||
|
|
febc75da17 | ||
|
|
eff8bbabc5 | ||
|
|
c7f4fa0165 | ||
|
|
a0312f8b52 | ||
|
|
f8879f7b68 | ||
|
|
39c8b9a4a1 | ||
|
|
812cdd970c | ||
|
|
a24ee01b37 | ||
|
|
6b8595750a | ||
|
|
aa451946a5 | ||
|
|
d1ea1d7658 | ||
|
|
626484d8cb | ||
|
|
864753942f | ||
|
|
66c2db86d9 | ||
|
|
bf9ba90ed3 | ||
|
|
07aa38e162 | ||
|
|
181161250a | ||
|
|
019b270242 | ||
|
|
cf5fd9753c | ||
|
|
a465fcf935 | ||
|
|
2d01e0af85 | ||
|
|
3fcd118af8 | ||
|
|
cdf27b4d48 | ||
|
|
ab993e36b9 | ||
|
|
9277db4dcd | ||
|
|
9b76577bb9 | ||
|
|
c57fd6e539 | ||
|
|
2ffe42741d | ||
|
|
5d97746d9d | ||
|
|
e22a84c8ad | ||
|
|
2b12baa166 | ||
|
|
2ecb522b44 | ||
|
|
6527ae8613 | ||
|
|
10aa29e68b | ||
|
|
d944ddccd5 | ||
|
|
ef469ce419 | ||
|
|
c1b3040f8a | ||
|
|
bf0c818c28 | ||
|
|
92f7464477 | ||
|
|
034944877c | ||
|
|
694b93ebe8 | ||
|
|
29e5be4e01 | ||
|
|
02e23c885a | ||
|
|
74d5473a8b | ||
|
|
02e1a28e89 | ||
|
|
2c61ab26c8 | ||
|
|
eeb14cf752 | ||
|
|
e97e708c01 | ||
|
|
aa8d60cc1d | ||
|
|
62259ab331 | ||
|
|
c010d1d17a | ||
|
|
91e3f23d4e | ||
|
|
1d6b970d7b | ||
|
|
430b2db9aa | ||
|
|
4a480d71cc | ||
|
|
1537016942 | ||
|
|
1023e80fa6 | ||
|
|
91e1959833 | ||
|
|
0add66f607 | ||
|
|
273e34de2c | ||
|
|
7adc06332e | ||
|
|
271fbc4b02 | ||
|
|
f6acbaab21 | ||
|
|
8715251e9b | ||
|
|
8c5660173c | ||
|
|
52ec124cd6 | ||
|
|
cf423ff5eb | ||
|
|
bd24711785 | ||
|
|
304b5fac6e | ||
|
|
9cfbfe48ea | ||
|
|
9e32098b55 | ||
|
|
2a1814254c | ||
|
|
e0633bfa0d | ||
|
|
a33891a5a9 | ||
|
|
8504e4a155 | ||
|
|
390a380763 | ||
|
|
c01f80c248 | ||
|
|
7427c45c81 | ||
|
|
5175ac1dfe | ||
|
|
9e17dc73fe | ||
|
|
8843e562da | ||
|
|
5146a6c3d8 | ||
|
|
c22ee282e9 | ||
|
|
0b5ac811c4 | ||
|
|
096399fb0a | ||
|
|
e703490a1e | ||
|
|
6cd180771a | ||
|
|
beb861d707 | ||
|
|
d4f299aa40 | ||
|
|
c2b1433ed6 | ||
|
|
6157557a12 | ||
|
|
0443814f0c | ||
|
|
a5537a5664 | ||
|
|
ebb7537b2f | ||
|
|
efd1461102 | ||
|
|
7022cf1f64 | ||
|
|
6689e68a7a | ||
|
|
99423dd04f | ||
|
|
50344f3cd5 | ||
|
|
b410879ca9 | ||
|
|
d904af2d2c | ||
|
|
d28d498e03 | ||
|
|
c81cfa3af3 | ||
|
|
705932825a | ||
|
|
6ace7e72ab | ||
|
|
4d972791af | ||
|
|
3187625dc5 | ||
|
|
fb5447c92f | ||
|
|
55c24f884e | ||
|
|
5795e40573 | ||
|
|
f7652ec3c3 | ||
|
|
60b4ed034a | ||
|
|
189634657e | ||
|
|
dfcacdc392 | ||
|
|
25a22d7da9 | ||
|
|
9e574aa6c5 | ||
|
|
3144e7a721 | ||
|
|
bc3f13d360 | ||
|
|
010665284b | ||
|
|
6f47cc50b0 | ||
|
|
1172adefb4 | ||
|
|
4f38a2d8b1 | ||
|
|
d75448c548 | ||
|
|
9c69b7129c | ||
|
|
4ea82f85ba | ||
|
|
3132294cce | ||
|
|
6f278f5bb5 | ||
|
|
92e3f15c01 | ||
|
|
1f5a76e481 | ||
|
|
4445a90437 | ||
|
|
0afa8bcdcd | ||
|
|
55d7e0d5cb | ||
|
|
4b07c3b5c9 | ||
|
|
a319e2b0bc | ||
|
|
924459f84b | ||
|
|
396b23ee88 | ||
|
|
775bc954e3 | ||
|
|
6fabb6c111 | ||
|
|
e328e6a4c2 | ||
|
|
443245280f | ||
|
|
a248151243 | ||
|
|
e05e75ed28 | ||
|
|
4d450e6b85 | ||
|
|
b014592381 | ||
|
|
1d1c9400f8 | ||
|
|
97d4bcfba9 | ||
|
|
ed19ed750c | ||
|
|
93e7b69f88 | ||
|
|
af58db914b | ||
|
|
0c9e6379d6 | ||
|
|
311b36b977 | ||
|
|
d12fd0e16f | ||
|
|
a7998a1d29 | ||
|
|
231635f86d | ||
|
|
5574085a01 | ||
|
|
5d53899f89 | ||
|
|
97c8114fb7 | ||
|
|
28d825c681 | ||
|
|
cede800726 | ||
|
|
53c1f9fd0b | ||
|
|
796f16a003 | ||
|
|
738d84045d | ||
|
|
de1028e141 | ||
|
|
f9d622a8d8 | ||
|
|
fbdf1df9d0 | ||
|
|
09baee8d73 | ||
|
|
84d0ec5e9c | ||
|
|
4d9bc435fc | ||
|
|
7d08e65e64 | ||
|
|
6e19bf6add | ||
|
|
96ad18a902 | ||
|
|
58983588a4 | ||
|
|
9401a8d483 | ||
|
|
6019303f2c | ||
|
|
28fd6a7236 | ||
|
|
e8d48eb5f0 | ||
|
|
d91534236f | ||
|
|
4d3682c0ef | ||
|
|
45c6cf22ff | ||
|
|
c56b30337f | ||
|
|
ab128ba6bb | ||
|
|
b2bbe75f6d | ||
|
|
0f095ceeca | ||
|
|
331dea37cf | ||
|
|
af72df8a4f | ||
|
|
4f56dfc276 | ||
|
|
2817d83c56 | ||
|
|
571b876626 | ||
|
|
60867671aa | ||
|
|
1d46599a1f | ||
|
|
04734a7dd9 | ||
|
|
37351f9fdf | ||
|
|
79fba3a402 | ||
|
|
7c9cc230ac | ||
|
|
ff2f6b035a | ||
|
|
b16d538942 | ||
|
|
abd94d6858 | ||
|
|
6ab2d8663e | ||
|
|
2576fa2a87 | ||
|
|
d0627d5370 | ||
|
|
a192129ff9 | ||
|
|
ee6b87c3a9 | ||
|
|
7fccd41ced | ||
|
|
b95e3269ea | ||
|
|
38c0023e23 | ||
|
|
1efc9a90cb | ||
|
|
de04006afc | ||
|
|
d484bff407 | ||
|
|
7cd5f786b2 | ||
|
|
65c94c9f12 | ||
|
|
c3e5b1cabc | ||
|
|
ad97c5638f | ||
|
|
c9d05642e2 | ||
|
|
3410128d6f | ||
|
|
b7a8d4d09d | ||
|
|
919047cfde | ||
|
|
fbaf98c117 | ||
|
|
5d96331ef5 | ||
|
|
587d457712 | ||
|
|
979b283928 | ||
|
|
7c70dea398 | ||
|
|
3f04d0e23b | ||
|
|
d953f6ac3e | ||
|
|
e5e78755ae | ||
|
|
52c1016b45 | ||
|
|
c0a3ba6a92 | ||
|
|
6be7b87173 | ||
|
|
35a63ff5ab | ||
|
|
375750a6ff | ||
|
|
ea8ac3c293 | ||
|
|
5507ccd728 | ||
|
|
b96c8c2516 | ||
|
|
e04240870d | ||
|
|
506cd00b27 | ||
|
|
cdd40facec | ||
|
|
6222610836 | ||
|
|
16b5bdf37d | ||
|
|
6e39fe9bef | ||
|
|
78c2f1aaa1 | ||
|
|
947b166935 | ||
|
|
337afcf075 | ||
|
|
77e67bb714 | ||
|
|
dbcac419de | ||
|
|
475e5eb3e7 | ||
|
|
12f05e8570 | ||
|
|
527a01bebb | ||
|
|
ffe2ab7737 | ||
|
|
2fd8346dc5 | ||
|
|
fd4cb58f0c | ||
|
|
d2647d5f0c | ||
|
|
e27c106c33 | ||
|
|
e7840b05a6 | ||
|
|
3484c6549e | ||
|
|
30054aec04 | ||
|
|
fae274062a | ||
|
|
3b77af2a10 | ||
|
|
a4b757fa95 | ||
|
|
9d7e1d4f63 | ||
|
|
858e62f732 | ||
|
|
50a775fc72 | ||
|
|
feaefdb952 | ||
|
|
755ad00619 | ||
|
|
c6e440ca72 | ||
|
|
f34f786bc6 | ||
|
|
b2e7a4d63c | ||
|
|
f100385c79 | ||
|
|
0081ed3716 | ||
|
|
065115d2bf | ||
|
|
ed6518df9c | ||
|
|
f7a52d70d6 | ||
|
|
72d978ed55 | ||
|
|
90889454a0 | ||
|
|
d1c90f8434 | ||
|
|
8622926a4c | ||
|
|
2de35424b5 | ||
|
|
d603953a77 | ||
|
|
d0e3e238b8 | ||
|
|
83dfb5e9b2 | ||
|
|
727d926bf6 | ||
|
|
fe9f4bb17e | ||
|
|
1afd876d99 | ||
|
|
6fee1ce5ea | ||
|
|
7828c372e3 | ||
|
|
e02047345c | ||
|
|
db76ec3266 | ||
|
|
06de1c1fb6 | ||
|
|
a662787b9c | ||
|
|
bc4c0811d1 | ||
|
|
893cbdaa0c | ||
|
|
44c5e30f4d | ||
|
|
b81ec3e634 | ||
|
|
b48e4e63d9 | ||
|
|
dce1875de4 | ||
|
|
3392bf5ed7 | ||
|
|
99de97b2e1 | ||
|
|
d30b616b26 | ||
|
|
aa7cb55c8e | ||
|
|
9e756c6772 | ||
|
|
8ee50adf4d | ||
|
|
4c28753e6a | ||
|
|
1cdb8b724c | ||
|
|
6a2efac566 | ||
|
|
8c1b926008 | ||
|
|
c539e5f70d | ||
|
|
154ec5000e | ||
|
|
80c5103ce0 | ||
|
|
15c0ff2e75 | ||
|
|
a404734794 | ||
|
|
f962c8e8a9 | ||
|
|
bb20937bc9 | ||
|
|
3026e21b46 | ||
|
|
92b9faa063 | ||
|
|
a1894871aa | ||
|
|
bd03a7164e | ||
|
|
9891620188 | ||
|
|
afa0726bdf | ||
|
|
1a927e5420 | ||
|
|
5f4db76cbf | ||
|
|
4092324fea | ||
|
|
efc7377942 | ||
|
|
94f88855de | ||
|
|
73afafb605 | ||
|
|
fe556a0707 | ||
|
|
79d0c6dd14 | ||
|
|
8e9349b58b | ||
|
|
22710adbe3 | ||
|
|
b8a1b39bba | ||
|
|
ef341cb94e | ||
|
|
cda001e5c6 | ||
|
|
254c4768eb | ||
|
|
8f0125587f | ||
|
|
1aaf6f8fab | ||
|
|
1d56d55487 | ||
|
|
205ca95217 | ||
|
|
f9a29ed3ef | ||
|
|
f3420dc66f | ||
|
|
6d581c8e7c | ||
|
|
10bb206d51 | ||
|
|
a2747b6e33 | ||
|
|
b3b0ae59ab | ||
|
|
d99434000f | ||
|
|
2a164ddf49 | ||
|
|
12a0660e5b | ||
|
|
2502460143 | ||
|
|
c31631b69f | ||
|
|
711cf7c690 | ||
|
|
3669ed7645 | ||
|
|
41ebf3c705 | ||
|
|
6f359be64d | ||
|
|
d472a321de | ||
|
|
eda9f0f7f9 | ||
|
|
d9c729d9ab | ||
|
|
78b0909f02 | ||
|
|
61a9b6c56f | ||
|
|
a6bc2a1f8f | ||
|
|
503c9128d4 | ||
|
|
1b4bba051e | ||
|
|
c14fb857bc | ||
|
|
be528ff282 | ||
|
|
fb9461834a | ||
|
|
0217d84721 | ||
|
|
f1744c3f69 | ||
|
|
a79373c8cd | ||
|
|
d2975f0c20 | ||
|
|
d782fb8eab | ||
|
|
bdf152e5d6 | ||
|
|
40edad1c99 | ||
|
|
7239aae8aa | ||
|
|
81c789b713 | ||
|
|
744da99f30 | ||
|
|
112ce08fde | ||
|
|
3fd01648a8 | ||
|
|
619ec15920 | ||
|
|
a6a097b15f | ||
|
|
450a2f6975 | ||
|
|
e006043290 | ||
|
|
34db50280e | ||
|
|
6268fb68f8 | ||
|
|
9a310a0438 | ||
|
|
821ab6a16f | ||
|
|
f9e78bf280 | ||
|
|
83488ca878 | ||
|
|
53d748a7bd | ||
|
|
6cb2b77f69 | ||
|
|
d133287e7e | ||
|
|
3ef14a6fe6 | ||
|
|
f84096488a | ||
|
|
884a822408 | ||
|
|
fe000cec33 | ||
|
|
3202dcb1b0 | ||
|
|
eb20ebff6e | ||
|
|
a0e8aa5c54 | ||
|
|
00e2642aa9 | ||
|
|
0396a617c8 | ||
|
|
9a55e4b72e | ||
|
|
60a88bc3d5 | ||
|
|
e73c41f3c6 | ||
|
|
4b561de04c | ||
|
|
35ec5b98cf | ||
|
|
4bd0052d9f | ||
|
|
545b0c79cb | ||
|
|
c66bf89883 | ||
|
|
60fa6d7806 | ||
|
|
194306ac4f | ||
|
|
f29305b0af | ||
|
|
1ac74dbe36 | ||
|
|
c76953294c | ||
|
|
1458184fc2 | ||
|
|
14b63f4627 | ||
|
|
9492455bc3 | ||
|
|
32ad99128e | ||
|
|
64f1fd4380 | ||
|
|
6386fc7a46 | ||
|
|
29425b48fc | ||
|
|
a9ba11a0ff | ||
|
|
d13b24263e | ||
|
|
0f13f2edb9 | ||
|
|
a042db27e8 | ||
|
|
ff619da2b7 | ||
|
|
93e6d388b6 | ||
|
|
173d153b82 | ||
|
|
0ddb3a4f75 | ||
|
|
9193b9be19 | ||
|
|
79970cf558 | ||
|
|
5d17ef7039 | ||
|
|
7e0aa5b93b | ||
|
|
fd32413ba1 | ||
|
|
22b277a4ad | ||
|
|
da65e5663f | ||
|
|
76d5ee1ed8 | ||
|
|
e7d68b5052 | ||
|
|
1a17b01f0c | ||
|
|
d7fcd8c761 | ||
|
|
5e0cf3a2d2 | ||
|
|
636e38a6d7 | ||
|
|
17d7e94138 | ||
|
|
49af44d4ca | ||
|
|
588b157005 | ||
|
|
b5f926ef96 | ||
|
|
2e110a7a78 | ||
|
|
f710e5c76c | ||
|
|
bedcc9a923 | ||
|
|
a554c0a9e1 | ||
|
|
ca2b3624e5 | ||
|
|
87947b3de6 | ||
|
|
160e2574d4 | ||
|
|
a534387593 | ||
|
|
004dec20d3 | ||
|
|
68c0faf527 | ||
|
|
6bd64b2863 | ||
|
|
fc96d200ed | ||
|
|
7e30c2acc6 | ||
|
|
541ead2136 | ||
|
|
6388fedd70 | ||
|
|
f34c342db3 | ||
|
|
1883d35779 | ||
|
|
15793cad26 | ||
|
|
5e3d5a4be2 | ||
|
|
5f1c1f9f2e | ||
|
|
a4af2a53ac | ||
|
|
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,13 @@
|
||||
{
|
||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||
"comments": false,
|
||||
"plugins": [
|
||||
[
|
||||
"formatjs",
|
||||
{
|
||||
"idInterpolationPattern": "[sha512:contenthash:base64:6]",
|
||||
"ast": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
index.dev.js binary
|
||||
index.dev.js.map binary
|
||||
index.prod.js binary
|
||||
index.prod.js.map binary
|
||||
base.min.css binary
|
||||
tinode.dev.js binary
|
||||
tinode.prod.js binary
|
||||
package-lock.json binary
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve TinodeWeb
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
If you want to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. Mac OS, Windows 10 Home]
|
||||
- Browser [e.g. Chrome, Safari]
|
||||
- TinodeWeb Version [e.g. 0.16.6]
|
||||
|
||||
**Console log**
|
||||
Please attach or insert console log which illustrates the problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
If you want to ask a question, please post to https://groups.google.com/d/forum/tinode instead.
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -1,138 +1,3 @@
|
||||
# ---> 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.*
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
src/i18n/src/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "co.tinode.tindroidx",
|
||||
"sha256_cert_fingerprints":
|
||||
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"relation": ["delegate_permission/common.get_login_creds"],
|
||||
"target": {
|
||||
"namespace": "web",
|
||||
"site": "https://web.tinode.co"
|
||||
}
|
||||
},
|
||||
{
|
||||
"relation": ["delegate_permission/common.get_login_creds"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "co.tinode.tindroidx",
|
||||
"sha256_cert_fingerprints":
|
||||
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
|
||||
}
|
||||
}]
|
||||
@@ -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,83 @@
|
||||
# im_webapp
|
||||
# Tinode Web
|
||||
|
||||
## Briefing
|
||||
|
||||
Single-page web chat application for [Tinode](https://github.com/tinode/chat/). The app is built on
|
||||
[React](https://reactjs.org/). 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.
|
||||
|
||||
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
|
||||
|
||||
Try possibly newer or older version live at https://web.tinode.co/.
|
||||
|
||||
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
||||
|
||||
For demo access and other instructions [see here](https://github.com/tinode/chat/#demosandbox).
|
||||
|
||||
## Installing and running
|
||||
|
||||
This is NOT a standalone app, this is just a frontend, a client. It requires a [backend](https://github.com/tinode/chat/). See [installation instructions](https://github.com/tinode/chat/blob/master/INSTALL.md).
|
||||
|
||||
## Getting support
|
||||
|
||||
* Read [client-side](http://tinode.github.io/js-api/) and [server-side](https://github.com/tinode/chat/blob/master/docs/API.md) API documentation.
|
||||
* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
|
||||
* For bugs and feature requests [open an issue](https://github.com/tinode/webchat/issues/new).
|
||||
|
||||
## Internationalization
|
||||
|
||||
The app is fully internationalized using [React-Intl](https://github.com/formatjs/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `#?hl=XX` parameter to the URL when starting the app, e.g. https://web.tinode.co/#?hl=ru.
|
||||
|
||||
The app is currently available in the following languages:
|
||||
* English
|
||||
* Chinese (simplified)
|
||||
* Chinese (traditional)
|
||||
* German
|
||||
* Korean
|
||||
* Romanian
|
||||
* Russian
|
||||
* Spanish
|
||||
|
||||
More translations are welcome. See [instructions](https://github.com/tinode/chat/blob/devel/docs/translations.md#webapp).
|
||||
|
||||
|
||||
## Not done yet
|
||||
|
||||
* End-to-End encryption.
|
||||
* Emoji support is weak.
|
||||
* Mentions, hashtags.
|
||||
* Replying or forwarding messages.
|
||||
* Previews not generated for videos, audio, links or docs.
|
||||
|
||||
## Other
|
||||
|
||||
* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
|
||||
* Background patterns from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
|
||||
* [Fonts](https://fonts.google.com/) and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web) from Google under [license](https://developers.google.com/terms) which permits commercial and non-commercial use.
|
||||
* Push notifications built with https://firebase.google.com/.
|
||||
|
||||
## Push notifications
|
||||
|
||||
If you want to use the app with your own server and want web push notification to work you have to set them up:
|
||||
|
||||
* Register at https://firebase.google.com/, set up the project if you have not done so already.
|
||||
* Follow instructions to create a web application https://support.google.com/firebase/answer/9326094 in your project.
|
||||
* Follow instructions at https://support.google.com/firebase/answer/7015592 to get a Firebase configuration object ("Firebase SDK snippet").
|
||||
* Locate `firebase-init.js` in the root folder of your copy of TinodeWeb app. Copy-paste the following keys from the configuration object to `firebase-init.js`: `apiKey`, `messagingSenderId`, `projectId`, `appId` (you may copy all keys).
|
||||
* Copy `Web Push certificate` (Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates) to `messagingVapidKey` field in `firebase-init.js`.
|
||||
* Double check that `firebase-init.js` contains the following keys: `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. The file may contain other optional keys.
|
||||
* Copy Google-provided server key to `tinode.conf`, see details [here](https://github.com/tinode/chat/blob/master/docs/faq.md#q-how-to-setup-fcm-push-notifications).
|
||||
|
||||
## Responsive design
|
||||
|
||||
### Desktop screenshot
|
||||
|
||||
<p align="center">
|
||||
<img src="web-desktop.jpg" alt="Desktop web: full app" width=810 />
|
||||
</p>
|
||||
|
||||
### Mobile screenshots
|
||||
|
||||
<p align="center">
|
||||
<kbd><img src="web-mob-contacts.png" alt="Mobile web: contacts" width=250 /></kbd> <kbd><img src="web-mob-chat.png" alt="Mobile web: chat" width=250 /></kbd> <kbd><img src="web-mob-info.png" alt="Mobile web: topic info" width=250 /></kbd> <kbd><img src="web-mob-new-chat.png" alt="Mobile web: start new chat" width=250 /></kbd> <kbd><img src="web-mob-acc-settings.png" alt="Mobile web: account settings" width=250 /></kbd>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Get these values for your project from the https://console.firebase.google.com/
|
||||
const FIREBASE_INIT = {
|
||||
// Set up a web application in console then get the config object.
|
||||
// The following keys are copied from the object:
|
||||
apiKey: "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ",
|
||||
messagingSenderId: "114126160546",
|
||||
projectId: "tinode-1000",
|
||||
appId: "1:114126160546:web:aca6ea2981feb81fb44dfb",
|
||||
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
|
||||
// This value IS NOT included in the generated js config you get from
|
||||
// the firebase console. It needs to be added separately.
|
||||
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
|
||||
};
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192" height="192"><defs><filter id="f" color-interpolation-filters="sRGB"><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-1" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="fbSourceGraphic"/><feColorMatrix result="fbSourceGraphicAlpha" in="fbSourceGraphic" values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"/><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in2="fbSourceGraphic" in="flood" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-3" result="offset"/><feComposite in2="offset" in="fbSourceGraphic" result="composite2"/></filter><filter id="d" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#FFF" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="b" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="c" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><linearGradient id="a"><stop offset="0" stop-opacity=".251"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="102.814" y1="97.076" x2="219.019" y2="170.481" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.29306 0 0 1.00596 -43.959 .807)"/></defs><g fill-rule="evenodd"><path d="M14.102 64A88 87.925 0 008 95.924c-.012 44.455 18.485 70.138 1.863 79.822-12.733 7.419 43.153 8.258 70.137 8.248V64H14.102z" fill="#fc5d3e" filter="url(#b)"/><path d="M80 64v119.994c8.24-.003 14.055-.08 14.055-.08L96 183.85a88 87.925 0 0088-87.926A88 87.925 0 00177.99 64H80z" fill="#45ae49" filter="url(#c)"/><path d="M96 8A88 87.925 0 009.52 80h172.978A88 87.925 0 0096 8z" fill="#2d526b" filter="url(#d)"/></g><path d="M88.002 40.975l-8.6 112.808 39.791 26.94c34.169-9.369 59.381-38.298 63.971-73.4z" fill="url(#e)" fill-rule="evenodd" opacity=".5"/><text y="153.195" x="53.125" style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" font-weight="700" font-size="160" font-family="Arial" letter-spacing="0" word-spacing="0"><tspan style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" y="153.195" x="53.125" fill="#fff">t</tspan></text></svg>
|
||||
|
After Width: | Height: | Size: 3.2 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 |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,53 @@
|
||||
<!-- DEVELOPMENT version of the web app. -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Tinode</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="application-name" content="Tinode Web, version for developers" />
|
||||
<meta name="description" content="Tinode instant messenger, development version" />
|
||||
<meta name="application-url" content="https://web.tinode.co/">
|
||||
<!-- Browser styling for Chrome, FF, Opera -->
|
||||
<meta name="theme-color" content="#3949AB" />
|
||||
<!-- Ask Google not to offer page translation -->
|
||||
<meta name="google" value="notranslate" />
|
||||
<!-- Open Graph description -->
|
||||
<meta name="og:url" content="https://web.tinode.co/">
|
||||
<meta name="og:title" content="Tinode Web Development">
|
||||
<meta name="og:image" content="img/og-logo.jpeg">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
||||
<link rel="canonical" href="https://web.tinode.co/" />
|
||||
<!-- Google's Roboto font -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
||||
<!-- Google's material design icons -->
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<!-- Styles for local elements -->
|
||||
<link rel="stylesheet" href="css/base.css" />
|
||||
<!-- ReactJS -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17/umd/react.development.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.development.js"></script>
|
||||
<!-- React-Intl for translations -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5/react-intl.iife.js"></script>
|
||||
<!-- Firebase for push notifications -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/firebase-messaging.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
||||
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
||||
then <a href="">try again</a>.
|
||||
</div>
|
||||
</div></noscript>
|
||||
<div id="mountPoint"></div>
|
||||
<script src="firebase-init.js"></script>
|
||||
<script src="umd/tinode.dev.js"></script>
|
||||
<script src="umd/index.dev.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tinode</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="application-name" content="Tinode Web" />
|
||||
<meta name="description" content="Tinode instant messaging in a browser" />
|
||||
<meta name="application-url" content="https://web.tinode.co/">
|
||||
<meta name="theme-color" content="#3949AB" />
|
||||
<meta name="google" value="notranslate" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="og:url" content="https://web.tinode.co/">
|
||||
<meta name="og:title" content="Tinode Web">
|
||||
<meta name="og:image" content="img/og-logo.jpeg">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
||||
<link rel="canonical" href="https://web.tinode.co/" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<link rel="stylesheet" href="css/base.min.css" />
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@5.20.7/react-intl.iife.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-messaging.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
||||
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
||||
then <a href="">try again</a>.</div></div></noscript>
|
||||
<div id="mountPoint"></div>
|
||||
<script src="firebase-init.js"></script>
|
||||
<script src="umd/tinode.prod.js"></script>
|
||||
<script src="umd/index.prod.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Tinode",
|
||||
"short_name": "Tinode",
|
||||
"description": "Tinode Web App.\nSee more at https://tinode.co/",
|
||||
"categories": ["chat", "communication", "productivity"],
|
||||
"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",
|
||||
"prefer_related_applications": true,
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=co.tinode.tindroidx",
|
||||
"id": "co.tinode.tindroidx"
|
||||
},
|
||||
{
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.17.10",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/example-react-js.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run vers && npm run build:css && npm run extract && npm run build:i18n && npm run build:prod && npm run build:dev",
|
||||
"build:css": "postcss css/base.css > css/base.min.css",
|
||||
"build:dev": "webpack --mode development",
|
||||
"build:prod": "webpack --mode production",
|
||||
"build:i18n": "node scripts/pack-messages.js",
|
||||
"vers": "node scripts/gen-version.js",
|
||||
"extract": "formatjs extract src/**/*.jsx --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
||||
"compile": "formatjs compile"
|
||||
},
|
||||
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
|
||||
"files": [
|
||||
"umd/index.prod.js",
|
||||
"umd/index.prod.js.map",
|
||||
"umd/index.dev.js",
|
||||
"umd/index.dev.js.map",
|
||||
"umd/tinode.prod.js",
|
||||
"umd/tinode.dev.js",
|
||||
"audio/msg.mp3",
|
||||
"css/base.css",
|
||||
"css/base.min.css",
|
||||
"img/*.png",
|
||||
"img/*.svg",
|
||||
"img/*.jpeg",
|
||||
"index-dev.html",
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"service-worker.js",
|
||||
"firebase-init.js",
|
||||
"version.js"
|
||||
],
|
||||
"keywords": [
|
||||
"chat",
|
||||
"instant-messenger",
|
||||
"message",
|
||||
"messenger",
|
||||
"react",
|
||||
"web"
|
||||
],
|
||||
"locales": [
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"ko",
|
||||
"ro",
|
||||
"ru",
|
||||
"zh",
|
||||
"zh-TW"
|
||||
],
|
||||
"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": "^8.9.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"tinode-sdk": "^0.17.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-env": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@formatjs/cli": "^4.2.20",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-formatjs": "^10.2.19",
|
||||
"browserslist": "^4.16.5",
|
||||
"copy-webpack-plugin": "^9.0.0",
|
||||
"cssnano": "^5.0.6",
|
||||
"glob": "^7.1.7",
|
||||
"mkdirp": "^1.0.4",
|
||||
"postcss": "^8.3.2",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"webpack": "^5.38.1",
|
||||
"webpack-cli": "^4.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('cssnano')({
|
||||
preset: 'default',
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const fs = require('fs');
|
||||
const version = require('../package.json').version;
|
||||
|
||||
function writeVersion(fileName, useExport) {
|
||||
fs.writeFile(fileName,
|
||||
'// This is a generated file. Don\'t edit.\n\n'+
|
||||
`${useExport ? 'export ' : ''}const PACKAGE_VERSION = "${version}";\n`,
|
||||
{ flag: 'w' },
|
||||
(err) => {
|
||||
if (err) {
|
||||
return console.log(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
writeVersion('./src/version.js', true);
|
||||
writeVersion('./version.js', false);
|
||||
@@ -0,0 +1,100 @@
|
||||
// 1. Takes file with messages extracted via the React Intl Babel plugin and converts it into a file containing
|
||||
// `id: message` pairs in "en" locale.
|
||||
//
|
||||
// 2. Takes all translations and groups them into a single file.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const globSync = require('glob').sync;
|
||||
const mkdirpSync = require('mkdirp').sync;
|
||||
|
||||
const EXTRACTED_STRINGS = './src/i18n/ex/base-en.json';
|
||||
const DEFAULT_TRANSLATION = 'en.json';
|
||||
const LANG_DIR = './src/i18n/';
|
||||
const LANG_PATTERN = './src/i18n/*.json';
|
||||
const FINAL_FILE = './src/messages.json';
|
||||
const TRANSLATIONS = './src/translations.js'
|
||||
|
||||
const MISSING_MARKER = ':!![MISSING]!!:';
|
||||
const OBSOLETE_MARKER = ':!![OBSOLETE]!!:';
|
||||
|
||||
// The formatjs/cli saves extracted messages into a file {'message_id': {defaultMessage: 'default message text', description: '...'}, ...}.
|
||||
// Rewrite them into a single file en.json {'message_id': 'default message text', }.
|
||||
|
||||
// Read master-set of messages in default language.
|
||||
const baseMessages = JSON.parse(fs.readFileSync(EXTRACTED_STRINGS, 'utf8'));
|
||||
// Convert.
|
||||
const en = {};
|
||||
for (const [id, msg] of Object.entries(baseMessages)) {
|
||||
en[id] = msg.defaultMessage;
|
||||
}
|
||||
|
||||
// Write extracted strings into default language file.
|
||||
let filename = path.join(LANG_DIR, DEFAULT_TRANSLATION);
|
||||
mkdirpSync(path.dirname(filename));
|
||||
fs.writeFileSync(filename, JSON.stringify(en, 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(baseMessages).forEach((ent) => {
|
||||
if (!translated.hasOwnProperty(ent[0])) {
|
||||
// Missing translation.
|
||||
translated[ent[0]] = MISSING_MARKER + ent[1];
|
||||
} else if (translated[ent[0]].indexOf(OBSOLETE_MARKER) === 0) {
|
||||
// No longer obsolete. Remove obsolete marker.
|
||||
translated[ent[0]] = translated[ent[0]].substring(0, OBSOLETE_MARKER.length);
|
||||
}
|
||||
});
|
||||
// Find no longer needed messages.
|
||||
Object.entries(translated).forEach((ent) => {
|
||||
if (!baseMessages.hasOwnProperty(ent[0]) && translated.hasOwnProperty(ent[0])) {
|
||||
let val = translated[ent[0]];
|
||||
if (val.indexOf(MISSING_MARKER) === 0) {
|
||||
// No longer missing.
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
// List of all available translations.
|
||||
const languageList = {};
|
||||
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||
// while removing messages marked as missing or obsolete.
|
||||
|
||||
// Read all translations.
|
||||
const 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'));
|
||||
languageList[locale] = locale;
|
||||
// Strip messages prefixed with MISSING or OBSOLETE markers.
|
||||
for (let id in translated) {
|
||||
// process.stdout.write("-- handling translation " + id + "; "+ String(translated[id]) + "; in " + String(filename) + "; --\n");
|
||||
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,161 @@
|
||||
// Must be located at the root.
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-app.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@8.9.1/firebase-messaging.js');
|
||||
importScripts('firebase-init.js');
|
||||
importScripts('version.js');
|
||||
|
||||
// Channel to notify the webapp. There is no BroadcastChannel in Safari.
|
||||
const webAppChannel = (typeof BroadcastChannel == 'function') ?
|
||||
new BroadcastChannel('tinode-sw') : null;
|
||||
|
||||
// Basic internationalization.
|
||||
const i18n = {
|
||||
'de': {
|
||||
'new_message': "Neue Nachricht",
|
||||
'new_chat': "Neuer Chat",
|
||||
},
|
||||
'en': {
|
||||
'new_message': "New message",
|
||||
'new_chat': "New chat",
|
||||
},
|
||||
'es': {
|
||||
'new_message': "Nuevo mensaje",
|
||||
'new_chat': "Nueva conversación",
|
||||
},
|
||||
'ko': {
|
||||
'new_message': "새로운 메시지",
|
||||
'new_chat': "새로운 채팅",
|
||||
},
|
||||
'ro': {
|
||||
'new_message': "Mesaj nou",
|
||||
'new_chat': "Chat nou",
|
||||
},
|
||||
'ru': {
|
||||
'new_message': "Новое сообщение",
|
||||
'new_chat': "Новый чат",
|
||||
},
|
||||
'zh': {
|
||||
'new_message': "新讯息",
|
||||
'new_chat': "新聊天",
|
||||
},
|
||||
'zh-TW': {
|
||||
'new_message': "新訊息",
|
||||
'new_chat': "新聊天",
|
||||
}
|
||||
};
|
||||
self.i18nMessage = function(id) {
|
||||
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
|
||||
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
|
||||
// Try finding string by id in the specified language, if missing try English, otherwise use the id itself
|
||||
// as the last resort.
|
||||
return lang[id] || i18n['en'][id] || id;
|
||||
}
|
||||
|
||||
firebase.initializeApp(FIREBASE_INIT);
|
||||
const fbMessaging = firebase.messaging();
|
||||
|
||||
// This method shows the push notifications while the window is in background.
|
||||
fbMessaging.onBackgroundMessage((payload) => {
|
||||
if (payload.data.silent == 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify webapp that a message was received.
|
||||
if (webAppChannel) {
|
||||
webAppChannel.postMessage(payload.data);
|
||||
}
|
||||
|
||||
const pushType = payload.data.what || 'msg';
|
||||
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
|
||||
const options = {
|
||||
body: payload.data.content || '',
|
||||
icon: '/img/logo96.png',
|
||||
badge: '/img/badge96.png',
|
||||
tag: payload.data.topic || undefined,
|
||||
data: {
|
||||
topic: payload.data.topic
|
||||
}
|
||||
};
|
||||
return self.registration.showNotification(title, options);
|
||||
});
|
||||
|
||||
// Update service worker immediately for both the current client
|
||||
// and all other active clients.
|
||||
self.addEventListener('install', event => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// This code handles a click on notification: takes
|
||||
// the user to the browser tab with the chat or opens a new tab.
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const notification = event.notification;
|
||||
notification.close();
|
||||
|
||||
const urlHash = '#/' + notification.data.topic;
|
||||
|
||||
event.waitUntil(self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
}).then((windowClients) => {
|
||||
let anyClient = null;
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const url = new URL(windowClients[i].url);
|
||||
if (url.hash.includes(notification.data.topic)) {
|
||||
// Found the Tinode tab with the right topic open.
|
||||
return windowClients[i].focus();
|
||||
} else {
|
||||
// This will be the least recently used tab.
|
||||
anyClient = windowClients[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Found tab with Tinode on a different topic,
|
||||
// navigate to the right topic.
|
||||
if (anyClient) {
|
||||
const url = new URL(anyClient.url);
|
||||
url.hash = urlHash;
|
||||
return anyClient.focus().then(thisClient => {
|
||||
return thisClient.navigate(url);
|
||||
});
|
||||
}
|
||||
|
||||
// Did not find a Tinode browser tab. Open one.
|
||||
const url = new URL(self.location.origin);
|
||||
url.hash = urlHash;
|
||||
return clients.openWindow(url);
|
||||
}));
|
||||
});
|
||||
|
||||
// This is needed for 'Add to Home Screen'.
|
||||
self.addEventListener('fetch', event => {
|
||||
if (event.request.method != 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith((async () => {
|
||||
// Try to find the response in the cache.
|
||||
const cache = await caches.open(PACKAGE_VERSION);
|
||||
const cachedResponse = await cache.match(event.request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
// Not found in cache.
|
||||
const response = await fetch(event.request);
|
||||
if (!response || response.status != 200 || response.type != 'basic') {
|
||||
return response;
|
||||
}
|
||||
if (event.request.url && (event.request.url.startsWith('http://') || event.request.url.startsWith('https://'))) {
|
||||
await cache.put(event.request, response.clone());
|
||||
}
|
||||
return response;
|
||||
})());
|
||||
});
|
||||
|
||||
// This code gets the human language from the webapp.
|
||||
self.addEventListener('message', event => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// The locale is used for selecting strings in an appropriate language.
|
||||
self.locale = data.locale || '';
|
||||
self.baseLocale = self.locale.toLowerCase().split(/[-_]/)[0];
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PACKAGE_VERSION } from './version.js';
|
||||
|
||||
// Name of this application, used in the User-Agent.
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.17');
|
||||
|
||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
||||
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
|
||||
// The array of possible hosts to connect to.
|
||||
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
|
||||
|
||||
// Default host name and port to connect to.
|
||||
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
||||
|
||||
// Enable console logging.
|
||||
export const LOGGING_ENABLED = true;
|
||||
|
||||
// Minimum time between two keypress notifications, milliseconds.
|
||||
export const KEYPRESS_DELAY = 3*1000;
|
||||
// Delay before sending a {note} for reciving a message, milliseconds.
|
||||
export const RECEIVED_DELAY = 500;
|
||||
// Delay before sending a read notification, milliseconds.
|
||||
export const READ_DELAY = 1000;
|
||||
|
||||
// The default shortest allowed tag length. Matches the value on the server.
|
||||
export const MIN_TAG_LENGTH = 2;
|
||||
// The default greatest allowed tag length. Matches the value on the server.
|
||||
export const MAX_TAG_LENGTH = 96;
|
||||
// The default maximum number of tags allowed. Matches the value on the server.
|
||||
export const MAX_TAG_COUNT = 16;
|
||||
|
||||
// Access mode for P2P subscriptions initiated by the current user.
|
||||
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
|
||||
// Access mode for new group topics created by the current user.
|
||||
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
|
||||
// Access mode for a channel.
|
||||
export const CHANNEL_ACCESS_MODE = 'JR';
|
||||
|
||||
// Access mode for no access.
|
||||
export const NO_ACCESS_MODE = 'N';
|
||||
|
||||
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
|
||||
// in @media (max-size: 640px) in base.css
|
||||
export const MEDIA_BREAKPOINT = 640;
|
||||
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
|
||||
export const REM_SIZE = 13;
|
||||
|
||||
// Size of the avatar image: When an avatar image is uploaded, it's resized to
|
||||
// a square of this size.
|
||||
export const AVATAR_SIZE = 128;
|
||||
|
||||
// Size of the broken_image shown in MessagesView
|
||||
export const BROKEN_IMAGE_SIZE = 32;
|
||||
|
||||
// Number of chat messages to fetch in one call.
|
||||
export const MESSAGES_PAGE = 24;
|
||||
|
||||
// Default maximum in-band (included directly into the message) attachment size which fits into
|
||||
// a message of 256K in size. Used when the server-provided value is unavailable. The actual
|
||||
// binary size of the attachment should be smaller due to base64 encoding expansion and some overhead,
|
||||
// for instance 1024 bytes.
|
||||
// Increase this limit to a greater value in production, if desired. Also increase
|
||||
// max_message_size in the server config.
|
||||
export const MAX_INBAND_ATTACHMENT_SIZE = 262144;
|
||||
|
||||
// Default absolute maximum attachment size to be used with the server = 8MB.
|
||||
// Used when the server-provided value is unavailable. Increase to something like 100MB in production.
|
||||
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
|
||||
|
||||
// Maximum allowed linear dimension (pixels) of an image sent inline. Larger images will be shrunk
|
||||
// to make the larger dimension fit under this size. You may want to adjust it to 1600 or
|
||||
// 2400 for production.
|
||||
export const MAX_IMAGE_DIM = 1024;
|
||||
|
||||
// Linear dimensions of image preview: shrink image under this size for previews.
|
||||
export const IMAGE_PREVIEW_DIM = 64;
|
||||
|
||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
||||
// hidden under "+X more"
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
|
||||
// Maximum length of user name, topic title, and private comment.
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
|
||||
// Length of message previews in chat list, in characters.
|
||||
export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
|
||||
// Link for "Contact Us".
|
||||
export const LINK_CONTACT_US = 'email:support@tinode.co';
|
||||
|
||||
// Link to Privacy Policy.
|
||||
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
|
||||
|
||||
// Link to Terms of Service.
|
||||
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"validate_credential_action": "bestätigen",
|
||||
"title_tag_manager": "Schlagworte (Nutzer entdecken)",
|
||||
"label_user_contacts": "Kontakte:",
|
||||
"button_add_another": "Hinzufügen",
|
||||
"label_message_sound": "Benachrichtigungston:",
|
||||
"label_push_notifications": "Benachrichtigungsmeldungen:",
|
||||
"label_push_notifications_disabled": "Benachrichtigungsmeldungen (erfordert HTTPS):",
|
||||
"label_incognito_mode": "inkognito-Modus:",
|
||||
"delete_account": "Konto löschen",
|
||||
"delete_account_arning": "Möchten Sie das Konto wirklich löschen? Das kann nicht rückgängig gemacht werden",
|
||||
"label_password": "Passwort",
|
||||
"password_unchanged_prompt": "unverändert",
|
||||
"button_logout": "Abmelden",
|
||||
"button_delete_account": "Konto löschen",
|
||||
"label_default_access_mode": "Standard Zugriffsmodus:",
|
||||
"blocked_contacts_link": "Blockierte Kontakte ({count})",
|
||||
"link_contact_us": "Kontakt",
|
||||
"link_terms_of_service": "Nutzungsbedingungen",
|
||||
"link_privacy_policy": "Datenschutzerklärung",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server_address": "Server Adresse:",
|
||||
"archived_contacts": "Archivierte Kontakte ({count})",
|
||||
"contacts_not_found": "Sie haben keine Unterhaltungen<br />¯∖_(ツ)_/¯",
|
||||
"full_name_prompt": "Vollständiger Name, z.B. Lisa Musterfrau",
|
||||
"email_prompt": "E-Mail, z.B. lisam@beispiel.de",
|
||||
"button_sign_up": "Anmelden",
|
||||
"label_your_name": "Ihr Name",
|
||||
"label_user_id": "ID:",
|
||||
"button_edit": "Bearbeiten",
|
||||
"requested_permissions": "Angefordert",
|
||||
"granted_permissions": "Erteilt",
|
||||
"menu_item_edit_permissions": "Berechtigungen ändern",
|
||||
"label_other_user": "Andere",
|
||||
"action_clear_messages": "Nachrichten leeren",
|
||||
"clear_messages_warning": "Sind Sie sicher, dass Sie diese Unterhaltung für alle leeren wollen? Das kann nicht rückgängig gemacht werden.",
|
||||
"action_delete_messages": "Nachrichten für alle löschen",
|
||||
"delete_messages_warning": "Sind Sie sicher, dass Sie diese Unterhaltung für alle löschen wollen? Das kann nicht rückgängig gemacht werden.",
|
||||
"action_leave_chat": "Unterhaltung verlassen",
|
||||
"leave_chat_warning": "Möchten Sie diese Unterhaltung wirklich verlassen?",
|
||||
"action_block_contact": "Kontakt blockieren",
|
||||
"block_contact_warning": "Möchten Sie diesen Kontakt wirklich blockieren?",
|
||||
"action_report_chat": "Unterhaltung melden",
|
||||
"report_chat_warning": "Möchten Sie diese Unterhaltung wirklich blockieren und melden?",
|
||||
"title_info": "Info",
|
||||
"label_topic_name": "Name",
|
||||
"label_private": "Privater Kommentar",
|
||||
"private_editing_placeholder": "Nur für Sie sichtbar",
|
||||
"label_muting_topic": "Stumm geschaltet:",
|
||||
"action_more": "Mehr",
|
||||
"label_your_permissions": "Ihre Berechtigungen:",
|
||||
"label_permissions": "Berechtigungen:",
|
||||
"label_you": "Sie:",
|
||||
"label_default_access": "Standard Zugriffsmodus:",
|
||||
"label_group_members": "Gruppenmitglieder:",
|
||||
"button_add_members": "Mitglieder hinzufügen",
|
||||
"group_has_no_members": "Keine Mitglieder",
|
||||
"login_prompt": "Anmelden",
|
||||
"password_prompt": "Passwort",
|
||||
"stay_logged_in": "Angemeldet bleiben",
|
||||
"forgot_password_link": "Passwort vergessen?",
|
||||
"button_sign_in": "Anmelden",
|
||||
"label_client": "Client:",
|
||||
"label_server": "Server:",
|
||||
"online_now": "jetzt online",
|
||||
"last_seen_timestamp": "Zuletzt gesehen",
|
||||
"title_not_found": "Nicht gefunden",
|
||||
"unnamed_topic": "Unbenannt",
|
||||
"messages_not_readable": "Neue Nachrichten können nicht gelesen werden",
|
||||
"peers_messaging_disabled": "Gruppennachrichten sind deaktiviert",
|
||||
"enable_peers_messaging": "Aktivieren",
|
||||
"search_for_contacts": "Nutzen Sie die Suche um Kontakte zu finden",
|
||||
"search_no_results": "Die Suche hatte keine Ergebnisse",
|
||||
"tabtitle_find_user": "Suchen",
|
||||
"tabtitle_new_group": "Neue Gruppe",
|
||||
"tabtitle_group_by_id": "nach ID",
|
||||
"new_password_placeholder": "Geben Sie ein neues Passwort ein",
|
||||
"label_reset_password": "Passwort per E-Mail wiederherstellen:",
|
||||
"credential_email_prompt": "E-Mail Adresse für Registrierung",
|
||||
"button_reset": "Zurücksetzen",
|
||||
"button_send_request": "Anfrage senden",
|
||||
"label_server_to_use": "Server verwenden:",
|
||||
"label_wire_transport": "Übertragung per Kabel:",
|
||||
"button_update": "Aktualisieren",
|
||||
"sidepanel_title_login": "Anmelden",
|
||||
"sidepanel_title_register": "Konto erstellen",
|
||||
"sidepanel_title_settings": "Einstellungen",
|
||||
"sidepanel_title_account_settings": "Konto-Einstellungen",
|
||||
"sidepanel_title_acc_general": "Allgemein",
|
||||
"sidepanel_title_acc_security": "Sicherheit",
|
||||
"sidepanel_title_acc_notifications": "Benachrichtigungen",
|
||||
"sidepanel_title_acc_support": "Unterstützung",
|
||||
"sidepanel_title_newtpk": "Neue Unterhaltung starten",
|
||||
"sidepanel_title_cred": "Anmeldeinformationen bestätigen",
|
||||
"sidepanel_title_reset": "Passwort zurücksetzen",
|
||||
"sidepanel_title_archive": "Archivierte Unterhaltungen",
|
||||
"sidepanel_title_blocked": "Blockierte Unterhaltungen",
|
||||
"reconnect_countdown": "Getrennt. Wiederverbinden in {seconds}…",
|
||||
"reconnect_now": "Jetzt probieren",
|
||||
"push_init_failed": "Initialisierung von Push-Benachrichtigungen fehlgeschlagen",
|
||||
"invalid_security_token": "Ungültiger Sicherheitsschlüssel",
|
||||
"no_connection": "Keine Verbindung",
|
||||
"code_doesnot_match": "Code stimmt nicht überein",
|
||||
"update_available": "Aktualisierung verfügbar.",
|
||||
"reload_update": "Neu laden",
|
||||
"phone_dative": "Telefon",
|
||||
"email_dative": "E-Mail",
|
||||
"enter_confirmation_code_prompt": "Geben Sie den Bestätigungscode ein, der per {method} geschickt wurde:",
|
||||
"numeric_confirmation_code_prompt": "Nur zahlen",
|
||||
"button_confirm": "Bestätigen",
|
||||
"save_attachment": "Speichern",
|
||||
"deleted_content": "Inhalt gelöscht",
|
||||
"invalid_content": "ungültiger Inhalt",
|
||||
"user_not_found": "Nicht gefunden",
|
||||
"badge_you": "Sie",
|
||||
"badge_owner": "Besitzer",
|
||||
"menu_item_info": "Info",
|
||||
"menu_item_clear_messages": "Nachrichten leeren",
|
||||
"menu_item_clear_messages_for_all": "Für alle leeren",
|
||||
"menu_item_delete": "Löschen",
|
||||
"menu_item_delete_for_all": "Für alle löschen",
|
||||
"menu_item_send_retry": "Wiederholen",
|
||||
"menu_item_mute": "Unterhaltung stumm schalten",
|
||||
"menu_item_unmute": "Stumm schalten beenden",
|
||||
"menu_item_delete_topic": "Entfernen",
|
||||
"topic_delete_warning": "Möchten Sie diese Unterhaltung wirklich löschen?",
|
||||
"menu_item_unblock": "Blockierung aufheben",
|
||||
"menu_item_block": "Blockieren",
|
||||
"topic_block_warning": "Möchten Sie diese Unterhaltung wirklich blockieren?",
|
||||
"menu_item_member_delete": "Entfernen",
|
||||
"menu_item_archive_topic": "Archivieren",
|
||||
"action_cancel": "Abbrechen",
|
||||
"upload_finishing": "wird abgeschlossen...",
|
||||
"no_contacts": "Sie haben keine Kontakte :-(",
|
||||
"contacts_not_found_short": "Keine Kontakte für ''{query}''",
|
||||
"title_group_members": "Gruppenmitglieder",
|
||||
"title_all_contacts": "Alle Kontakte",
|
||||
"button_ok": "OK",
|
||||
"button_cancel": "Abbrechen",
|
||||
"more_online_members": "+{overflow} mehr",
|
||||
"download_action": "herunterladen",
|
||||
"label_file_name": "Dateiname:",
|
||||
"label_content_type": "Inhaltsart:",
|
||||
"label_size": "Größe:",
|
||||
"chat_invitation": "Sie sind eingeladen, an einem neuen Chat teilzunehmen. Was möchten Sie tun?",
|
||||
"chat_invitation_accept": "Akzeptieren",
|
||||
"chat_invitation_ignore": "Ignorieren",
|
||||
"chat_invitation_block": "Blockieren",
|
||||
"error_invalid_id": "ungültige ID",
|
||||
"group_user_id_prompt": "Gruppe oder Benutzer ID",
|
||||
"button_subscribe": "Abbonieren",
|
||||
"topic_name_editing_placeholder": "Freiform Name der Gruppe",
|
||||
"button_create": "Erstellen",
|
||||
"permission_join": "Beitreten ({val})",
|
||||
"permission_read": "Lesen ({val})",
|
||||
"permission_write": "Schreiben ({val})",
|
||||
"permission_pres": "Benachrichtigt werden ({val})",
|
||||
"permission_admin": "Bestätigen ({val})",
|
||||
"permission_share": "Teilen ({val})",
|
||||
"permission_delete": "Entfernen ({val})",
|
||||
"permission_owner": "Besitzer ({val})",
|
||||
"title_permissions": "Berechtigungen",
|
||||
"message_sending": "wird gesendet...",
|
||||
"message_sending_failed": "fehlgeschlagen",
|
||||
"search_placeholder": "Liste: email:lisa@beispiel.de, tel:17025550003...",
|
||||
"messaging_disabled_prompt": "Nachrichtenübermittlung deaktiviert",
|
||||
"new_message_prompt": "Neue Nachricht",
|
||||
"image_caption_prompt": "Bildunterschrift",
|
||||
"file_attachment_too_large": "Die Dateigröße {size} überschreitet das Limit von {limit}.",
|
||||
"cannot_initiate_file_upload": "Datei kann nicht hochgeladen werden.",
|
||||
"tags_not_found": "Keine Schlagworte definiert. Erstellen Sie welche.",
|
||||
"tags_editor_no_tags": "Schlagworte hinzufügen",
|
||||
"title_manage_tags": "Verwalten",
|
||||
"channel_prompt": "Dies ist ein Sender",
|
||||
"channel": "Sender",
|
||||
"drafty_attachment": "Dateianhang",
|
||||
"drafty_form": "Formular:",
|
||||
"drafty_image": "Bild",
|
||||
"topic_delete": "Chat löschen",
|
||||
"password_reset_email_sent": "Eine E-Mail wurde an {email} gesendet. Folgen Sie den Anweisungen in der E-Mail, um Ihr Passwort zurückzusetzen.",
|
||||
"menu_item_restore_topic": "Wiederherstellen"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"action_block_contact": "Block Contact",
|
||||
"action_cancel": "cancel",
|
||||
"action_clear_messages": "Clear Messages",
|
||||
"action_delete_messages": "Clear Messages for All",
|
||||
"action_leave_chat": "Leave Conversation",
|
||||
"action_more": "More",
|
||||
"action_report_chat": "Report Conversation",
|
||||
"archived_contacts": "Archived contacts ({count})",
|
||||
"badge_owner": "owner",
|
||||
"badge_you": "you",
|
||||
"block_contact_warning": "Are you sure you want to block this contact?",
|
||||
"blocked_contacts_link": "Blocked contacts ({count})",
|
||||
"button_add_another": "Add another",
|
||||
"button_add_members": "Add members",
|
||||
"button_cancel": "Cancel",
|
||||
"button_confirm": "Confirm",
|
||||
"button_create": "Create",
|
||||
"button_delete_account": "Delete account",
|
||||
"button_edit": "Edit",
|
||||
"button_logout": "Logout",
|
||||
"button_ok": "OK",
|
||||
"button_reset": "Reset",
|
||||
"button_send_request": "Send request",
|
||||
"button_sign_in": "Sign in",
|
||||
"button_sign_up": "Sign up",
|
||||
"button_subscribe": "Subscribe",
|
||||
"button_update": "Update",
|
||||
"cannot_initiate_file_upload": "Cannot initiate file upload.",
|
||||
"channel": "channel",
|
||||
"channel_prompt": "This is a channel",
|
||||
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
|
||||
"chat_invitation_accept": "Accept",
|
||||
"chat_invitation_block": "Block",
|
||||
"chat_invitation_ignore": "Ignore",
|
||||
"clear_messages_warning": "Are you sure you want to clear all messages? It cannot be undone.",
|
||||
"code_doesnot_match": "Code does not match",
|
||||
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
|
||||
"contacts_not_found_short": "No contacts match ''{query}''",
|
||||
"credential_email_prompt": "Your registration email",
|
||||
"delete_account": "Delete account",
|
||||
"delete_account_arning": "Are you sure you want to delete your account? It cannot be undone.",
|
||||
"delete_messages_warning": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
|
||||
"deleted_content": "content deleted",
|
||||
"download_action": "download",
|
||||
"drafty_attachment": "Attachment",
|
||||
"drafty_form": "Form:",
|
||||
"drafty_image": "Picture",
|
||||
"email_dative": "email",
|
||||
"email_prompt": "Email, e.g. jdoe@example.com",
|
||||
"enable_peers_messaging": "Enable",
|
||||
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
|
||||
"error_invalid_id": "Invalid ID",
|
||||
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
|
||||
"forgot_password_link": "Forgot password?",
|
||||
"full_name_prompt": "Full name, e.g. John Doe",
|
||||
"granted_permissions": "Granted",
|
||||
"group_has_no_members": "No members",
|
||||
"group_user_id_prompt": "Group or User ID",
|
||||
"image_caption_prompt": "Image caption",
|
||||
"invalid_content": "invalid content",
|
||||
"invalid_security_token": "Invalid security token",
|
||||
"label_client": "Client:",
|
||||
"label_content_type": "Content type:",
|
||||
"label_default_access": "Default access mode:",
|
||||
"label_default_access_mode": "Default access mode:",
|
||||
"label_file_name": "File name:",
|
||||
"label_group_members": "Group members:",
|
||||
"label_incognito_mode": "Incognito mode:",
|
||||
"label_message_sound": "Message sound:",
|
||||
"label_muting_topic": "Muted:",
|
||||
"label_other_user": "Other",
|
||||
"label_password": "Password",
|
||||
"label_permissions": "Permissions:",
|
||||
"label_private": "Private comment",
|
||||
"label_push_notifications": "Notification alerts:",
|
||||
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
|
||||
"label_reset_password": "Send a password reset email:",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server": "Server:",
|
||||
"label_server_address": "Server address:",
|
||||
"label_server_to_use": "Server to use:",
|
||||
"label_size": "Size:",
|
||||
"label_topic_name": "Name",
|
||||
"label_user_contacts": "Contacts:",
|
||||
"label_user_id": "ID:",
|
||||
"label_wire_transport": "Wire transport:",
|
||||
"label_you": "You:",
|
||||
"label_your_name": "Your name",
|
||||
"label_your_permissions": "Your permissions:",
|
||||
"last_seen_timestamp": "Last seen",
|
||||
"leave_chat_warning": "Are you sure you want to leave this conversation?",
|
||||
"link_contact_us": "Contact Us",
|
||||
"link_privacy_policy": "Privacy Policy",
|
||||
"link_terms_of_service": "Terms of Service",
|
||||
"login_prompt": "Login",
|
||||
"menu_item_archive_topic": "Archive",
|
||||
"menu_item_block": "Block",
|
||||
"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_delete_topic": "Delete",
|
||||
"menu_item_edit_permissions": "Edit permissions",
|
||||
"menu_item_info": "Info",
|
||||
"menu_item_member_delete": "Remove",
|
||||
"menu_item_mute": "Mute",
|
||||
"menu_item_restore_topic": "Restore",
|
||||
"menu_item_send_retry": "Retry",
|
||||
"menu_item_unblock": "Unblock",
|
||||
"menu_item_unmute": "Unmute",
|
||||
"message_sending": "sending...",
|
||||
"message_sending_failed": "failed",
|
||||
"messages_not_readable": "no access to messages",
|
||||
"messaging_disabled_prompt": "Messaging disabled",
|
||||
"more_online_members": "+{overflow} more",
|
||||
"new_message_prompt": "New message",
|
||||
"new_password_placeholder": "Enter new password",
|
||||
"no_connection": "No connection",
|
||||
"no_contacts": "You have no contacts :-(",
|
||||
"numeric_confirmation_code_prompt": "Numbers only",
|
||||
"online_now": "online now",
|
||||
"password_prompt": "Password",
|
||||
"password_reset_email_sent": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"password_unchanged_prompt": "Unchanged",
|
||||
"peers_messaging_disabled": "Peer's messaging is disabled.",
|
||||
"permission_admin": "Approve ({val})",
|
||||
"permission_delete": "Delete ({val})",
|
||||
"permission_join": "Join ({val})",
|
||||
"permission_owner": "Owner ({val})",
|
||||
"permission_pres": "Get notified ({val})",
|
||||
"permission_read": "Read ({val})",
|
||||
"permission_share": "Share ({val})",
|
||||
"permission_write": "Write ({val})",
|
||||
"phone_dative": "phone",
|
||||
"private_editing_placeholder": "Visible to you only",
|
||||
"push_init_failed": "Failed to initialize push notifications",
|
||||
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
|
||||
"reconnect_now": "Try now",
|
||||
"reload_update": "Reload",
|
||||
"report_chat_warning": "Are you sure you want to block and report this conversation?",
|
||||
"requested_permissions": "Requested",
|
||||
"save_attachment": "save",
|
||||
"search_for_contacts": "Use search to find contacts",
|
||||
"search_no_results": "Search returned no results",
|
||||
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
|
||||
"sidepanel_title_acc_general": "General",
|
||||
"sidepanel_title_acc_notifications": "Notifications",
|
||||
"sidepanel_title_acc_security": "Security",
|
||||
"sidepanel_title_acc_support": "Support",
|
||||
"sidepanel_title_account_settings": "Account Settings",
|
||||
"sidepanel_title_archive": "Archived Chats",
|
||||
"sidepanel_title_blocked": "Blocked Chats",
|
||||
"sidepanel_title_cred": "Confirm Credentials",
|
||||
"sidepanel_title_login": "Sign In",
|
||||
"sidepanel_title_newtpk": "Start New Chat",
|
||||
"sidepanel_title_register": "Create Account",
|
||||
"sidepanel_title_reset": "Reset Password",
|
||||
"sidepanel_title_settings": "Settings",
|
||||
"stay_logged_in": "Stay logged in",
|
||||
"tabtitle_find_user": "find",
|
||||
"tabtitle_group_by_id": "by id",
|
||||
"tabtitle_new_group": "new group",
|
||||
"tags_editor_no_tags": "Add some tags",
|
||||
"tags_not_found": "No tags defined. Add some.",
|
||||
"title_all_contacts": "All Contacts",
|
||||
"title_group_members": "Group Members",
|
||||
"title_info": "Info",
|
||||
"title_manage_tags": "Manage",
|
||||
"title_not_found": "Not found",
|
||||
"title_permissions": "Permissions",
|
||||
"title_tag_manager": "Tags (user discovery)",
|
||||
"topic_block_warning": "Are you sure you want to block this conversation?",
|
||||
"topic_delete": "Delete Conversation",
|
||||
"topic_delete_warning": "Are you sure you want to delete this conversation? It cannot be undone.",
|
||||
"topic_name_editing_placeholder": "Freeform name of the group",
|
||||
"unnamed_topic": "Unnamed",
|
||||
"update_available": "Update available.",
|
||||
"upload_finishing": "finishing...",
|
||||
"user_not_found": "Not found",
|
||||
"validate_credential_action": "confirm"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"validate_credential_action": "confirmar",
|
||||
"title_tag_manager": "Etiquetas (descubrimiento de usuarios)",
|
||||
"label_user_contacts": "Contactos:",
|
||||
"button_add_another": "Añadir contacto",
|
||||
"label_message_sound": "Sonido de mensaje:",
|
||||
"label_push_notifications": "Alertas de notificaciones:",
|
||||
"label_push_notifications_disabled": "Alertas de notificaciones (requiere HTTPS):",
|
||||
"label_incognito_mode": "Modo incógnito:",
|
||||
"delete_account": "Eliminar cuenta",
|
||||
"delete_account_arning": "¿Estás seguro de que deseas eliminar permanentemente tu cuenta? Esta acción es irreversible.",
|
||||
"label_password": "Contraseña",
|
||||
"password_unchanged_prompt": "Sin cambios",
|
||||
"button_logout": "Cerrar sesión",
|
||||
"button_delete_account": "Eliminar cuenta",
|
||||
"label_default_access_mode": "Modo de acceso predeterminado:",
|
||||
"blocked_contacts_link": "Contactos bloqueados ({count})",
|
||||
"link_contact_us": "Contáctanos",
|
||||
"link_terms_of_service": "Términos de uso",
|
||||
"link_privacy_policy": "Política de privacidad",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server_address": "Dirección del servidor:",
|
||||
"archived_contacts": "Contactos archivados ({count})",
|
||||
"contacts_not_found": "No tienes chats<br />¯∖_(ツ)_/¯",
|
||||
"full_name_prompt": "Nombre completo, p.ej. Juan González Hernández",
|
||||
"email_prompt": "Correo electrónico, p.ej. juan@example.com",
|
||||
"button_sign_up": "Regístrate",
|
||||
"label_your_name": "Tu nombre",
|
||||
"label_user_id": "ID:",
|
||||
"button_edit": "Editar",
|
||||
"requested_permissions": "Solicitados",
|
||||
"granted_permissions": "Otorgados",
|
||||
"menu_item_edit_permissions": "Editar permisos",
|
||||
"label_other_user": "Otros",
|
||||
"action_clear_messages": "Borrar mensajes",
|
||||
"clear_messages_warning": "¿Estás seguro de que quieres eliminar todos los mensajes? Esta acción es irreversible.",
|
||||
"action_delete_messages": "Borrar mensajes para todos",
|
||||
"delete_messages_warning": "¿Estás seguro de que quieres eliminar todos los mensajes para todos? Esta acción es irreversible.",
|
||||
"action_leave_chat": "Dejar conversación",
|
||||
"leave_chat_warning": "¿Estás seguro de que quieres dejar esta conversación?",
|
||||
"action_block_contact": "Bloquear contacto",
|
||||
"block_contact_warning": "¿Estás seguro de que quieres bloquear a este contacto?",
|
||||
"action_report_chat": "Reportar conversación",
|
||||
"report_chat_warning": "¿Estás seguro de que quieres bloquear y reportar a esta conversación?",
|
||||
"title_info": "Información",
|
||||
"label_topic_name": "Nombre del tema",
|
||||
"label_private": "Comentario privado",
|
||||
"private_editing_placeholder": "Sólo visible para tí",
|
||||
"label_muting_topic": "Silenciado:",
|
||||
"action_more": "Más",
|
||||
"label_your_permissions": "Tus permisos:",
|
||||
"label_permissions": "Permisos:",
|
||||
"label_you": "Tú:",
|
||||
"label_default_access": "Modo de acceso predeterminado:",
|
||||
"label_group_members": "Miembros del grupo:",
|
||||
"button_add_members": "Añadir miembros",
|
||||
"group_has_no_members": "No hay miembros",
|
||||
"login_prompt": "Nombre de usuario",
|
||||
"password_prompt": "Contraseña",
|
||||
"stay_logged_in": "Permanecer conectado",
|
||||
"forgot_password_link": "¿Olvidaste tu contraseña?",
|
||||
"button_sign_in": "Entrar",
|
||||
"label_client": "Cliente:",
|
||||
"label_server": "Servidor:",
|
||||
"online_now": "en línea",
|
||||
"last_seen_timestamp": "Últ. vez",
|
||||
"title_not_found": "No encontrado",
|
||||
"unnamed_topic": "Sin nombre",
|
||||
"messages_not_readable": "sin acceso a mensajes",
|
||||
"peers_messaging_disabled": "La mensajería Peer está deshabilitada.",
|
||||
"enable_peers_messaging": "Habilitar",
|
||||
"search_for_contacts": "Usa la búsqueda para encontrar contactos",
|
||||
"search_no_results": "La búsqueda no arrojó resultados",
|
||||
"tabtitle_find_user": "encontrar",
|
||||
"tabtitle_new_group": "nuevo grupo",
|
||||
"tabtitle_group_by_id": "por ID",
|
||||
"new_password_placeholder": "Introduzca una nueva contraseña",
|
||||
"label_reset_password": "Enviar un correo electrónico de restablecimiento de contraseña:",
|
||||
"credential_email_prompt": "Tu correo electrónico de registro",
|
||||
"button_reset": "Restablecer",
|
||||
"button_send_request": "Enviar petición",
|
||||
"label_server_to_use": "Servidor para usar:",
|
||||
"label_wire_transport": "Transporte de alambre:",
|
||||
"button_update": "Actualizar",
|
||||
"sidepanel_title_login": "Iniciar sesión",
|
||||
"sidepanel_title_register": "Crear cuenta",
|
||||
"sidepanel_title_settings": "Ajustes",
|
||||
"sidepanel_title_account_settings": "Ajustes de la cuenta",
|
||||
"sidepanel_title_acc_general": "General",
|
||||
"sidepanel_title_acc_security": "Seguridad",
|
||||
"sidepanel_title_acc_notifications": "Notificaciones",
|
||||
"sidepanel_title_acc_support": "Soporte",
|
||||
"sidepanel_title_newtpk": "Iniciar un nuevo chat",
|
||||
"sidepanel_title_cred": "Confirmar credenciales",
|
||||
"sidepanel_title_reset": "Restablecer contraseña",
|
||||
"sidepanel_title_archive": "Chats archivados",
|
||||
"sidepanel_title_blocked": "Chats bloqueados",
|
||||
"update_available": "Actualización disponible.",
|
||||
"reload_update": "Recargar",
|
||||
"reconnect_countdown": "Desconectado. Reconectando en {seconds}…",
|
||||
"reconnect_now": "Reintentar",
|
||||
"push_init_failed": "Error al inicializar las notificaciones push",
|
||||
"invalid_security_token": "Token de seguridad inválido",
|
||||
"no_connection": "Sin conexión",
|
||||
"code_doesnot_match": "El código no coincide",
|
||||
"phone_dative": "teléfono",
|
||||
"email_dative": "correo electrónico",
|
||||
"enter_confirmation_code_prompt": "Introduzca el código de confirmación enviado a tu {method}:",
|
||||
"numeric_confirmation_code_prompt": "Sólo números",
|
||||
"button_confirm": "Confirmar",
|
||||
"save_attachment": "guardar",
|
||||
"deleted_content": "este mensaje fue eliminado",
|
||||
"invalid_content": "contenido inválido",
|
||||
"user_not_found": "Usuario no encontrado",
|
||||
"badge_you": "tú",
|
||||
"badge_owner": "propietario",
|
||||
"menu_item_info": "Información",
|
||||
"menu_item_clear_messages": "Borrar mensajes",
|
||||
"menu_item_clear_messages_for_all": "Borrar para todos",
|
||||
"menu_item_delete": "Eliminar",
|
||||
"menu_item_delete_for_all": "Eliminar para todos",
|
||||
"menu_item_send_retry": "Inténtalo de nuevo",
|
||||
"menu_item_mute": "Silenciar",
|
||||
"menu_item_unmute": "Anular el silencio",
|
||||
"menu_item_delete_topic": "Eliminar",
|
||||
"topic_delete_warning": "¿Estás seguro de que quieres eliminar esta conversación?",
|
||||
"menu_item_unblock": "Desbloquear",
|
||||
"menu_item_block": "Bloquear",
|
||||
"topic_block_warning": "¿Estás seguro de que quieres bloquear esta conversación",
|
||||
"menu_item_member_delete": "Eliminar",
|
||||
"menu_item_archive_topic": "Archivar",
|
||||
"action_cancel": "cancelar",
|
||||
"upload_finishing": "terminando...",
|
||||
"no_contacts": "No tienes contactos :-(",
|
||||
"contacts_not_found_short": "Ningún contacto coincide con ''{query}''",
|
||||
"title_group_members": "Miembros del grupo",
|
||||
"title_all_contacts": "Todos los contactos",
|
||||
"button_ok": "OK",
|
||||
"button_cancel": "Cancelar",
|
||||
"more_online_members": "+{overflow} más",
|
||||
"download_action": "descargar",
|
||||
"label_file_name": "Nombre del archivo:",
|
||||
"label_content_type": "Tipo de contenido:",
|
||||
"label_size": "Tamaño:",
|
||||
"chat_invitation": "Estás invitado a participar en un nuevo chat. ¿Qué te gustaría hacer?",
|
||||
"chat_invitation_accept": "Aceptar",
|
||||
"chat_invitation_ignore": "Ignorar",
|
||||
"chat_invitation_block": "Bloquear",
|
||||
"error_invalid_id": "ID inválido",
|
||||
"group_user_id_prompt": "ID del grupo o usuario",
|
||||
"button_subscribe": "Suscribirse",
|
||||
"topic_name_editing_placeholder": "Nombre del grupo",
|
||||
"button_create": "Crear",
|
||||
"permission_join": "Unirse ({val})",
|
||||
"permission_read": "Leer ({val})",
|
||||
"permission_write": "Escribir ({val})",
|
||||
"permission_pres": "Ser notificado ({val})",
|
||||
"permission_admin": "Approbar ({val})",
|
||||
"permission_share": "Compartir ({val})",
|
||||
"permission_delete": "Eliminar ({val})",
|
||||
"permission_owner": "Propietario ({val})",
|
||||
"title_permissions": "Permisos",
|
||||
"message_sending": "enviando...",
|
||||
"message_sending_failed": "no se pudo enviar el mensaje",
|
||||
"search_placeholder": "Ej. email:alba@example.com, tel:17025550003...",
|
||||
"messaging_disabled_prompt": "El envío de mensajes está deshabilitado",
|
||||
"new_message_prompt": "Nuevo mensaje",
|
||||
"image_caption_prompt": "Añade un comentario",
|
||||
"file_attachment_too_large": "El tamaño del archivo {size} excede el límite de {limit}.",
|
||||
"cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.",
|
||||
"tags_not_found": "No hay etiquetas definidas. Añade unas.",
|
||||
"tags_editor_no_tags": "Añadir etiquetas",
|
||||
"title_manage_tags": "Gestionar",
|
||||
"channel_prompt": "Este es un canal",
|
||||
"channel": "canal",
|
||||
"drafty_attachment": "Archivo",
|
||||
"drafty_form": "Formulario:",
|
||||
"drafty_image": "Imagen",
|
||||
"topic_delete": "Eliminar chat",
|
||||
"password_reset_email_sent": "Se ha enviado un correo electrónico a {email}. Siga las instrucciones del correo electrónico para restablecer su contraseña.",
|
||||
"menu_item_restore_topic": "Restaurar"
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
{
|
||||
"action_block_contact": {
|
||||
"defaultMessage": "Block Contact",
|
||||
"description": "Flat button [Block Contact]"
|
||||
},
|
||||
"action_cancel": {
|
||||
"defaultMessage": "cancel",
|
||||
"description": "Call to action [cancel]"
|
||||
},
|
||||
"action_clear_messages": {
|
||||
"defaultMessage": "Clear Messages",
|
||||
"description": "Flat button [Clear Messages] (soft-delete messages)"
|
||||
},
|
||||
"action_delete_messages": {
|
||||
"defaultMessage": "Clear Messages for All",
|
||||
"description": "Flat button [Clear for All] (hard-delete all messages)"
|
||||
},
|
||||
"action_leave_chat": {
|
||||
"defaultMessage": "Leave Conversation",
|
||||
"description": "Flat button [Leave Conversation]"
|
||||
},
|
||||
"action_more": {
|
||||
"defaultMessage": "More",
|
||||
"description": "Action for showing more content"
|
||||
},
|
||||
"action_report_chat": {
|
||||
"defaultMessage": "Report Conversation",
|
||||
"description": "Flat button [Report Group]"
|
||||
},
|
||||
"archived_contacts": {
|
||||
"defaultMessage": "Archived contacts ({count})",
|
||||
"description": "Label for archived chats"
|
||||
},
|
||||
"badge_owner": {
|
||||
"defaultMessage": "owner",
|
||||
"description": "Badge for indicating the owner"
|
||||
},
|
||||
"badge_you": {
|
||||
"defaultMessage": "you",
|
||||
"description": "Badge for indicating the current user"
|
||||
},
|
||||
"block_contact_warning": {
|
||||
"defaultMessage": "Are you sure you want to block this contact?",
|
||||
"description": "Alert dialog warning when blocking a contact."
|
||||
},
|
||||
"blocked_contacts_link": {
|
||||
"defaultMessage": "Blocked contacts ({count})",
|
||||
"description": "Blocked contacts link"
|
||||
},
|
||||
"button_add_another": {
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]"
|
||||
},
|
||||
"button_add_members": {
|
||||
"defaultMessage": "Add members",
|
||||
"description": "Flat button [Add members] (to topic)"
|
||||
},
|
||||
"button_cancel": {
|
||||
"defaultMessage": "Cancel",
|
||||
"description": "Button [Cancel]"
|
||||
},
|
||||
"button_confirm": {
|
||||
"defaultMessage": "Confirm",
|
||||
"description": "Button [Confirm]"
|
||||
},
|
||||
"button_create": {
|
||||
"defaultMessage": "Create",
|
||||
"description": "Button [Create]"
|
||||
},
|
||||
"button_delete_account": {
|
||||
"defaultMessage": "Delete account",
|
||||
"description": "Button [Delete account]"
|
||||
},
|
||||
"button_edit": {
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Call to action [Edit]"
|
||||
},
|
||||
"button_logout": {
|
||||
"defaultMessage": "Logout",
|
||||
"description": "Button [Logout]"
|
||||
},
|
||||
"button_ok": {
|
||||
"defaultMessage": "OK",
|
||||
"description": "Button [OK]"
|
||||
},
|
||||
"button_reset": {
|
||||
"defaultMessage": "Reset",
|
||||
"description": "Button [Reset]"
|
||||
},
|
||||
"button_send_request": {
|
||||
"defaultMessage": "Send request",
|
||||
"description": "Button [Send request]"
|
||||
},
|
||||
"button_sign_in": {
|
||||
"defaultMessage": "Sign in",
|
||||
"description": "Button [Sign In]"
|
||||
},
|
||||
"button_sign_up": {
|
||||
"defaultMessage": "Sign up",
|
||||
"description": "Create account button [Sign Up]"
|
||||
},
|
||||
"button_subscribe": {
|
||||
"defaultMessage": "Subscribe",
|
||||
"description": "Button [Subscribe]"
|
||||
},
|
||||
"button_update": {
|
||||
"defaultMessage": "Update",
|
||||
"description": "Button [Update]"
|
||||
},
|
||||
"cannot_initiate_file_upload": {
|
||||
"defaultMessage": "Cannot initiate file upload.",
|
||||
"description": "Generic error messagewhen attachment fails"
|
||||
},
|
||||
"channel": {
|
||||
"defaultMessage": "channel",
|
||||
"description": "Subtitle shown for channels in MessagesView instead of last seen"
|
||||
},
|
||||
"channel_prompt": {
|
||||
"defaultMessage": "This is a channel",
|
||||
"description": "Checkbox label when creating a channel"
|
||||
},
|
||||
"chat_invitation": {
|
||||
"defaultMessage": "You are invited to start a new chat. What would you like to do?",
|
||||
"description": "New chat invitation message: [Accept] [Ignore] [Block]."
|
||||
},
|
||||
"chat_invitation_accept": {
|
||||
"defaultMessage": "Accept",
|
||||
"description": "Action [Accept] for chat invitation."
|
||||
},
|
||||
"chat_invitation_block": {
|
||||
"defaultMessage": "Block",
|
||||
"description": "Action [Block] for chat invitation."
|
||||
},
|
||||
"chat_invitation_ignore": {
|
||||
"defaultMessage": "Ignore",
|
||||
"description": "Action [Ignore] for chat invitation."
|
||||
},
|
||||
"clear_messages_warning": {
|
||||
"defaultMessage": "Are you sure you want to clear all messages? It cannot be undone.",
|
||||
"description": "Alert dialog warning when deleting all messages."
|
||||
},
|
||||
"code_doesnot_match": {
|
||||
"defaultMessage": "Code does not match",
|
||||
"description": "Error message when the credential validation code is incorrect."
|
||||
},
|
||||
"contacts_not_found": {
|
||||
"defaultMessage": "You have no chats<br />¯∖_(ツ)_/¯",
|
||||
"description": "HTML message shown in ContactList when no contacts are found"
|
||||
},
|
||||
"contacts_not_found_short": {
|
||||
"defaultMessage": "No contacts match ''{query}''",
|
||||
"description": "Shown in ContactsView when search returned no results"
|
||||
},
|
||||
"credential_email_prompt": {
|
||||
"defaultMessage": "Your registration email",
|
||||
"description": "Placeholder for entering email"
|
||||
},
|
||||
"delete_account": {
|
||||
"defaultMessage": "Delete account",
|
||||
"description": "Title for delete account warning"
|
||||
},
|
||||
"delete_account_arning": {
|
||||
"defaultMessage": "Are you sure you want to delete your account? It cannot be undone.",
|
||||
"description": "Warning message when deleting an account"
|
||||
},
|
||||
"delete_messages_warning": {
|
||||
"defaultMessage": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
|
||||
"description": "Alert dialog warning when hard-deleting all messages."
|
||||
},
|
||||
"deleted_content": {
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted"
|
||||
},
|
||||
"download_action": {
|
||||
"defaultMessage": "download",
|
||||
"description": "Call to action [download]"
|
||||
},
|
||||
"drafty_attachment": {
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview"
|
||||
},
|
||||
"drafty_form": {
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview"
|
||||
},
|
||||
"drafty_image": {
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview"
|
||||
},
|
||||
"email_dative": {
|
||||
"defaultMessage": "email",
|
||||
"description": "Dative case of 'email', i.e. 'email' in 'by email'"
|
||||
},
|
||||
"email_prompt": {
|
||||
"defaultMessage": "Email, e.g. jdoe@example.com",
|
||||
"description": "Input placeholder for email entry"
|
||||
},
|
||||
"enable_peers_messaging": {
|
||||
"defaultMessage": "Enable",
|
||||
"description": "Call to action to enable peer's messaging"
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"description": "Request to enter confirmation code"
|
||||
},
|
||||
"error_invalid_id": {
|
||||
"defaultMessage": "Invalid ID",
|
||||
"description": "Error message"
|
||||
},
|
||||
"file_attachment_too_large": {
|
||||
"defaultMessage": "The file size {size} exceeds the {limit} limit.",
|
||||
"description": "Error message when attachment is too large"
|
||||
},
|
||||
"forgot_password_link": {
|
||||
"defaultMessage": "Forgot password?",
|
||||
"description": "Link to Reset password form"
|
||||
},
|
||||
"full_name_prompt": {
|
||||
"defaultMessage": "Full name, e.g. John Doe",
|
||||
"description": "Input placeholder for person's full name"
|
||||
},
|
||||
"granted_permissions": {
|
||||
"defaultMessage": "Granted",
|
||||
"description": "Title for permissions"
|
||||
},
|
||||
"group_has_no_members": {
|
||||
"defaultMessage": "No members",
|
||||
"description": "Shown in place of group members"
|
||||
},
|
||||
"group_user_id_prompt": {
|
||||
"defaultMessage": "Group or User ID",
|
||||
"description": "Prompt for entering user or group ID"
|
||||
},
|
||||
"image_caption_prompt": {
|
||||
"defaultMessage": "Image caption",
|
||||
"description": "Prompt in SendMessage for attached image"
|
||||
},
|
||||
"invalid_content": {
|
||||
"defaultMessage": "invalid content",
|
||||
"description": "Shown when message is unreadable"
|
||||
},
|
||||
"invalid_security_token": {
|
||||
"defaultMessage": "Invalid security token",
|
||||
"description": "Error message when resetting password."
|
||||
},
|
||||
"label_client": {
|
||||
"defaultMessage": "Client:",
|
||||
"description": "Label for a client version"
|
||||
},
|
||||
"label_content_type": {
|
||||
"defaultMessage": "Content type:",
|
||||
"description": "Label for file content type (mime)"
|
||||
},
|
||||
"label_default_access": {
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Section title"
|
||||
},
|
||||
"label_default_access_mode": {
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Label for default access mode"
|
||||
},
|
||||
"label_file_name": {
|
||||
"defaultMessage": "File name:",
|
||||
"description": "Label for a file name"
|
||||
},
|
||||
"label_group_members": {
|
||||
"defaultMessage": "Group members:",
|
||||
"description": "Section title or label"
|
||||
},
|
||||
"label_incognito_mode": {
|
||||
"defaultMessage": "Incognito mode:",
|
||||
"description": "Label for incognito mode toggle"
|
||||
},
|
||||
"label_message_sound": {
|
||||
"defaultMessage": "Message sound:",
|
||||
"description": "Label for message sounds toggle"
|
||||
},
|
||||
"label_muting_topic": {
|
||||
"defaultMessage": "Muted:",
|
||||
"description": "Label for Muting/unmuting the topic"
|
||||
},
|
||||
"label_other_user": {
|
||||
"defaultMessage": "Other",
|
||||
"description": "Label for the other user when the user is unnamed"
|
||||
},
|
||||
"label_password": {
|
||||
"defaultMessage": "Password",
|
||||
"description": "Label for password editing"
|
||||
},
|
||||
"label_permissions": {
|
||||
"defaultMessage": "Permissions:",
|
||||
"description": "Section title"
|
||||
},
|
||||
"label_private": {
|
||||
"defaultMessage": "Private comment",
|
||||
"description": "Label for editing 'private'"
|
||||
},
|
||||
"label_push_notifications": {
|
||||
"defaultMessage": "Notification alerts:",
|
||||
"description": "Label for push notifications switch"
|
||||
},
|
||||
"label_push_notifications_disabled": {
|
||||
"defaultMessage": "Notification alerts (requires HTTPS):",
|
||||
"description": "Label for push notifications switch"
|
||||
},
|
||||
"label_reset_password": {
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"description": "Label for password reset field"
|
||||
},
|
||||
"label_sdk": {
|
||||
"defaultMessage": "SDK:"
|
||||
},
|
||||
"label_server": {
|
||||
"defaultMessage": "Server:",
|
||||
"description": "Label for a server version"
|
||||
},
|
||||
"label_server_address": {
|
||||
"defaultMessage": "Server address:"
|
||||
},
|
||||
"label_server_to_use": {
|
||||
"defaultMessage": "Server to use:",
|
||||
"description": "Label for server selector in SettingsView"
|
||||
},
|
||||
"label_size": {
|
||||
"defaultMessage": "Size:",
|
||||
"description": "Label for file size"
|
||||
},
|
||||
"label_topic_name": {
|
||||
"defaultMessage": "Name",
|
||||
"description": "Label for editing topic name"
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"defaultMessage": "Contacts:",
|
||||
"description": "Label for user contacts"
|
||||
},
|
||||
"label_user_id": {
|
||||
"defaultMessage": "ID:",
|
||||
"description": "Label for user address (ID)"
|
||||
},
|
||||
"label_wire_transport": {
|
||||
"defaultMessage": "Wire transport:",
|
||||
"description": "Label for wire transport selection in SettingsView"
|
||||
},
|
||||
"label_you": {
|
||||
"defaultMessage": "You:",
|
||||
"description": "Label for the current user"
|
||||
},
|
||||
"label_your_name": {
|
||||
"defaultMessage": "Your name",
|
||||
"description": "Label for full name editing"
|
||||
},
|
||||
"label_your_permissions": {
|
||||
"defaultMessage": "Your permissions:",
|
||||
"description": "Label for current user permissions"
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"defaultMessage": "Last seen",
|
||||
"description": "Label for the timestamp of when the user or topic was last online"
|
||||
},
|
||||
"leave_chat_warning": {
|
||||
"defaultMessage": "Are you sure you want to leave this conversation?",
|
||||
"description": "Alert dialog warning when unsubscribing from a chat."
|
||||
},
|
||||
"link_contact_us": {
|
||||
"defaultMessage": "Contact Us",
|
||||
"description": "Ancor text for contacting us by email"
|
||||
},
|
||||
"link_privacy_policy": {
|
||||
"defaultMessage": "Privacy Policy",
|
||||
"description": "Ancor text for privacy policy link"
|
||||
},
|
||||
"link_terms_of_service": {
|
||||
"defaultMessage": "Terms of Service",
|
||||
"description": "Ancor text for terms of service link"
|
||||
},
|
||||
"login_prompt": {
|
||||
"defaultMessage": "Login",
|
||||
"description": "Placeholer for username/login"
|
||||
},
|
||||
"menu_item_archive_topic": {
|
||||
"defaultMessage": "Archive",
|
||||
"description": "Move topic from the list of active chats to archive"
|
||||
},
|
||||
"menu_item_block": {
|
||||
"defaultMessage": "Block",
|
||||
"description": "Block topic or user"
|
||||
},
|
||||
"menu_item_clear_messages": {
|
||||
"defaultMessage": "Clear messages",
|
||||
"description": "Delete all messages"
|
||||
},
|
||||
"menu_item_clear_messages_for_all": {
|
||||
"defaultMessage": "Clear for All",
|
||||
"description": "Delete all message(s) for all members"
|
||||
},
|
||||
"menu_item_delete": {
|
||||
"defaultMessage": "Delete",
|
||||
"description": "Delete selected messages"
|
||||
},
|
||||
"menu_item_delete_for_all": {
|
||||
"defaultMessage": "Delete for All",
|
||||
"description": "Delete selected message(s) for all members"
|
||||
},
|
||||
"menu_item_delete_topic": {
|
||||
"defaultMessage": "Delete",
|
||||
"description": "Delete entire topic"
|
||||
},
|
||||
"menu_item_edit_permissions": {
|
||||
"defaultMessage": "Edit permissions",
|
||||
"description": "Menu item [Edit permissions]"
|
||||
},
|
||||
"menu_item_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Show extended topic information"
|
||||
},
|
||||
"menu_item_member_delete": {
|
||||
"defaultMessage": "Remove",
|
||||
"description": "Remove user from topic"
|
||||
},
|
||||
"menu_item_mute": {
|
||||
"defaultMessage": "Mute",
|
||||
"description": "Turn off notifications"
|
||||
},
|
||||
"menu_item_restore_topic": {
|
||||
"defaultMessage": "Restore",
|
||||
"description": "Restore topic from archive"
|
||||
},
|
||||
"menu_item_send_retry": {
|
||||
"defaultMessage": "Retry",
|
||||
"description": "Retry sending message"
|
||||
},
|
||||
"menu_item_unblock": {
|
||||
"defaultMessage": "Unblock",
|
||||
"description": "Unblock topic or user"
|
||||
},
|
||||
"menu_item_unmute": {
|
||||
"defaultMessage": "Unmute",
|
||||
"description": "Turn notifications on"
|
||||
},
|
||||
"message_sending": {
|
||||
"defaultMessage": "sending...",
|
||||
"description": "Message being sent, in place of time stamp"
|
||||
},
|
||||
"message_sending_failed": {
|
||||
"defaultMessage": "failed",
|
||||
"description": "Failed to send message, in place of time stamp"
|
||||
},
|
||||
"messages_not_readable": {
|
||||
"defaultMessage": "no access to messages",
|
||||
"description": "Message shown in topic without the read access"
|
||||
},
|
||||
"messaging_disabled_prompt": {
|
||||
"defaultMessage": "Messaging disabled",
|
||||
"description": "Prompt in SendMessage in read-only topic"
|
||||
},
|
||||
"more_online_members": {
|
||||
"defaultMessage": "+{overflow} more",
|
||||
"description": "Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
|
||||
},
|
||||
"new_message_prompt": {
|
||||
"defaultMessage": "New message",
|
||||
"description": "Prompt in send message field"
|
||||
},
|
||||
"new_password_placeholder": {
|
||||
"defaultMessage": "Enter new password",
|
||||
"description": "Placeholder for entering new password"
|
||||
},
|
||||
"no_connection": {
|
||||
"defaultMessage": "No connection",
|
||||
"description": "Warning that the user is offline."
|
||||
},
|
||||
"no_contacts": {
|
||||
"defaultMessage": "You have no contacts :-(",
|
||||
"description": "Shown in ContactsView when the user has no contacts"
|
||||
},
|
||||
"numeric_confirmation_code_prompt": {
|
||||
"defaultMessage": "Numbers only",
|
||||
"description": "Prompt for numeric conformation code"
|
||||
},
|
||||
"online_now": {
|
||||
"defaultMessage": "online now",
|
||||
"description": "Indicator that the user or topic is currently online"
|
||||
},
|
||||
"password_prompt": {
|
||||
"defaultMessage": "Password",
|
||||
"description": "Placeholder/prompt for entering password"
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"description": "Notification that the email with password reset instructions has been sent"
|
||||
},
|
||||
"password_unchanged_prompt": {
|
||||
"defaultMessage": "Unchanged",
|
||||
"description": "Message in editor while password is unchanged"
|
||||
},
|
||||
"peers_messaging_disabled": {
|
||||
"defaultMessage": "Peer's messaging is disabled.",
|
||||
"description": "Shown when the p2p peer's messaging is disabled"
|
||||
},
|
||||
"permission_admin": {
|
||||
"defaultMessage": "Approve ({val})",
|
||||
"description": "Name of A permission"
|
||||
},
|
||||
"permission_delete": {
|
||||
"defaultMessage": "Delete ({val})",
|
||||
"description": "Name of D permission"
|
||||
},
|
||||
"permission_join": {
|
||||
"defaultMessage": "Join ({val})",
|
||||
"description": "Name of J permission"
|
||||
},
|
||||
"permission_owner": {
|
||||
"defaultMessage": "Owner ({val})",
|
||||
"description": "Name of O permission"
|
||||
},
|
||||
"permission_pres": {
|
||||
"defaultMessage": "Get notified ({val})",
|
||||
"description": "Name of P permission"
|
||||
},
|
||||
"permission_read": {
|
||||
"defaultMessage": "Read ({val})",
|
||||
"description": "Name of R permission"
|
||||
},
|
||||
"permission_share": {
|
||||
"defaultMessage": "Share ({val})",
|
||||
"description": "Name of S permission"
|
||||
},
|
||||
"permission_write": {
|
||||
"defaultMessage": "Write ({val})",
|
||||
"description": "Name of W permission"
|
||||
},
|
||||
"phone_dative": {
|
||||
"defaultMessage": "phone",
|
||||
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
|
||||
},
|
||||
"private_editing_placeholder": {
|
||||
"defaultMessage": "Visible to you only",
|
||||
"description": "Placeholder for editing 'private'"
|
||||
},
|
||||
"push_init_failed": {
|
||||
"defaultMessage": "Failed to initialize push notifications",
|
||||
"description": "Error message when push notifications have failed to initialize."
|
||||
},
|
||||
"reconnect_countdown": {
|
||||
"defaultMessage": "Disconnected. Reconnecting in {seconds}…",
|
||||
"description": "Message shown when an app update is available."
|
||||
},
|
||||
"reconnect_now": {
|
||||
"defaultMessage": "Try now",
|
||||
"description": "Prompt for reconnecting now"
|
||||
},
|
||||
"reload_update": {
|
||||
"defaultMessage": "Reload",
|
||||
"description": "Call to action to reload application when update is available."
|
||||
},
|
||||
"report_chat_warning": {
|
||||
"defaultMessage": "Are you sure you want to block and report this conversation?",
|
||||
"description": "Alert dialog warning when reporting a conversation for abuse"
|
||||
},
|
||||
"requested_permissions": {
|
||||
"defaultMessage": "Requested",
|
||||
"description": "Title for permissions"
|
||||
},
|
||||
"save_attachment": {
|
||||
"defaultMessage": "save",
|
||||
"description": "Call to save an attachment"
|
||||
},
|
||||
"search_for_contacts": {
|
||||
"defaultMessage": "Use search to find contacts",
|
||||
"description": "Text shown in contacts view when user entered no search query."
|
||||
},
|
||||
"search_no_results": {
|
||||
"defaultMessage": "Search returned no results",
|
||||
"description": "Text shown in contacts view when query returned no results."
|
||||
},
|
||||
"search_placeholder": {
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"description": "Placeholder in contacts search field"
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView."
|
||||
},
|
||||
"sidepanel_title_acc_notifications": {
|
||||
"defaultMessage": "Notifications",
|
||||
"description": "Sidepanel title for AccNotificationsView."
|
||||
},
|
||||
"sidepanel_title_acc_security": {
|
||||
"defaultMessage": "Security",
|
||||
"description": "Sidepanel title for AccSecurityView."
|
||||
},
|
||||
"sidepanel_title_acc_support": {
|
||||
"defaultMessage": "Support",
|
||||
"description": "Sidepanel title for AccSupportView."
|
||||
},
|
||||
"sidepanel_title_account_settings": {
|
||||
"defaultMessage": "Account Settings",
|
||||
"description": "Sidepanel title for EditAccountView."
|
||||
},
|
||||
"sidepanel_title_archive": {
|
||||
"defaultMessage": "Archived Chats",
|
||||
"description": "Sidepanel title for ContactsView-Archive."
|
||||
},
|
||||
"sidepanel_title_blocked": {
|
||||
"defaultMessage": "Blocked Chats",
|
||||
"description": "Sidepanel title for ContactsView-Blocked."
|
||||
},
|
||||
"sidepanel_title_cred": {
|
||||
"defaultMessage": "Confirm Credentials",
|
||||
"description": "Sidepanel title for ValidationView."
|
||||
},
|
||||
"sidepanel_title_login": {
|
||||
"defaultMessage": "Sign In",
|
||||
"description": "Sidepanel title for LoginView."
|
||||
},
|
||||
"sidepanel_title_newtpk": {
|
||||
"defaultMessage": "Start New Chat",
|
||||
"description": "Sidepanel title for NewTopicView."
|
||||
},
|
||||
"sidepanel_title_register": {
|
||||
"defaultMessage": "Create Account",
|
||||
"description": "Sidepanel title for CreateAccountView."
|
||||
},
|
||||
"sidepanel_title_reset": {
|
||||
"defaultMessage": "Reset Password",
|
||||
"description": "Sidepanel title for PasswordResetView."
|
||||
},
|
||||
"sidepanel_title_settings": {
|
||||
"defaultMessage": "Settings",
|
||||
"description": "Sidepanel title for SettingsView."
|
||||
},
|
||||
"stay_logged_in": {
|
||||
"defaultMessage": "Stay logged in",
|
||||
"description": "Label for a checkbox"
|
||||
},
|
||||
"tabtitle_find_user": {
|
||||
"defaultMessage": "find",
|
||||
"description": "Tab title Find"
|
||||
},
|
||||
"tabtitle_group_by_id": {
|
||||
"defaultMessage": "by id",
|
||||
"description": "Tab title Find topic by ID"
|
||||
},
|
||||
"tabtitle_new_group": {
|
||||
"defaultMessage": "new group",
|
||||
"description": "Tab title New Group"
|
||||
},
|
||||
"tags_editor_no_tags": {
|
||||
"defaultMessage": "Add some tags",
|
||||
"description": "Tag editor prompt when no tags are found."
|
||||
},
|
||||
"tags_not_found": {
|
||||
"defaultMessage": "No tags defined. Add some.",
|
||||
"description": ""
|
||||
},
|
||||
"title_all_contacts": {
|
||||
"defaultMessage": "All Contacts",
|
||||
"description": "Section title [All Contacts]"
|
||||
},
|
||||
"title_group_members": {
|
||||
"defaultMessage": "Group Members",
|
||||
"description": "Section title"
|
||||
},
|
||||
"title_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Title for InfoView"
|
||||
},
|
||||
"title_manage_tags": {
|
||||
"defaultMessage": "Manage",
|
||||
"description": "Section title for the list of tags"
|
||||
},
|
||||
"title_not_found": {
|
||||
"defaultMessage": "Not found",
|
||||
"description": "Title shown when topic is not found"
|
||||
},
|
||||
"title_permissions": {
|
||||
"defaultMessage": "Permissions",
|
||||
"description": "Section title"
|
||||
},
|
||||
"title_tag_manager": {
|
||||
"defaultMessage": "Tags (user discovery)",
|
||||
"description": "Section title for TagManager"
|
||||
},
|
||||
"topic_block_warning": {
|
||||
"defaultMessage": "Are you sure you want to block this conversation?",
|
||||
"description": "Alert warning when blocking a topic."
|
||||
},
|
||||
"topic_delete": {
|
||||
"defaultMessage": "Delete Conversation",
|
||||
"description": "Alert title when deleting the topic."
|
||||
},
|
||||
"topic_delete_warning": {
|
||||
"defaultMessage": "Are you sure you want to delete this conversation? It cannot be undone.",
|
||||
"description": "Alert warning when deleting entire topic"
|
||||
},
|
||||
"topic_name_editing_placeholder": {
|
||||
"defaultMessage": "Freeform name of the group",
|
||||
"description": "Prompt for entering topic name"
|
||||
},
|
||||
"unnamed_topic": {
|
||||
"defaultMessage": "Unnamed",
|
||||
"description": "Title shown when the topic has no name"
|
||||
},
|
||||
"update_available": {
|
||||
"defaultMessage": "Update available.",
|
||||
"description": "Message shown when an app update is available."
|
||||
},
|
||||
"upload_finishing": {
|
||||
"defaultMessage": "finishing...",
|
||||
"description": "Notification that upload is finishing"
|
||||
},
|
||||
"user_not_found": {
|
||||
"defaultMessage": "Not found",
|
||||
"description": "In place of a user's full name when the user is not found."
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"validate_credential_action": "확인",
|
||||
"title_tag_manager": "태그(사용자검색)",
|
||||
"label_user_contacts": "연락처: ",
|
||||
"button_add_another": "다른항목추가",
|
||||
"label_message_sound": "메시지소리: ",
|
||||
"label_push_notifications": "통지경보: ",
|
||||
"label_push_notifications_disabled": "통지경보(HTTPS필요): ",
|
||||
"label_incognito_mode": "시크릿모드: ",
|
||||
"delete_account": "계정삭제",
|
||||
"delete_account_arning": "계정을삭제하시겠습니까?실행취소할수없습니다.",
|
||||
"label_password": "비밀번호",
|
||||
"password_unchanged_prompt": "변경되지않음",
|
||||
"button_logout": "로그아웃",
|
||||
"button_delete_account": "계정삭제",
|
||||
"label_default_access_mode": "기본액세스모드: ",
|
||||
"blocked_contacts_link": "차단된연락처({수})",
|
||||
"link_contact_us": "문의처",
|
||||
"link_terms_of_service": "서비스약관",
|
||||
"link_privacy_policy": "개인정보보호정책",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server_address": "서버주소: ",
|
||||
"archived_contacts": "보관된연락처({수})",
|
||||
"contacts_not_found": "채팅이없습니다<br/>¯∖_(ツ)_/¯",
|
||||
"full_name_prompt": "전체이름(예:홍길동)",
|
||||
"email_prompt": "이메일(예:hong@example.com)",
|
||||
"button_sign_up": "가입",
|
||||
"label_your_name": "이름",
|
||||
"label_user_id": "ID:",
|
||||
"button_edit": "편집",
|
||||
"requested_permissions": "요청",
|
||||
"grant_permissions": ":!![OBSOLETE]!!:승인",
|
||||
"menu_item_edit_permissions": "편집권한",
|
||||
"label_other_user": "기타",
|
||||
"action_clear_messages": "메시지지우기",
|
||||
"clear_messages_warning": "모든메시지를지우시겠습니까?실행취소할수없습니다.",
|
||||
"action_delete_messages": "모든메시지지우기",
|
||||
"delete_messages_warning": "모든사람의모든메시지를삭제하시겠습니까?실행취소할수없습니다.",
|
||||
"action_leave_chat": "대화나누기",
|
||||
"leave_chat_warning": "이대화를나가시겠습니까?",
|
||||
"action_block_contact": "연락차단",
|
||||
"block_contact_warning": "이연락처를차단하시겠습니까?",
|
||||
"action_report_chat": "대화기록",
|
||||
"report_chat_warning": "이대화를차단하고기록하시겠습니까?",
|
||||
"title_info": "정보",
|
||||
"label_topic_name": "이름",
|
||||
"label_private": "개인코멘트",
|
||||
"private_editing_placeholder": "나만볼수있습니다",
|
||||
"label_muting_topic": "음소거: ",
|
||||
"action_more": "더보기",
|
||||
"label_your_permissions": "권한: ",
|
||||
"label_permissions": "권한: ",
|
||||
"label_you": "당신: ",
|
||||
"label_default_access": "기본액세스모드: ",
|
||||
"label_group_members": "그룹회원: ",
|
||||
"button_add_members": "회원추가",
|
||||
"group_has_no_members": "회원없음",
|
||||
"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": "이름없는",
|
||||
"message_not_readable": ":!![OBSOLETE]!!:메시지에접근할수없음",
|
||||
"peers_messaging_disabled": "동료의메시지가비활성화되었습니다.",
|
||||
"enable_peers_messaging": "활성화",
|
||||
"search_for_contacts": "검색을사용하여연락처찾기",
|
||||
"search_no_results": "검색결과가없습니다",
|
||||
"tabtitle_find_user": "찾기",
|
||||
"tabtitle_new_group": "새그룹",
|
||||
"tabtitle_group_by_id": "id로",
|
||||
"new_password_placeholder": "새비밀번호입력",
|
||||
"label_reset_password": "비밀번호재설정이메일보내기: ",
|
||||
"credential_email_prompt": "등록이메일",
|
||||
"button_reset": "재설정",
|
||||
"button_send_request": "요청보내기",
|
||||
"label_server_to_use": "사용할서버:",
|
||||
"label_wire_transport": "와이어수송:",
|
||||
"button_update": "업데이트",
|
||||
"sidepanel_title_login": "로그인",
|
||||
"sidepanel_title_register": "계정만들기",
|
||||
"sidepanel_title_settings": "설정",
|
||||
"sidepanel_title_account_settings": "계정설정",
|
||||
"sidepanel_title_acc_general": "일반",
|
||||
"sidepanel_title_acc_security": "보안",
|
||||
"sidepanel_title_acc_notifications": "알림",
|
||||
"sidepanel_title_acc_support": "지원",
|
||||
"sidepanel_title_newtpk": "새채팅시작",
|
||||
"sidepanel_title_cred": "자격증명확인",
|
||||
"sidepanel_title_reset": "비밀번호재설정",
|
||||
"sidepanel_title_archive": "보관된채팅",
|
||||
"sidepanel_title_blocked": "차단된채팅",
|
||||
"update_available": "업데이트가능.",
|
||||
"reload_update": "재로드",
|
||||
"reconnect_countdown": "연결이끊어졌습니다.다시연결하는중{초}…",
|
||||
"reconnect_now": "지금시도",
|
||||
"push_init_failed": "푸시알림을초기화하지못했습니다",
|
||||
"invalid_security_token": "유효하지않은보안토큰",
|
||||
"no_connection": "연결없음",
|
||||
"code_doesnot_match": "코드가일치하지않습니다",
|
||||
"phone_dative": "전화",
|
||||
"email_dative": "이메일",
|
||||
"enter_confirmation_code_prompt": "{방법}으로보낸확인코드를입력하십시오:",
|
||||
"numeric_confirmation_code_prompt": "숫자만",
|
||||
"button_confirm": "확인",
|
||||
"save_attachment": "저장",
|
||||
"delete_content": ":!![OBSOLETE]!!:컨텐츠삭제",
|
||||
"invalid_content": "잘못된내용",
|
||||
"user_not_found": "찾을수없음",
|
||||
"badge_you": "당신",
|
||||
"badge_owner": "소유자",
|
||||
"menu_item_info": "정보",
|
||||
"menu_item_clear_messages": "메시지지우기",
|
||||
"menu_item_clear_messages_for_all": "모두지우기",
|
||||
"menu_item_delete": "삭제",
|
||||
"menu_item_delete_for_all": "모두삭제",
|
||||
"menu_item_send_retry": "다시시도",
|
||||
"menu_item_mute": "음소거",
|
||||
"menu_item_unmute": "음소거해제",
|
||||
"menu_item_delete_topic": "삭제",
|
||||
"topic_delete_warning": "이대화를삭제하시겠습니까?",
|
||||
"menu_item_unblock": "차단해제",
|
||||
"menu_item_block": "차단",
|
||||
"topic_block_warning": "이대화를차단하시겠습니까?",
|
||||
"menu_item_member_delete": "제거",
|
||||
"menu_item_archive_topic": "보관",
|
||||
"action_cancel": "취소",
|
||||
"upload_finishing": "마무리...",
|
||||
"no_contacts": "연락처가없습니다 (._.)",
|
||||
"contacts_not_found_short": "'{문의}'와일치하는연락처가없습니다.",
|
||||
"title_group_members": "그룹구성원",
|
||||
"title_all_contacts": "모든연락처",
|
||||
"button_ok": "OK",
|
||||
"button_cancel": "취소",
|
||||
"more_online_members": "+{넘침}더보기",
|
||||
"download_action": "다운로드",
|
||||
"label_file_name": "파일이름: ",
|
||||
"label_content_type": "컨텐츠유형: ",
|
||||
"label_size": "크기: ",
|
||||
"chat_invitation": "새로운대화를시작하도록초대되었습니다.무엇을하시겠습니까?",
|
||||
"chat_invitation_accept": "수락",
|
||||
"chat_invitation_ignore": "무시",
|
||||
"chat_invitation_block": "차단",
|
||||
"error_invalid_id": "잘못된ID",
|
||||
"group_user_id_prompt": "그룹또는사용자ID",
|
||||
"button_subscribe": "구독",
|
||||
"topic_name_editing_placeholder": "그룹의자유형이름",
|
||||
"button_create": "작성",
|
||||
"permission_join": "참여({val})",
|
||||
"permission_read": "읽기({val})",
|
||||
"permission_write": "쓰기({val})",
|
||||
"permission_pres": "알림받기({val})",
|
||||
"permission_admin": "승인({val})",
|
||||
"permission_share": "공유({val})",
|
||||
"permission_delete": "삭제({val})",
|
||||
"permission_owner": "소유자({val})",
|
||||
"title_permissions": "권한",
|
||||
"message_sending": "보내기...",
|
||||
"message_sending_failed": "실패",
|
||||
"search_placeholder": "email:kim@example.com,tel:17025550003...와같은목록",
|
||||
"messaging_disabled_prompt": "메시지비활성화",
|
||||
"new_message_prompt": "새메시지",
|
||||
"image_caption_prompt": "이미지설명",
|
||||
"file_attachment_too_large": "파일크기{크기}이(가){제한}제한을초과합니다.",
|
||||
"cannot_initiate_file_upload": "파일업로드를시작할수없습니다.",
|
||||
"tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.",
|
||||
"tags_editor_no_tags": "일부태그추가",
|
||||
"title_manage_tags": "관리",
|
||||
"granted_permissions": "승낙하다",
|
||||
"messages_not_readable": "메시지에 액세스 할 수 없습니다",
|
||||
"deleted_content": "내용이 삭제되었습니다",
|
||||
"channel_prompt": "이 채널입니다",
|
||||
"channel": "채널",
|
||||
"drafty_attachment": "부착",
|
||||
"drafty_form": "형태:",
|
||||
"drafty_image": "이미지",
|
||||
"topic_delete": "채팅 삭제",
|
||||
"password_reset_email_sent": "이메일이 {email}(으)로 전송되었습니다. 이메일의 지침에 따라 비밀번호를 재설정하세요.",
|
||||
"menu_item_restore_topic": "복원"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"validate_credential_action": "confirmă",
|
||||
"title_tag_manager": "Tag-uri (user discovery)",
|
||||
"label_user_contacts": "Contacte:",
|
||||
"button_add_another": "Adaugă",
|
||||
"label_message_sound": "Sunet de mesaj:",
|
||||
"label_push_notifications": "Alerte de notificare:",
|
||||
"label_push_notifications_disabled": "Alerte de notificare (necesită HTTPS):",
|
||||
"label_incognito_mode": "Mod incognito:",
|
||||
"delete_account": "Șterge contul",
|
||||
"delete_account_arning": "Ești sigur că dorești să ștergi contul? Acesta nu va putea fi recuperat.",
|
||||
"label_password": "Parola",
|
||||
"password_unchanged_prompt": "Neschimbată",
|
||||
"button_logout": "Deconectare",
|
||||
"button_delete_account": "Șterge cont",
|
||||
"label_default_access_mode": "Default access mode:",
|
||||
"blocked_contacts_link": "Contact blocate ({count})",
|
||||
"link_contact_us": "Contactează-ne",
|
||||
"link_terms_of_service": "Termenii Serviciului",
|
||||
"link_privacy_policy": "Politica de Confidențialitate",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server_address": "Adresa serverului:",
|
||||
"archived_contacts": "Contacte arhivate ({count})",
|
||||
"contacts_not_found": "Nu ai contacte salvate<br />¯∖_(ツ)_/¯",
|
||||
"full_name_prompt": "Numele tău",
|
||||
"email_prompt": "Adresa de e-mail",
|
||||
"button_sign_up": "Creează cont",
|
||||
"label_your_name": "Numele tău",
|
||||
"label_user_id": "ID:",
|
||||
"button_edit": "Editează",
|
||||
"requested_permissions": "Necesare",
|
||||
"granted_permissions": "Oferite",
|
||||
"menu_item_edit_permissions": "Editează permisiuni",
|
||||
"label_other_user": "Altele",
|
||||
"action_clear_messages": "Șterge mesajele",
|
||||
"clear_messages_warning": "Ești sigur că dorești să ștergi mesajele? Acestea nu pot fi recuperate.",
|
||||
"action_delete_messages": "Șterge toate mesajele",
|
||||
"delete_messages_warning": "Ești sigur că dorești să șterge mesajele din toate conversațiile? Acestea nu pot fi recuperate.",
|
||||
"action_leave_chat": "Ieși din conversație",
|
||||
"leave_chat_warning": "Ești sigur că dorești să ieși din conersație?",
|
||||
"action_block_contact": "Blochează contact",
|
||||
"block_contact_warning": "Ești sigur că dorești să blochezi acest contact?",
|
||||
"action_report_chat": "Raportează conversația",
|
||||
"report_chat_warning": "Ești sigur că dorești să blochezi și să raportezi această conversație?",
|
||||
"title_info": "Info",
|
||||
"label_topic_name": "Nume",
|
||||
"label_private": "Comentariu privat",
|
||||
"private_editing_placeholder": "Vizibil doar ție",
|
||||
"label_muting_topic": "Mod silențios (muted):",
|
||||
"action_more": "Mai multe",
|
||||
"label_your_permissions": "Permisiuniile tale:",
|
||||
"label_permissions": "Permisiuni:",
|
||||
"label_you": "Tu:",
|
||||
"label_default_access": "Modul default:",
|
||||
"label_group_members": "Membrii grupului:",
|
||||
"button_add_members": "Adaugă persoane",
|
||||
"group_has_no_members": "În acest grup nu se află persoane",
|
||||
"login_prompt": "Conectează-te",
|
||||
"password_prompt": "Parola",
|
||||
"stay_logged_in": "Rămâi conectat",
|
||||
"forgot_password_link": "Ți-ai uitat parola?",
|
||||
"button_sign_in": "Conectează-te",
|
||||
"label_client": "Client:",
|
||||
"label_server": "Server:",
|
||||
"online_now": "online acum",
|
||||
"last_seen_timestamp": "Văzut ultima dată",
|
||||
"title_not_found": "Nu a fost găsit",
|
||||
"channel": "canal",
|
||||
"unnamed_topic": "Nedenumit",
|
||||
"messages_not_readable": "Mesajele nu pot fi citite",
|
||||
"peers_messaging_disabled": "Mesageria de tip Peer este dezactivată.",
|
||||
"enable_peers_messaging": "Activează",
|
||||
"search_for_contacts": "Caută în contacte",
|
||||
"search_no_results": "Nu s-au găsit rezultate",
|
||||
"tabtitle_find_user": "caută",
|
||||
"tabtitle_new_group": "grup nou",
|
||||
"tabtitle_group_by_id": "după id",
|
||||
"new_password_placeholder": "Introdu parolă",
|
||||
"label_reset_password": "Resetează parola:",
|
||||
"credential_email_prompt": "Adresa de e-mail",
|
||||
"button_reset": "Resetează",
|
||||
"button_send_request": "Trimite cerere",
|
||||
"label_server_to_use": "Server de utilizat:",
|
||||
"label_wire_transport": "Transportul legat:",
|
||||
"button_update": "Actualizează",
|
||||
"sidepanel_title_login": "Conectează-te",
|
||||
"sidepanel_title_register": "Creează Cont",
|
||||
"sidepanel_title_settings": "Setări",
|
||||
"sidepanel_title_account_settings": "Setările Contului",
|
||||
"sidepanel_title_acc_general": "Generale",
|
||||
"sidepanel_title_acc_security": "Securitate",
|
||||
"sidepanel_title_acc_notifications": "Notificări",
|
||||
"sidepanel_title_acc_support": "Ajutor",
|
||||
"sidepanel_title_newtpk": "Creeză un nou Chat",
|
||||
"sidepanel_title_cred": "Confirmă credențiale",
|
||||
"sidepanel_title_reset": "Resetează Parola",
|
||||
"sidepanel_title_archive": "Conversații Arhivate",
|
||||
"sidepanel_title_blocked": "Conversații Blocate",
|
||||
"reconnect_countdown": "Deconectat. Se încearcă conectarea în {seconds}…",
|
||||
"reconnect_now": "Încearcă din nou",
|
||||
"push_init_failed": "Nu s-a reușit inițializarea tip push notifications",
|
||||
"invalid_security_token": "Codul de securitate este invalid",
|
||||
"no_connection": "Nu există conexiune",
|
||||
"code_doesnot_match": "Codul furnizat nu este valid",
|
||||
"update_available": "Actualizare disponibilă.",
|
||||
"reload_update": "Reîncarcă",
|
||||
"phone_dative": "telefon",
|
||||
"email_dative": "email",
|
||||
"enter_confirmation_code_prompt": "Introu codul de confirmare trimis pe {method}:",
|
||||
"numeric_confirmation_code_prompt": "Doar cifre",
|
||||
"button_confirm": "Confirmă",
|
||||
"save_attachment": "salvează",
|
||||
"deleted_content": "conținut șters",
|
||||
"invalid_content": "conținut invalid",
|
||||
"user_not_found": "Utilizatorul nu a fost găsit",
|
||||
"badge_you": "tu",
|
||||
"badge_owner": "owner",
|
||||
"menu_item_info": "Info",
|
||||
"menu_item_clear_messages": "Șterge mesajele",
|
||||
"menu_item_clear_messages_for_all": "Ștergele pe toate",
|
||||
"menu_item_delete": "Șterge",
|
||||
"menu_item_delete_for_all": "Ștergele pe toate",
|
||||
"menu_item_send_retry": "Reîncearcă",
|
||||
"menu_item_mute": "Mute",
|
||||
"menu_item_unmute": "Unmute",
|
||||
"menu_item_delete_topic": "Șterge",
|
||||
"topic_delete_warning": "Ești sigur că dorești să șterge această conversație?",
|
||||
"menu_item_unblock": "Deblochează",
|
||||
"menu_item_block": "Blochează",
|
||||
"topic_block_warning": "Ești sigur că dorești să blochezi această conversație?",
|
||||
"menu_item_member_delete": "Șterge persoană",
|
||||
"menu_item_archive_topic": "Arhivează",
|
||||
"action_cancel": "anulează",
|
||||
"upload_finishing": "se încarcă...",
|
||||
"no_contacts": "Nu ai contacte adăugate :-(",
|
||||
"contacts_not_found_short": "Niciun contact găsit după criteriile ''{query}''",
|
||||
"title_group_members": "Persoanele din grup",
|
||||
"title_all_contacts": "Toate Contactele",
|
||||
"button_ok": "OK",
|
||||
"button_cancel": "Anulează",
|
||||
"more_online_members": "+{overflow} mai mult",
|
||||
"download_action": "download",
|
||||
"label_file_name": "Nume fișier:",
|
||||
"label_content_type": "Tip conținut:",
|
||||
"label_size": "Size:",
|
||||
"chat_invitation": "Ai primit invitație într-o conversație. Accepți?",
|
||||
"chat_invitation_accept": "Da, accept",
|
||||
"chat_invitation_ignore": "Ignoră",
|
||||
"chat_invitation_block": "Blochează",
|
||||
"error_invalid_id": "ID Invalid",
|
||||
"group_user_id_prompt": "Grup sau user ID",
|
||||
"button_subscribe": "Subscribe",
|
||||
"topic_name_editing_placeholder": "Numele grupului",
|
||||
"channel_prompt": "Acesta este un canal",
|
||||
"button_create": "Creează",
|
||||
"permission_join": "Participare ({val})",
|
||||
"permission_read": "Citire ({val})",
|
||||
"permission_write": "Scriere ({val})",
|
||||
"permission_pres": "Notificare ({val})",
|
||||
"permission_admin": "Aprobare ({val})",
|
||||
"permission_share": "Distribuire ({val})",
|
||||
"permission_delete": "Ștergere ({val})",
|
||||
"permission_owner": "Deținător ({val})",
|
||||
"title_permissions": "Permisiuni",
|
||||
"message_sending": "se trimite...",
|
||||
"message_sending_failed": "eroare",
|
||||
"search_placeholder": "Caută după e-mail sau număr telefon...",
|
||||
"messaging_disabled_prompt": "Mesageria este dezactivată",
|
||||
"new_message_prompt": "Mesaj nou",
|
||||
"image_caption_prompt": "Titlul imaginii",
|
||||
"file_attachment_too_large": "Dimensiunea fișiterului {size} depășește limita de {limit}.",
|
||||
"cannot_initiate_file_upload": "Nu se poate face încărcarea.",
|
||||
"tags_not_found": "Niciun tag definit. Adaugă.",
|
||||
"tags_editor_no_tags": "Adaugă tag-uri",
|
||||
"title_manage_tags": "Administrează",
|
||||
"drafty_attachment": "Atasament",
|
||||
"drafty_form": "Formă:",
|
||||
"drafty_image": "Imagine",
|
||||
"topic_delete": "Ștergeți Chat",
|
||||
"password_reset_email_sent": "Un e-mail a fost trimis către {email}. Urmați instrucțiunile din e-mail pentru a vă reseta parola.",
|
||||
"menu_item_restore_topic": "Restabili"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"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": "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_account_settings": "Настройки аккаунта",
|
||||
"sidepanel_title_newtpk": "Новый чат",
|
||||
"sidepanel_title_cred": "Подтвердить",
|
||||
"sidepanel_title_reset": "Сменить пароль",
|
||||
"tags_not_found": "Тегов нет. Добавьте",
|
||||
"tags_editor_no_tags": "Добавьте теги",
|
||||
"title_manage_tags": "Редактировать",
|
||||
"message_sending": "в пути...",
|
||||
"message_sending_failed": "ошибка",
|
||||
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
|
||||
"messaging_disabled_prompt": "Отправка недоступна",
|
||||
"new_message_prompt": "Новое сообщение",
|
||||
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
|
||||
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
|
||||
"search_for_contacts": "Поиск контактов",
|
||||
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
|
||||
"numeric_confirmation_code_prompt": "Только цифры",
|
||||
"button_confirm": "Подтвердить",
|
||||
"button_ok": "OK",
|
||||
"button_cancel": "Отменить",
|
||||
"invalid_content": "сообщение не читается",
|
||||
"label_file_name": "Имя файла:",
|
||||
"label_content_type": "Тип:",
|
||||
"label_size": "Размер:",
|
||||
"phone_dative": "телефону",
|
||||
"email_dative": "емейлу",
|
||||
"title_group_members": "Участники",
|
||||
"download_action": "скачать",
|
||||
"permission_join": "Подписываться ({val})",
|
||||
"permission_read": "Читать ({val})",
|
||||
"permission_write": "Писать ({val})",
|
||||
"permission_pres": "Уведомлять ({val})",
|
||||
"permission_admin": "Подтверждать ({val})",
|
||||
"permission_share": "Приглашать ({val})",
|
||||
"permission_delete": "Удалять ({val})",
|
||||
"permission_owner": "Владелец ({val})",
|
||||
"title_permissions": "Права доступа",
|
||||
"requested_permissions": "Требуются",
|
||||
"granted_permissions": "Получены",
|
||||
"menu_item_edit_permissions": "Права доступа",
|
||||
"label_other_user": "Второй",
|
||||
"label_topic_name": "Название",
|
||||
"label_private": "Комментарий",
|
||||
"private_editing_placeholder": "Виден только вам",
|
||||
"label_muting_topic": "Без уведомлений",
|
||||
"action_more": "Ещё",
|
||||
"label_your_permissions": "Ваши права доступа:",
|
||||
"label_permissions": "Права доступа:",
|
||||
"label_you": "Вы:",
|
||||
"label_default_access": "Права по умолчанию:",
|
||||
"label_group_members": "Участники чата:",
|
||||
"button_add_members": "Добавить",
|
||||
"group_has_no_members": "Нет участников",
|
||||
"action_leave_chat": "Уйти из чата",
|
||||
"menu_item_info": "Информация",
|
||||
"menu_item_clear_messages": "Удалить сообщения",
|
||||
"menu_item_clear_messages_for_all": "Удалить для всех",
|
||||
"menu_item_delete": "Удалить",
|
||||
"menu_item_delete_for_all": "Удалить для всех",
|
||||
"menu_item_send_retry": "Отправить заново",
|
||||
"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": "Есть новая версия приложения.",
|
||||
"reload_update": "Обновить",
|
||||
"user_not_found": "Не найден",
|
||||
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
|
||||
"reconnect_now": "Подключить сейчас.",
|
||||
"save_attachment": "сохранить",
|
||||
"menu_item_archive_topic": "В архив",
|
||||
"archived_contacts": "Чаты в архиве ({count})",
|
||||
"sidepanel_title_archive": "Архив чатов",
|
||||
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
|
||||
"chat_invitation_accept": "Принять",
|
||||
"chat_invitation_ignore": "Игнорировать",
|
||||
"chat_invitation_block": "Заблокировать",
|
||||
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
|
||||
"enable_peers_messaging": "Разблокировать.",
|
||||
"more_online_members": "+еще {overflow}",
|
||||
"label_user_contacts": "Конакты:",
|
||||
"validate_credential_action": "подтвердить",
|
||||
"link_contact_us": "Связаться с нами",
|
||||
"link_terms_of_service": "Условия сервиса",
|
||||
"link_privacy_policy": "Политика конфиденциальности",
|
||||
"action_delete_messages": "Удалить сообщения",
|
||||
"action_block_contact": "Заблокировать контакт",
|
||||
"action_report_chat": "Сообщить о нарушении",
|
||||
"delete_messages_warning": "Вы действительно хотите удалить все сообщения?",
|
||||
"leave_chat_warning": "Вы действительно хотите покинуть этот чат?",
|
||||
"block_contact_warning": "Вы действительно заблокировать этот контакт?",
|
||||
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
|
||||
"action_clear_messages": "Удалить сообщения",
|
||||
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить.",
|
||||
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
|
||||
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
|
||||
"search_no_results": "Ничего не найдено",
|
||||
"deleted_content": "удалено",
|
||||
"image_caption_prompt": "Подпись к фото",
|
||||
"label_incognito_mode": "Режим инкогнито:",
|
||||
"delete_account": "Удалить аккаунт",
|
||||
"delete_account_arning": "Вы уверены, что ходите удалить свой аккаунт? Его невозможно будет восстановить.",
|
||||
"button_delete_account": "Удалить аккаунт",
|
||||
"sidepanel_title_acc_security": "Безопасность",
|
||||
"sidepanel_title_acc_notifications": "Уведомления",
|
||||
"sidepanel_title_acc_support": "Поддержка",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server_address": "Адрес сервера:",
|
||||
"button_edit": "Редактировать",
|
||||
"button_add_another": "Добавить",
|
||||
"sidepanel_title_acc_general": "Общие настройки",
|
||||
"blocked_contacts_link": "Заблокированные ({count})",
|
||||
"sidepanel_title_blocked": "Заблокированные чаты",
|
||||
"push_init_failed": "Ошибка инициализации пуш уведомлений",
|
||||
"invalid_security_token": "Токен некорректен",
|
||||
"no_connection": "Нет связи",
|
||||
"code_doesnot_match": "Код не совпадает",
|
||||
"channel_prompt": "Создать канал",
|
||||
"channel": "канал",
|
||||
"drafty_attachment": "Аттачмент",
|
||||
"drafty_form": "Форма:",
|
||||
"drafty_image": "Картинка",
|
||||
"topic_delete": "Удалить чат",
|
||||
"password_reset_email_sent": "Сообщение было отправлено на адрес {email}. Следуйте инструкциям в сообщении, чтобы изменить пароль.",
|
||||
"menu_item_restore_topic": "Восстановить"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"action_block_contact": "封鎖聯絡人",
|
||||
"action_cancel": "取消",
|
||||
"action_clear_messages": "清除訊息",
|
||||
"action_delete_messages": "為所有人清除訊息",
|
||||
"action_leave_chat": "離開對話",
|
||||
"action_more": "更多",
|
||||
"action_report_chat": "檢舉對話",
|
||||
"archived_contacts": "已封存的聯絡人 ({count})",
|
||||
"badge_owner": "擁有者",
|
||||
"badge_you": "您",
|
||||
"block_contact_warning": "您確定要封鎖此聯絡人嗎?",
|
||||
"blocked_contacts_link": "已封鎖的聯絡人 ({count})",
|
||||
"button_add_another": "新增其他人",
|
||||
"button_add_members": "新增成員",
|
||||
"button_cancel": "取消",
|
||||
"button_confirm": "確認",
|
||||
"button_create": "建立",
|
||||
"button_delete_account": "刪除帳號",
|
||||
"button_edit": "編輯",
|
||||
"button_logout": "登出",
|
||||
"button_ok": "OK",
|
||||
"button_reset": "重設",
|
||||
"button_send_request": "傳送要求",
|
||||
"button_sign_in": "登入",
|
||||
"button_sign_up": "註冊",
|
||||
"button_subscribe": "訂閱",
|
||||
"button_update": "更新",
|
||||
"cannot_initiate_file_upload": "無法為檔案上傳初始化。",
|
||||
"channel": "頻道",
|
||||
"channel_prompt": "這是頻道",
|
||||
"chat_invitation": "您已被邀請開始新對話。您接下來要做什麼呢?",
|
||||
"chat_invitation_accept": "接受",
|
||||
"chat_invitation_block": "封鎖",
|
||||
"chat_invitation_ignore": "忽略",
|
||||
"clear_messages_warning": "您確定要清除所有訊息嗎?此操作無法復原。",
|
||||
"code_doesnot_match": "代碼不相符",
|
||||
"contacts_not_found": "您沒有任何對話<br />¯∖_(ツ)_/¯",
|
||||
"contacts_not_found_short": "沒有符合 ''{query}'' 的聯絡人",
|
||||
"credential_email_prompt": "您的註冊電子郵件地址",
|
||||
"delete_account": "刪除帳號",
|
||||
"delete_account_arning": "您確定要刪除您的帳號嗎?此操作無法復原。",
|
||||
"delete_messages_warning": "您確定要為所有人刪除所有訊息嗎?此操作無法復原。",
|
||||
"deleted_content": "內容已被刪除",
|
||||
"download_action": "下載",
|
||||
"drafty_attachment": "附加檔案",
|
||||
"drafty_form": "表單:",
|
||||
"drafty_image": "圖片",
|
||||
"email_dative": "電子郵件地址",
|
||||
"email_prompt": "電子郵件地址,例如:jdoe@example.com",
|
||||
"enable_peers_messaging": "啟用",
|
||||
"enter_confirmation_code_prompt": "輸入透過 {method} 傳送給您的確認碼:",
|
||||
"error_invalid_id": "ID 無效",
|
||||
"file_attachment_too_large": "檔案大小 {size} 超出 {limit} 限制。",
|
||||
"forgot_password_link": "忘記密碼?",
|
||||
"full_name_prompt": "全名,例如:王小明",
|
||||
"granted_permissions": "已授權",
|
||||
"group_has_no_members": "無成員",
|
||||
"group_user_id_prompt": "群組或使用者 ID",
|
||||
"image_caption_prompt": "圖片說明",
|
||||
"invalid_content": "內容無效",
|
||||
"invalid_security_token": "安全權杖無效",
|
||||
"label_client": "客戶端:",
|
||||
"label_content_type": "類型:",
|
||||
"label_default_access": "預設存取模式:",
|
||||
"label_default_access_mode": "預設存取模式:",
|
||||
"label_file_name": "檔名:",
|
||||
"label_group_members": "群組成員:",
|
||||
"label_incognito_mode": "無痕模式:",
|
||||
"label_message_sound": "訊息提示聲:",
|
||||
"label_muting_topic": "靜音:",
|
||||
"label_other_user": "其他",
|
||||
"label_password": "密碼",
|
||||
"label_permissions": "權限:",
|
||||
"label_private": "私人留言",
|
||||
"label_push_notifications": "通知:",
|
||||
"label_push_notifications_disabled": "通知 (需要 HTTPS):",
|
||||
"label_reset_password": "傳送重設密碼郵件:",
|
||||
"label_sdk": "SDK:",
|
||||
"label_server": "伺服器:",
|
||||
"label_server_address": "伺服器位址:",
|
||||
"label_server_to_use": "使用的伺服器:",
|
||||
"label_size": "大小:",
|
||||
"label_topic_name": "名稱",
|
||||
"label_user_contacts": "聯絡人:",
|
||||
"label_user_id": "ID:",
|
||||
"label_wire_transport": "Wire transport:",
|
||||
"label_you": "您:",
|
||||
"label_your_name": "您的名字",
|
||||
"label_your_permissions": "您的權限:",
|
||||
"last_seen_timestamp": "最後上線",
|
||||
"leave_chat_warning": "您確定要離開此對話嗎?",
|
||||
"link_contact_us": "聯絡我們",
|
||||
"link_privacy_policy": "隱私權政策",
|
||||
"link_terms_of_service": "服務條款",
|
||||
"login_prompt": "登入",
|
||||
"menu_item_archive_topic": "封存",
|
||||
"menu_item_block": "封鎖",
|
||||
"menu_item_clear_messages": "清除訊息",
|
||||
"menu_item_clear_messages_for_all": "為所有人清除訊息",
|
||||
"menu_item_delete": "刪除",
|
||||
"menu_item_delete_for_all": "為所有人刪除",
|
||||
"menu_item_delete_topic": "刪除",
|
||||
"menu_item_edit_permissions": "編輯權限",
|
||||
"menu_item_info": "Info",
|
||||
"menu_item_member_delete": "移除",
|
||||
"menu_item_mute": "靜音",
|
||||
"menu_item_send_retry": "重試",
|
||||
"menu_item_unblock": "解除封鎖",
|
||||
"menu_item_unmute": "解除靜音",
|
||||
"message_sending": "正在傳送...",
|
||||
"message_sending_failed": "失敗",
|
||||
"messages_not_readable": "沒有存取訊息的權限",
|
||||
"messaging_disabled_prompt": "停用訊息",
|
||||
"more_online_members": "+{overflow}",
|
||||
"new_message_prompt": "新訊息",
|
||||
"new_password_placeholder": "輸入新的密碼",
|
||||
"no_connection": "無連線",
|
||||
"no_contacts": "您沒有任何聯絡人 :-(",
|
||||
"numeric_confirmation_code_prompt": "僅數字",
|
||||
"online_now": "上線中",
|
||||
"password_prompt": "密碼",
|
||||
"password_unchanged_prompt": "未修改",
|
||||
"peers_messaging_disabled": "Peer 已停用訊息。",
|
||||
"permission_admin": "核可 ({val})",
|
||||
"permission_delete": "刪除 ({val})",
|
||||
"permission_join": "加入 ({val})",
|
||||
"permission_owner": "擁有者 ({val})",
|
||||
"permission_pres": "收到通知 ({val})",
|
||||
"permission_read": "讀取 ({val})",
|
||||
"permission_share": "分享 ({val})",
|
||||
"permission_write": "撰寫 ({val})",
|
||||
"phone_dative": "phone",
|
||||
"private_editing_placeholder": "僅您可見",
|
||||
"push_init_failed": "初始化通知失敗。",
|
||||
"reconnect_countdown": "已斷線。將在 {seconds} 秒後重新連線…",
|
||||
"reconnect_now": "現在重試",
|
||||
"reload_update": "重新載入",
|
||||
"report_chat_warning": "您確定要封鎖並檢舉此對話嗎?",
|
||||
"requested_permissions": "重新請求",
|
||||
"save_attachment": "儲存",
|
||||
"search_for_contacts": "使用搜尋來尋找聯絡人",
|
||||
"search_no_results": "搜尋無結果",
|
||||
"search_placeholder": "例如 email:alice@example.com, tel:17025550003... 的清單",
|
||||
"sidepanel_title_acc_general": "一般",
|
||||
"sidepanel_title_acc_notifications": "通知",
|
||||
"sidepanel_title_acc_security": "安全性",
|
||||
"sidepanel_title_acc_support": "支援",
|
||||
"sidepanel_title_account_settings": "帳號設定",
|
||||
"sidepanel_title_archive": "已封存的對話",
|
||||
"sidepanel_title_blocked": "已封鎖的對話",
|
||||
"sidepanel_title_cred": "確認認證",
|
||||
"sidepanel_title_login": "登入",
|
||||
"sidepanel_title_newtpk": "開始新對話",
|
||||
"sidepanel_title_register": "建立帳號",
|
||||
"sidepanel_title_reset": "重設密碼",
|
||||
"sidepanel_title_settings": "設定",
|
||||
"stay_logged_in": "保持登入",
|
||||
"tabtitle_find_user": "尋找",
|
||||
"tabtitle_group_by_id": "以 id",
|
||||
"tabtitle_new_group": "新群組",
|
||||
"tags_editor_no_tags": "新增一些標籤",
|
||||
"tags_not_found": "未定義任何標籤。請新增一些標籤。",
|
||||
"title_all_contacts": "所有聯絡人",
|
||||
"title_group_members": "群組成員",
|
||||
"title_info": "資訊",
|
||||
"title_manage_tags": "管理",
|
||||
"title_not_found": "找不到",
|
||||
"title_permissions": "權限",
|
||||
"title_tag_manager": "標籤 (使用者探索)",
|
||||
"topic_block_warning": "您確定要封鎖此對話嗎?",
|
||||
"topic_delete_warning": "您確定要刪除此對話嗎?",
|
||||
"topic_name_editing_placeholder": "群組的任意名稱",
|
||||
"unnamed_topic": "未命名",
|
||||
"update_available": "有可用的更新。",
|
||||
"upload_finishing": "正在完成...",
|
||||
"user_not_found": "找不到",
|
||||
"validate_credential_action": "確認",
|
||||
"topic_delete": "刪除聊天",
|
||||
"password_reset_email_sent": "一封電子郵件已發送至 {email}。按照電子郵件中的說明重置密碼。",
|
||||
"menu_item_restore_topic": "恢復"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"archived_contacts": "已归档联系人 ({count})",
|
||||
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
|
||||
"full_name_prompt": "全名,例如张伟",
|
||||
"email_prompt": "电子邮件,例如 zhang@example.com",
|
||||
"button_sign_up": "注册",
|
||||
"label_your_name": "你的姓名",
|
||||
"label_password": "密码",
|
||||
"password_unchanged_prompt": "未改变",
|
||||
"label_user_id": "地址:",
|
||||
"label_default_access_mode": "蓦然访问模式:",
|
||||
"label_message_sound": "消息提示音:",
|
||||
"label_push_notifications": "通知提醒:",
|
||||
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
|
||||
"title_tag_manager": "标签(用户发现)",
|
||||
"button_logout": "登出",
|
||||
"requested_permissions": "已请求",
|
||||
"granted_permissions": "已授予",
|
||||
"menu_item_edit_permissions": "编辑权限",
|
||||
"label_other_user": "其他",
|
||||
"title_info": "信息",
|
||||
"label_topic_name": "名称",
|
||||
"label_private": "私人评论",
|
||||
"private_editing_placeholder": "仅自己可见",
|
||||
"label_muting_topic": "已静音:",
|
||||
"action_more": "更多",
|
||||
"label_your_permissions": "你的权限:",
|
||||
"label_permissions": "权限:",
|
||||
"label_you": "你:",
|
||||
"label_default_access": "默认权限模式:",
|
||||
"label_group_members": "群组成员:",
|
||||
"button_add_members": "添加成员",
|
||||
"group_has_no_members": "无成员",
|
||||
"action_leave_chat": "离开",
|
||||
"login_prompt": "登录",
|
||||
"password_prompt": "密码",
|
||||
"stay_logged_in": "保持登录",
|
||||
"forgot_password_link": "忘记密码?",
|
||||
"button_sign_in": "登录",
|
||||
"label_client": "客户端:",
|
||||
"label_server": "服务器:",
|
||||
"online_now": "在线",
|
||||
"last_seen_timestamp": "最后可见",
|
||||
"title_not_found": "无法找到",
|
||||
"unnamed_topic": "未命名",
|
||||
"messages_not_readable": "无消息访问权限",
|
||||
"peers_messaging_disabled": "成员间消息已禁用。",
|
||||
"enable_peers_messaging": "启用",
|
||||
"tabtitle_find_user": "搜索",
|
||||
"tabtitle_new_group": "新群组",
|
||||
"tabtitle_group_by_id": "通过 id",
|
||||
"search_for_contacts": "使用搜索寻找联系人",
|
||||
"new_password_placeholder": "输入新密码",
|
||||
"label_reset_password": "发送密码重置邮件:",
|
||||
"credential_email_prompt": "你的注册邮箱",
|
||||
"button_reset": "重置",
|
||||
"button_send_request": "发送请求",
|
||||
"label_server_to_use": "使用的服务器:",
|
||||
"label_wire_transport": "线路传输:",
|
||||
"button_update": "更新",
|
||||
"sidepanel_title_login": "登录",
|
||||
"sidepanel_title_register": "创建账户",
|
||||
"sidepanel_title_settings": "设置",
|
||||
"sidepanel_title_account_settings": "帐号设定",
|
||||
"sidepanel_title_newtpk": "开始新会话",
|
||||
"sidepanel_title_cred": "确认凭据",
|
||||
"sidepanel_title_reset": "重置密码",
|
||||
"sidepanel_title_archive": "已存档会话",
|
||||
"update_available": "更新可用。",
|
||||
"reload_update": "重新载入",
|
||||
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
|
||||
"reconnect_now": "立即尝试",
|
||||
"phone_dative": "电话",
|
||||
"email_dative": "电子邮件",
|
||||
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
|
||||
"numeric_confirmation_code_prompt": "仅数字",
|
||||
"button_confirm": "确认",
|
||||
"save_attachment": "保存",
|
||||
"invalid_content": "无效内容",
|
||||
"user_not_found": "未找到",
|
||||
"badge_you": "你",
|
||||
"badge_owner": "所有者",
|
||||
"menu_item_info": "信息",
|
||||
"menu_item_clear_messages": "清空消息",
|
||||
"menu_item_clear_messages_for_all": "全部清除",
|
||||
"menu_item_delete": "删除",
|
||||
"menu_item_delete_for_all": "全部删除",
|
||||
"menu_item_mute": "静音",
|
||||
"menu_item_unmute": "取消静音",
|
||||
"menu_item_delete_topic": "删除",
|
||||
"menu_item_unblock": "取消屏蔽",
|
||||
"menu_item_block": "屏蔽",
|
||||
"menu_item_member_delete": "移除",
|
||||
"menu_item_archive_topic": "归档",
|
||||
"action_cancel": "取消",
|
||||
"upload_finishing": "正在结束...",
|
||||
"no_contacts": "你尚无联系人 (._.)",
|
||||
"contacts_not_found_short": "无联系人匹配''{query}''",
|
||||
"title_group_members": "群组成员",
|
||||
"title_all_contacts": "全部联系人",
|
||||
"button_ok": "好",
|
||||
"button_cancel": "取消",
|
||||
"download_action": "下载",
|
||||
"label_file_name": "文件名:",
|
||||
"label_content_type": "内容类型:",
|
||||
"label_size": "大小:",
|
||||
"chat_invitation": "你受邀开始新会话。你想怎么做?",
|
||||
"chat_invitation_accept": "接受",
|
||||
"chat_invitation_ignore": "忽略",
|
||||
"chat_invitation_block": "屏蔽",
|
||||
"error_invalid_id": "无效 ID",
|
||||
"group_user_id_prompt": "群组或用户 ID",
|
||||
"button_subscribe": "订阅",
|
||||
"topic_name_editing_placeholder": "群组自由格式名称",
|
||||
"button_create": "创建",
|
||||
"permission_join": "加入 ({val})",
|
||||
"permission_read": "读取 ({val})",
|
||||
"permission_write": "写入 ({val})",
|
||||
"permission_pres": "获取通知 ({val})",
|
||||
"permission_admin": "批准 ({val})",
|
||||
"permission_share": "分享 ({val})",
|
||||
"permission_delete": "删除 ({val})",
|
||||
"permission_owner": "所有者 ({val})",
|
||||
"title_permissions": "权限",
|
||||
"message_sending": "正在发送...",
|
||||
"message_sending_failed": "发送失败",
|
||||
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
|
||||
"messaging_disabled_prompt": "消息已禁用",
|
||||
"new_message_prompt": "新消息",
|
||||
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
|
||||
"cannot_initiate_file_upload": "无法初始化文件上传。",
|
||||
"tags_not_found": "尚未定义标签。添加一些。",
|
||||
"tags_editor_no_tags": "添加一些标签",
|
||||
"title_manage_tags": "管理标签",
|
||||
"more_online_members": "还有{overflow}个",
|
||||
"label_user_contacts": "往来:",
|
||||
"validate_credential_action": "确认",
|
||||
"link_contact_us": "联系我们",
|
||||
"link_terms_of_service": "条款和条件",
|
||||
"link_privacy_policy": "隐私政策",
|
||||
"action_delete_messages": "删除所有帖子",
|
||||
"action_block_contact": "屏蔽联系人",
|
||||
"delete_messages_warning": "您确定要删除所有消息吗?无法撤消。",
|
||||
"leave_chat_warning": "您确定要退出此对话吗?",
|
||||
"block_contact_warning": "您确定要阻止此联系人吗?",
|
||||
"action_report_chat": "检举垃圾邮件",
|
||||
"report_chat_warning": "您确定要停止并报告此对话吗?",
|
||||
"action_clear_messages": "删除讯息",
|
||||
"clear_messages_warning": "您确定要清除所有消息吗?无法撤消。",
|
||||
"topic_delete_warning": "您确定要删除此对话吗?",
|
||||
"topic_block_warning": "您确定要阻止此对话吗?",
|
||||
"search_no_results": "搜索返回任何结果",
|
||||
"deleted_content": "内容已删除",
|
||||
"image_caption_prompt": "图片标题",
|
||||
"menu_item_send_retry": "重试",
|
||||
"label_incognito_mode": "无痕模式:",
|
||||
"delete_account": "删除帐户",
|
||||
"delete_account_arning": "您确定要删除您的帐户吗?无法撤消。",
|
||||
"button_delete_account": "删除帐户",
|
||||
"sidepanel_title_acc_security": "安全",
|
||||
"sidepanel_title_acc_notifications": "通知",
|
||||
"sidepanel_title_acc_support": "支持",
|
||||
"label_sdk": "开发包:",
|
||||
"label_server_address": "服务器地址:",
|
||||
"button_edit": "编辑",
|
||||
"button_add_another": "加上另一个",
|
||||
"sidepanel_title_acc_general": "常用设定",
|
||||
"blocked_contacts_link": "封锁的联络人 ({count})",
|
||||
"sidepanel_title_blocked": "被阻止的聊天",
|
||||
"push_init_failed": "初始化推送通知失败",
|
||||
"invalid_security_token": "无效的安全令牌",
|
||||
"no_connection": "无连接",
|
||||
"code_doesnot_match": "代码不匹配",
|
||||
"channel_prompt": "这是一个频道",
|
||||
"channel": "频道",
|
||||
"drafty_attachment": "附件",
|
||||
"drafty_form": "形式:",
|
||||
"drafty_image": "图像",
|
||||
"topic_delete": "删除聊天",
|
||||
"password_reset_email_sent": "一封电子邮件已发送至 {email}。按照电子邮件中的说明重置密码。",
|
||||
"menu_item_restore_topic": "从存档中恢复"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Put all packages together.
|
||||
// Used to generate umd/index.prod.js
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import allMessages from './messages.json';
|
||||
import TinodeWeb from './views/tinode-web.jsx';
|
||||
import HashNavigation from './lib/navigation.js';
|
||||
|
||||
// Detect human language to use in the UI:
|
||||
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
|
||||
const { params } = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const language = (params && params.hl) ||
|
||||
(navigator.languages && navigator.languages[0]) ||
|
||||
navigator.language ||
|
||||
navigator.userLanguage ||
|
||||
'en';
|
||||
|
||||
// Get the base language 'en' from a more specific 'en_GB' or 'en-US' as a partial fallback.
|
||||
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
|
||||
|
||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
||||
const messages =
|
||||
allMessages[language] ||
|
||||
allMessages[baseLanguage] ||
|
||||
allMessages.en;
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
|
||||
<TinodeWeb />
|
||||
</IntlProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('mountPoint')
|
||||
);
|
||||
@@ -0,0 +1,213 @@
|
||||
// File and image helper functions.
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
|
||||
import { bytesToHumanSize } from './strformat.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 makeImageDataUrl(photo) {
|
||||
if (photo) {
|
||||
if (photo.data && photo.type) {
|
||||
const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);
|
||||
return 'data:' + mime + ';base64,' + photo.data;
|
||||
}
|
||||
return photo.ref;
|
||||
}
|
||||
return 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);
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
Math.min(width, maxWidth) / width,
|
||||
Math.min(height, maxHeight) / height
|
||||
);
|
||||
|
||||
const 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) {
|
||||
const idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
|
||||
if (idx < 0 || !fname) {
|
||||
// Unknown mime or empty name.
|
||||
return fname;
|
||||
}
|
||||
const ext = MIME_EXTENSIONS[idx];
|
||||
|
||||
const at = fname.lastIndexOf('.');
|
||||
if (at >= 0) {
|
||||
fname = fname.substring(0, at);
|
||||
}
|
||||
return fname + '.' + ext;
|
||||
}
|
||||
|
||||
// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
|
||||
// On success calls onSuccess callback with the scaled image as Blob.
|
||||
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare, onSuccess, onError) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onerror = function(err) {
|
||||
onError("Image format unrecognized");
|
||||
}
|
||||
img.onload = async function() {
|
||||
// Once the image is loaded, the URL is no longer needed.
|
||||
URL.revokeObjectURL(img.src);
|
||||
|
||||
// Calculate the desired image dimensions.
|
||||
const dim = fitImageSize(this.width, this.height, maxWidth, maxHeight, forceSquare);
|
||||
if (!dim) {
|
||||
onError("Invalid image");
|
||||
return;
|
||||
}
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = dim.dstWidth;
|
||||
canvas.height = dim.dstHeight;
|
||||
let ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
0, 0, dim.dstWidth, dim.dstHeight);
|
||||
|
||||
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
|
||||
// Generate blob to check size of the image.
|
||||
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
if (!blob) {
|
||||
onError("Unsupported image format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the image is not too large. Shrink the image keeping the aspect ratio.
|
||||
// Do nothing if maxsize is <= 0.
|
||||
while (maxSize > 0 && blob.length > maxSize) {
|
||||
dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;
|
||||
dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;
|
||||
canvas.width = dim.dstWidth;
|
||||
canvas.height = dim.dstHeight;
|
||||
ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
0, 0, dim.dstWidth, dim.dstHeight);
|
||||
blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
}
|
||||
|
||||
canvas = null;
|
||||
onSuccess(mime, blob, dim.dstWidth, dim.dstHeight, fileNameForMime(fileOrBlob.name, mime));
|
||||
};
|
||||
img.src = URL.createObjectURL(fileOrBlob);
|
||||
}
|
||||
|
||||
// Convert File to base64 string.
|
||||
export function fileToBase64(file, onSuccess) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function() {
|
||||
onSuccess(file.type, reader.result.split(',')[1], file.name);
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Convert Blob to base64 string.
|
||||
export function blobToBase64(blob, onSuccess) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function() {
|
||||
onSuccess(blob.type, reader.result.split(',')[1]);
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
|
||||
// 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(/-/g, '+').replace(/_/g, '/');
|
||||
try {
|
||||
str = btoa(atob(str));
|
||||
} catch(err) {
|
||||
console.log("Failed to base64 re-encode string.", err);
|
||||
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) {
|
||||
const value = localStorage.getItem(key);
|
||||
return value && JSON.parse(value);
|
||||
}
|
||||
|
||||
// Partially or wholly update stored object.
|
||||
static updateObject(key, value) {
|
||||
const oldVal = this.getObject(key);
|
||||
this.setObject(key, Object.assign(oldVal || {}, value));
|
||||
}
|
||||
|
||||
// Just a wrapper.
|
||||
static removeItem(key) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
const parts = hash.split('?', 2);
|
||||
const 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) {
|
||||
let url = path.join('/');
|
||||
const args = [];
|
||||
for (const 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) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
parsed.params[key] = value;
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static removeUrlParam(hash, key) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
delete parsed.params[key];
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlSidePanel(hash, sidepanel) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
parsed.path[0] = sidepanel;
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlTopic(hash, topic) {
|
||||
const 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,50 @@
|
||||
// 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()) {
|
||||
// Same year.
|
||||
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
|
||||
// Same month and day, show time only.
|
||||
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
// Different month and/or day, show month day, time.
|
||||
return then.toLocaleDateString(locale,
|
||||
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
// Different year: just show the date.
|
||||
return then.toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
}
|
||||
|
||||
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
|
||||
export function secondsToTime(seconds) {
|
||||
const min = Math.floor(seconds / 60);
|
||||
let sec = seconds % 60;
|
||||
sec = sec < 10 ? `0${sec}` : sec;
|
||||
return `${min}:${sec}`;
|
||||
}
|
||||
|
||||
// Convert a number of bytes to human-readable format.
|
||||
export function bytesToHumanSize(bytes) {
|
||||
if (!bytes || bytes == 0) {
|
||||
return '0 Bytes';
|
||||
}
|
||||
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1);
|
||||
const count = bytes / Math.pow(1024, bucket);
|
||||
const round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
|
||||
return count.toFixed(round) + ' ' + sizes[bucket];
|
||||
}
|
||||
|
||||
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
|
||||
// as Java's String#hash().
|
||||
export function stringHash(value) {
|
||||
let hash = 0;
|
||||
value = '' + value;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = ((hash << 5) - hash) + value.charCodeAt(i);
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Odds and ends
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
// Make shortcut icon appear with a green dot + show unread count in title.
|
||||
export function updateFavicon(count) {
|
||||
const oldIcon = document.getElementById('shortcut-icon');
|
||||
const head = document.head || document.getElementsByTagName('head')[0];
|
||||
const newIcon = document.createElement('link');
|
||||
newIcon.type = 'image/png';
|
||||
newIcon.id = 'shortcut-icon';
|
||||
newIcon.rel = 'shortcut icon';
|
||||
newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png';
|
||||
if (oldIcon) {
|
||||
head.removeChild(oldIcon);
|
||||
}
|
||||
head.appendChild(newIcon);
|
||||
|
||||
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
|
||||
}
|
||||
|
||||
// Create theCard which represents user's or topic's "public" info.
|
||||
export function theCard(fn, imageUrl, imageMimeType) {
|
||||
let card = null;
|
||||
fn = fn && fn.trim();
|
||||
|
||||
if (fn) {
|
||||
card = {
|
||||
fn: fn
|
||||
};
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
card = card || {};
|
||||
let mimeType = imageMimeType;
|
||||
// Is this a data URL "data:[<mediatype>][;base64],<data>"?
|
||||
const matches = /^data:(image\/[-a-z0-9+.]+)?(;base64)?,/i.exec(imageUrl);
|
||||
if (matches) {
|
||||
mimeType = matches[1];
|
||||
card.photo = {
|
||||
data: imageUrl.substring(imageUrl.indexOf(',') + 1)
|
||||
};
|
||||
} else {
|
||||
card.photo = {
|
||||
ref: imageUrl
|
||||
};
|
||||
}
|
||||
card.photo.type = (mimeType || 'image/jpeg').substring('image/'.length);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Deep-shallow compare two arrays: shallow compare each element.
|
||||
export function arrayEqual(a, b) {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare lengths first.
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
// Order of elements is ignored.
|
||||
a.sort();
|
||||
b.sort();
|
||||
for (let i = 0, l = a.length; i < l; i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks (loosely) if the given string is a phone. If so, returns the phone number in a format
|
||||
// as close to E.164 as possible.
|
||||
export function asPhone(val) {
|
||||
val = val.trim();
|
||||
if (/^(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?$/.test(val)) {
|
||||
return val.replace(/[- ().]*/, '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Checks (loosely) if the given string is an email. If so returns the email.
|
||||
export function asEmail(val) {
|
||||
val = val.trim();
|
||||
if (/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(val)) {
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Checks if URL is a relative url, i.e. has no 'scheme://', including the case of missing scheme '//'.
|
||||
// The scheme is expected to be RFC-compliant, e.g. [a-z][a-z0-9+.-]*
|
||||
// example.html - ok
|
||||
// https:example.com - not ok.
|
||||
// http:/example.com - not ok.
|
||||
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
|
||||
export function isUrlRelative(url) {
|
||||
return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
|
||||
}
|
||||
|
||||
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
|
||||
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
|
||||
// If apikey and/or token is defined, it's appended to relative URL.
|
||||
export function sanitizeUrl(url, allowedSchemes) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip control characters and whitespace. They are not valid URL characters anyway.
|
||||
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
|
||||
|
||||
// Relative URLs are safe.
|
||||
// Relative URL does not start with ':', abcd123: or '//'.
|
||||
if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Blob URLs are safe.
|
||||
if (/^blob:http/.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Absolute URL. Accept only safe schemes, or no scheme.
|
||||
const schemes = Array.isArray(allowedSchemes) ? allowedSchemes.join('|') : 'http|https';
|
||||
const re = new RegExp('^((' + schemes + '):|//)', 'i');
|
||||
if (!re.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
||||
// have http:, https:, blob: or data: scheme.
|
||||
// The relative URL is appended with the API key and/or auth token.
|
||||
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
||||
export function sanitizeImageUrl(url) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitizedUrl = sanitizeUrl(url);
|
||||
if (sanitizedUrl) {
|
||||
return sanitizedUrl;
|
||||
}
|
||||
|
||||
// Is this a data: URL of an image?
|
||||
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Given message's received status, return name and color of a delivery indicator icon.
|
||||
export function deliveryMarker(received) {
|
||||
switch (received) {
|
||||
case Tinode.MESSAGE_STATUS_SENDING:
|
||||
return { name: 'access_time' }; // watch face
|
||||
case Tinode.MESSAGE_STATUS_FAILED:
|
||||
return { name: 'warning', color: 'amber' }; // yellow icon /!\
|
||||
case Tinode.MESSAGE_STATUS_SENT:
|
||||
return { name: 'done' }; // checkmark
|
||||
case Tinode.MESSAGE_STATUS_RECEIVED:
|
||||
return { name: 'done_all' }; // double checkmark
|
||||
case Tinode.MESSAGE_STATUS_READ:
|
||||
return { name: 'done_all', color: 'blue' }; // blue double checkmark
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.17.10";
|
||||
@@ -0,0 +1,189 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
||||
import TagManager from '../widgets/tag-manager.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { arrayEqual, asEmail, asPhone, theCard } from '../lib/utils.js';
|
||||
|
||||
export default class AccGeneralView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
this.state = {
|
||||
fullName: me.public ? me.public.fn : undefined,
|
||||
avatar: makeImageDataUrl(me.public ? me.public.photo : null),
|
||||
tags: me.tags(),
|
||||
credentials: me.getCredentials() || [],
|
||||
addCredActive: false,
|
||||
addCredInvalid: false,
|
||||
newCred: '',
|
||||
previousOnTags: me.onTagsUpdated
|
||||
};
|
||||
|
||||
this.tnNewTags = this.tnNewTags.bind(this);
|
||||
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
|
||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleCredChange = this.handleCredChange.bind(this);
|
||||
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
|
||||
this.handleCredEntered = this.handleCredEntered.bind(this);
|
||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
me.onCredsUpdated = this.tnCredsUpdated;
|
||||
me.onTagsUpdated = this.tnNewTags;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
me.onTagsUpdated = this.state.previousOnTags;
|
||||
me.onCredsUpdated = undefined;
|
||||
}
|
||||
|
||||
tnNewTags(tags) {
|
||||
this.setState({tags: tags});
|
||||
}
|
||||
|
||||
tnCredsUpdated(creds) {
|
||||
this.setState({credentials: creds || []});
|
||||
}
|
||||
|
||||
handleFullNameUpdate(fn) {
|
||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
||||
if (fn) {
|
||||
this.setState({fullName: fn});
|
||||
this.props.onUpdateAccount(undefined, theCard(fn, null));
|
||||
}
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
this.setState({avatar: img});
|
||||
this.props.onUpdateAccount(undefined, theCard(null, img || Tinode.DEL_CHAR));
|
||||
}
|
||||
|
||||
handleCredChange(e) {
|
||||
this.setState({newCred: e.target.value, addCredInvalid: false});
|
||||
}
|
||||
|
||||
handleCredKeyDown(e) {
|
||||
if (e.keyCode === 27) {
|
||||
// Escape pressed
|
||||
this.setState({newCred: '', addCredActive: false});
|
||||
} else if (e.keyCode === 13) {
|
||||
// Enter pressed
|
||||
this.handleCredEntered(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleCredEntered(e) {
|
||||
let value = this.state.newCred.trim();
|
||||
if (!value) {
|
||||
this.setState({addCredActive: false, addCredInvalid: false});
|
||||
return;
|
||||
}
|
||||
|
||||
let val = asPhone(value);
|
||||
let method;
|
||||
if (val) {
|
||||
method = 'tel';
|
||||
} else {
|
||||
val = asEmail(value);
|
||||
if (val) {
|
||||
method = 'email';
|
||||
}
|
||||
}
|
||||
if (method) {
|
||||
this.props.onCredAdd(method, val);
|
||||
this.setState({addCredActive: false, newCred: ''});
|
||||
} else {
|
||||
this.setState({addCredInvalid: true});
|
||||
}
|
||||
}
|
||||
|
||||
handleTagsUpdated(tags) {
|
||||
// Check if tags have actually changed.
|
||||
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||
return;
|
||||
}
|
||||
this.props.onUpdateTags(tags);
|
||||
}
|
||||
|
||||
render() {
|
||||
const credentials = [];
|
||||
this.state.credentials.map((cred) => {
|
||||
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
|
||||
<span > {!cred.done ?
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
|
||||
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
|
||||
description="Validate credentail call to action" />
|
||||
</a>
|
||||
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
|
||||
className="material-icons gray">delete_outline</i></a></span></div>);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<label className="small"><FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||
description="Label for full name editing" /></label>
|
||||
<div><FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
|
||||
description="Input placeholder for person's full name">{
|
||||
(full_name_placeholder) => <InPlaceEdit
|
||||
placeholder={full_name_placeholder}
|
||||
value={this.state.fullName}
|
||||
onFinished={this.handleFullNameUpdate} />
|
||||
}</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" />
|
||||
<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}
|
||||
tinode={this.props.tinode}
|
||||
onSubmit={this.handleTagsUpdated} />
|
||||
}</FormattedMessage>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
|
||||
description="Label for user contacts" />
|
||||
</label>
|
||||
<div className="quoted">
|
||||
{credentials}
|
||||
{this.state.addCredActive ?
|
||||
<input type="text" value={this.state.value}
|
||||
className={this.state.addCredInvalid ? 'invalid' : null}
|
||||
placeholder="Phone number or email" required="required" autoFocus
|
||||
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
|
||||
: null}
|
||||
<div>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
|
||||
<i className="material-icons">add</i>
|
||||
<FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
|
||||
export default class EditAccountView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
|
||||
}
|
||||
|
||||
handleCheckboxClick(what, checked) {
|
||||
if (what == 'sound') {
|
||||
this.props.onToggleMessageSounds(checked);
|
||||
} else if (what == 'alert') {
|
||||
this.props.onTogglePushNotifications(checked);
|
||||
} else if (what == 'incognito') {
|
||||
this.props.onToggleIncognitoMode(checked);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<label htmlFor="message-sound">
|
||||
<FormattedMessage id="label_message_sound" defaultMessage="Message sound:"
|
||||
description="Label for message sounds toggle" />
|
||||
</label>
|
||||
<CheckBox name="sound" id="message-sound"
|
||||
checked={this.props.messageSounds} onChange={this.handleCheckboxClick} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label htmlFor="desktop-alerts">
|
||||
{this.props.desktopAlertsEnabled ?
|
||||
<FormattedMessage id="label_push_notifications" defaultMessage="Notification alerts:"
|
||||
description="Label for push notifications switch" />
|
||||
:
|
||||
<FormattedMessage id="label_push_notifications_disabled"
|
||||
defaultMessage="Notification alerts (requires HTTPS):"
|
||||
description="Label for push notifications switch" />
|
||||
}
|
||||
</label>
|
||||
<CheckBox name="alert" id="desktop-alerts"
|
||||
checked={this.props.desktopAlerts}
|
||||
onChange={this.props.desktopAlertsEnabled ? this.handleCheckboxClick : null} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label htmlFor="incognito-mode">
|
||||
<FormattedMessage id="label_incognito_mode" defaultMessage="Incognito mode:"
|
||||
description="Label for incognito mode toggle" />
|
||||
</label>
|
||||
<CheckBox name="incognito" id="incognito-mode"
|
||||
checked={this.props.incognitoMode} onChange={this.handleCheckboxClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete_account: {
|
||||
id: 'delete_account',
|
||||
defaultMessage: 'Delete account',
|
||||
description: 'Title for delete account warning'
|
||||
},
|
||||
delete_account_warning: {
|
||||
id: 'delete_account_arning',
|
||||
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
|
||||
description: 'Warning message when deleting an account'
|
||||
}
|
||||
});
|
||||
|
||||
class AccSecurityView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
let blockedCount = 0;
|
||||
me.contacts((c) => {
|
||||
if (c.acs && !c.acs.isJoiner()) {
|
||||
blockedCount ++;
|
||||
}
|
||||
});
|
||||
const defacs = me.getDefaultAccess();
|
||||
this.state = {
|
||||
auth: defacs ? defacs.auth : null,
|
||||
anon: defacs ? defacs.anon : null,
|
||||
showPermissionEditorFor: undefined,
|
||||
blockedCount: blockedCount,
|
||||
};
|
||||
|
||||
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
|
||||
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
|
||||
}
|
||||
|
||||
handlePasswordUpdate(pwd) {
|
||||
this.setState({password: pwd});
|
||||
this.props.onUpdateAccount(pwd);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
handleDeleteAccount(e) {
|
||||
e.preventDefault();
|
||||
const {formatMessage} = this.props.intl;
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.delete_account), // title
|
||||
formatMessage(messages.delete_account_warning), // content
|
||||
(() => { this.props.onDeleteAccount(); }), // onConfirm
|
||||
null, // use default text "OK"
|
||||
true, // Show Reject button
|
||||
null // use default text "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>{this.state.showPermissionEditorFor ?
|
||||
<PermissionsEditor
|
||||
mode={this.state.editedPermissions}
|
||||
skip="O"
|
||||
onSubmit={this.handlePermissionsChanged}
|
||||
onCancel={this.handleHidePermissionsEditor} />
|
||||
:
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-column">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_password" defaultMessage="Password"
|
||||
description="Label for password editing" />
|
||||
</label>
|
||||
<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>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
|
||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
||||
defaultMessage="Logout" description="Button [Logout]" />
|
||||
</a>
|
||||
<a href="#" className="danger flat-button" onClick={(e) => {this.handleDeleteAccount(e);}}>
|
||||
<i className="material-icons">delete</i> <FormattedMessage id="button_delete_account"
|
||||
defaultMessage="Delete account" description="Button [Delete account]" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<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>
|
||||
{this.state.blockedCount > 0 ?
|
||||
<>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<i className="material-icons">block</i>
|
||||
<a href="#" className="gray" onClick={(e) => {e.preventDefault(); this.props.onShowBlocked();}}>
|
||||
<FormattedMessage id="blocked_contacts_link" defaultMessage="Blocked contacts ({count})"
|
||||
values={{ count: this.state.blockedCount }} description="Blocked contacts link" />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
: null }
|
||||
</div>
|
||||
}</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(AccSecurityView);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
|
||||
|
||||
export default class AccSupportView extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-column">
|
||||
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
|
||||
<i className="material-icons">email</i> <FormattedMessage id="link_contact_us"
|
||||
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
||||
</a>
|
||||
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
|
||||
<i className="material-icons">description</i> <FormattedMessage id="link_terms_of_service"
|
||||
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
|
||||
</a>
|
||||
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
|
||||
<i className="material-icons">policy</i> <FormattedMessage id="link_privacy_policy"
|
||||
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label className="small"><FormattedMessage id="label_client" defaultMessage="Client:"
|
||||
description="Label for a client version" /></label>
|
||||
{APP_NAME}
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
|
||||
{Tinode.getLibrary()}
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
|
||||
description="Label for a server version" /></label>
|
||||
{this.props.serverVersion}
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label className="small"><FormattedMessage id="label_server_address" defaultMessage="Server address:" /></label>
|
||||
{this.props.serverAddress}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/* ContactsView holds all contacts-related stuff */
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages } from 'react-intl';
|
||||
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
|
||||
import { updateFavicon } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
archived_contacts_title: {
|
||||
id: "archived_contacts",
|
||||
defaultMessage: "Archived contacts ({count})",
|
||||
description: "Label for archived chats"
|
||||
}
|
||||
});
|
||||
|
||||
export default class ContactsView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleAction = this.handleAction.bind(this);
|
||||
|
||||
this.state = ContactsView.deriveStateFromProps(props);
|
||||
}
|
||||
|
||||
static deriveStateFromProps(props) {
|
||||
const contacts = [];
|
||||
let unreadThreads = 0;
|
||||
let archivedCount = 0;
|
||||
props.chatList.map((c) => {
|
||||
const blocked = c.acs && !c.acs.isJoiner();
|
||||
// Show only blocked contacts only when props.blocked == true.
|
||||
if (blocked && props.blocked) {
|
||||
contacts.push(c);
|
||||
}
|
||||
if (blocked || props.blocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.private && c.private.arch) {
|
||||
if (props.archive) {
|
||||
contacts.push(c);
|
||||
} else {
|
||||
archivedCount ++;
|
||||
}
|
||||
} else if (!props.archive) {
|
||||
contacts.push(c);
|
||||
unreadThreads += c.unread > 0 ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
contacts.sort((a, b) => {
|
||||
return (b.touched || 0) - (a.touched || 0);
|
||||
});
|
||||
|
||||
if (archivedCount > 0) {
|
||||
contacts.push({
|
||||
action: 'archive',
|
||||
title: messages.archived_contacts_title,
|
||||
values: {count: archivedCount}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
contactList: contacts,
|
||||
unreadThreads: unreadThreads
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.chatList != this.props.chatList ||
|
||||
prevProps.archive != this.props.archive ||
|
||||
prevProps.blocked != this.props.blocked) {
|
||||
const newState = ContactsView.deriveStateFromProps(this.props);
|
||||
this.setState(newState);
|
||||
if (newState.unreadThreads != prevState.unreadThreads) {
|
||||
updateFavicon(newState.unreadThreads);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleAction(action_ignored) {
|
||||
this.props.onShowArchive();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FormattedMessage id="contacts_not_found"
|
||||
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
|
||||
description="HTML message shown in ContactList when no contacts are found">{
|
||||
(no_contacts) => <ContactList
|
||||
connected={this.props.connected}
|
||||
contacts={this.state.contactList}
|
||||
emptyListMessage={no_contacts}
|
||||
topicSelected={this.props.topicSelected}
|
||||
myUserId={this.props.myUserId}
|
||||
showOnline={true}
|
||||
showUnread={true}
|
||||
onTopicSelected={this.props.onTopicSelected}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
onAction={this.handleAction} />
|
||||
}</FormattedMessage>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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 { theCard } from '../lib/utils.js';
|
||||
|
||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
||||
|
||||
export default class CreateAccountView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
login: '',
|
||||
password: '',
|
||||
email: '',
|
||||
fn: '', // full/formatted name
|
||||
imageDataUrl: null,
|
||||
errorCleared: false,
|
||||
saveToken: LocalStorageUtil.getObject('keep-logged-in')
|
||||
};
|
||||
|
||||
this.handleLoginChange = this.handleLoginChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
this.handleFnChange = this.handleFnChange.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleLoginChange(e) {
|
||||
this.setState({login: e.target.value});
|
||||
}
|
||||
|
||||
handlePasswordChange(password) {
|
||||
this.setState({password: password});
|
||||
}
|
||||
|
||||
handleEmailChange(e) {
|
||||
this.setState({email: e.target.value})
|
||||
}
|
||||
|
||||
handleFnChange(e) {
|
||||
this.setState({fn: e.target.value});
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
this.setState({imageDataUrl: img});
|
||||
}
|
||||
|
||||
handleToggleSaveToken() {
|
||||
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
|
||||
this.setState({saveToken: !this.state.saveToken});
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.setState({errorCleared: false});
|
||||
this.props.onCreateAccount(
|
||||
this.state.login.trim(),
|
||||
this.state.password.trim(),
|
||||
theCard(this.state.fn.trim().substring(0, MAX_TITLE_LENGTH), this.state.imageDataUrl),
|
||||
{'meth': 'email', 'val': this.state.email});
|
||||
}
|
||||
|
||||
render() {
|
||||
let submitClasses = 'primary';
|
||||
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" defaultMessage="Login"
|
||||
description="Placeholer for username/login">{
|
||||
(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" defaultMessage="Password"
|
||||
description="Placeholder/prompt for entering password">{
|
||||
(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" defaultMessage="Stay logged in"
|
||||
description="Label for a checkbox">{
|
||||
(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,66 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
export default class EditAccountView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
this.state = {
|
||||
fullName: me.public ? me.public.fn : undefined,
|
||||
avatar: makeImageDataUrl(me.public ? me.public.photo : null)
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||
description="Label for full name editing" />
|
||||
</label>
|
||||
<div className="large">{this.state.fullName}</div>
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.props.myUserId}</tt>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
avatar={this.state.avatar}
|
||||
readOnly={!this.state.owner}
|
||||
uid={this.props.myUserId}
|
||||
title={this.state.fullName} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('notif');}}>
|
||||
<i className="material-icons">notifications</i> <FormattedMessage id="sidepanel_title_acc_notifications"
|
||||
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
|
||||
</a>
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="sidepanel_title_acc_security"
|
||||
defaultMessage="Security" description="Sidepanel title for AccSecurityView." />
|
||||
</a>
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('support');}}>
|
||||
<i className="material-icons">contact_support</i> <FormattedMessage id="sidepanel_title_acc_support"
|
||||
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,811 @@
|
||||
// InfoView: panel with topic/user info.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import GroupManager from '../widgets/group-manager.jsx';
|
||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
||||
import MenuCancel from '../widgets/menu-cancel.jsx';
|
||||
import MoreButton from '../widgets/more-button.jsx';
|
||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||
import TagManager from '../widgets/tag-manager.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
requested: {
|
||||
id: 'requested_permissions',
|
||||
defaultMessage: 'Requested',
|
||||
description: 'Title for permissions'
|
||||
},
|
||||
granted: {
|
||||
id: 'granted_permissions',
|
||||
defaultMessage: 'Granted',
|
||||
description: 'Title for permissions'
|
||||
},
|
||||
edit_permissions: {
|
||||
id: 'menu_item_edit_permissions',
|
||||
defaultMessage: 'Edit permissions',
|
||||
description: 'Menu item [Edit permissions]'
|
||||
},
|
||||
other_user: {
|
||||
id: 'label_other_user',
|
||||
defaultMessage: 'Other',
|
||||
description: 'Label for the other user when the user is unnamed'
|
||||
},
|
||||
clear_messages: {
|
||||
id: 'action_clear_messages',
|
||||
defaultMessage: 'Clear Messages',
|
||||
description: 'Flat button [Clear Messages] (soft-delete messages)'
|
||||
},
|
||||
clear_messages_warning: {
|
||||
id: 'clear_messages_warning',
|
||||
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
|
||||
description: 'Alert dialog warning when deleting all messages.'
|
||||
},
|
||||
delete_messages: {
|
||||
id: 'action_delete_messages',
|
||||
defaultMessage: 'Clear Messages for All',
|
||||
description: 'Flat button [Clear for All] (hard-delete all messages)'
|
||||
},
|
||||
delete_messages_warning: {
|
||||
id: 'delete_messages_warning',
|
||||
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
|
||||
description: 'Alert dialog warning when hard-deleting all messages.'
|
||||
},
|
||||
topic_delete: {
|
||||
id: 'topic_delete',
|
||||
defaultMessage: 'Delete Conversation',
|
||||
description: 'Alert title when deleting the topic.'
|
||||
},
|
||||
topic_delete_warning: {
|
||||
id: 'topic_delete_warning',
|
||||
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
|
||||
description: 'Alert warning when deleting entire topic'
|
||||
},
|
||||
leave_chat: {
|
||||
id: 'action_leave_chat',
|
||||
defaultMessage: 'Leave Conversation',
|
||||
description: 'Flat button [Leave Conversation]'
|
||||
},
|
||||
leave_chat_warning: {
|
||||
id: 'leave_chat_warning',
|
||||
defaultMessage: 'Are you sure you want to leave this conversation?',
|
||||
description: 'Alert dialog warning when unsubscribing from a chat.'
|
||||
},
|
||||
block_contact: {
|
||||
id: 'action_block_contact',
|
||||
defaultMessage: "Block Contact",
|
||||
description: "Flat button [Block Contact]"
|
||||
},
|
||||
block_contact_warning: {
|
||||
id: 'block_contact_warning',
|
||||
defaultMessage: 'Are you sure you want to block this contact?',
|
||||
description: 'Alert dialog warning when blocking a contact.'
|
||||
},
|
||||
report_chat: {
|
||||
id: 'action_report_chat',
|
||||
defaultMessage: 'Report Conversation',
|
||||
description: 'Flat button [Report Group]'
|
||||
},
|
||||
report_chat_warning: {
|
||||
id: 'report_chat_warning',
|
||||
defaultMessage: 'Are you sure you want to block and report this conversation?',
|
||||
description: 'Alert dialog warning when reporting a conversation for abuse'
|
||||
},
|
||||
});
|
||||
|
||||
class InfoView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
topic: null,
|
||||
owner: false,
|
||||
admin: false,
|
||||
sharer: false,
|
||||
deleter: false,
|
||||
muted: false,
|
||||
address: null,
|
||||
groupTopic: undefined,
|
||||
channel: 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.handleDeleteTopic = this.handleDeleteTopic.bind(this);
|
||||
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
|
||||
this.handleLeave = this.handleLeave.bind(this);
|
||||
this.handleBlock = this.handleBlock.bind(this);
|
||||
this.handleReport = this.handleReport.bind(this);
|
||||
this.handleMemberSelected = this.handleMemberSelected.bind(this);
|
||||
this.handleMoreInfo = this.handleMoreInfo.bind(this);
|
||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||
this.handleContextMenu = this.handleContextMenu.bind(this);
|
||||
}
|
||||
|
||||
// No need to separately handle component mount.
|
||||
componentDidUpdate(props) {
|
||||
const topic = this.props.tinode.getTopic(props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onMetaDesc != topic.onMetaDesc) {
|
||||
this.previousMetaDesc = topic.onMetaDesc;
|
||||
topic.onMetaDesc = this.onMetaDesc;
|
||||
|
||||
this.previousSubsUpdated = topic.onSubsUpdated;
|
||||
topic.onSubsUpdated = this.onSubsUpdated;
|
||||
|
||||
if (topic.getType() == 'grp') {
|
||||
this.previousTagsUpdated = topic.onTagsUpdated;
|
||||
topic.onTagsUpdated = this.onTagsUpdated;
|
||||
} else {
|
||||
this.previousTagsUpdated = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.topic != props.topic) {
|
||||
this.setState({topic: props.topic});
|
||||
this.resetDesc(topic, props);
|
||||
this.resetSubs(topic, props);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
this.setState({topic: null});
|
||||
topic.onMetaDesc = this.previousMetaDesc;
|
||||
topic.onSubsUpdated = this.previousSubsUpdated;
|
||||
topic.onTagsUpdated = this.previousTagsUpdated;
|
||||
}
|
||||
|
||||
resetSubs(topic, props) {
|
||||
const newState = {contactList: []};
|
||||
if (topic.getType() == 'p2p') {
|
||||
// Fetch the other party in the p2p conversation.
|
||||
// Topic may not be ready yet, so check if user is found.
|
||||
const user2 = topic.subscriber(props.topic);
|
||||
if (user2) {
|
||||
newState.modeGiven2 = user2.acs.getGiven();
|
||||
newState.modeWant2 = user2.acs.getWant();
|
||||
} else {
|
||||
newState.modeGiven2 = NO_ACCESS_MODE;
|
||||
newState.modeWant2 = NO_ACCESS_MODE;
|
||||
}
|
||||
} else {
|
||||
topic.subscribers((sub) => {
|
||||
newState.contactList.push(sub);
|
||||
}, this);
|
||||
}
|
||||
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
resetDesc(topic, props) {
|
||||
const defacs = topic.getDefaultAccess() || {};
|
||||
const acs = topic.getAccessMode();
|
||||
|
||||
this.setState({
|
||||
owner: acs && acs.isOwner(),
|
||||
admin: acs && acs.isAdmin(),
|
||||
sharer: acs && acs.isSharer(),
|
||||
deleter: acs && acs.isDeleter(),
|
||||
muted: acs && acs.isMuted(),
|
||||
|
||||
fullName: topic.public ? topic.public.fn : undefined,
|
||||
avatar: makeImageDataUrl(topic.public ? topic.public.photo : null),
|
||||
private: topic.private ? topic.private.comment : null,
|
||||
address: topic.name,
|
||||
groupTopic: topic.isGroupType(),
|
||||
channel: topic.isChannelType(),
|
||||
showMemberPanel: false,
|
||||
access: acs ? acs.getMode() : undefined,
|
||||
modeGiven: acs ? acs.getGiven() : undefined,
|
||||
modeWant: acs ? acs.getWant() : undefined,
|
||||
auth: defacs.auth,
|
||||
anon: defacs.anon
|
||||
});
|
||||
|
||||
if (topic.getType() == 'grp' && acs && acs.isOwner()) {
|
||||
// Requesting tags: owner is editing the topic.
|
||||
topic.getMeta(topic.startMetaQuery().withTags().build());
|
||||
}
|
||||
}
|
||||
|
||||
onMetaDesc(desc) {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
this.resetDesc(topic, this.props);
|
||||
|
||||
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
|
||||
this.previousMetaDesc(desc);
|
||||
}
|
||||
}
|
||||
|
||||
onSubsUpdated(subs) {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
this.resetSubs(topic, this.props);
|
||||
|
||||
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
|
||||
this.previousSubsUpdated(subs);
|
||||
}
|
||||
}
|
||||
|
||||
onTagsUpdated(tags) {
|
||||
this.setState({tags: tags});
|
||||
|
||||
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
|
||||
this.previousTagsUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
handleFullNameUpdate(fn) {
|
||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
||||
if (this.state.fullName !== fn) {
|
||||
this.setState({fullName: fn});
|
||||
this.props.onTopicDescUpdate(this.props.topic, theCard(fn, null), null);
|
||||
}
|
||||
}
|
||||
|
||||
handlePrivateUpdate(comment) {
|
||||
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
|
||||
if (this.state.private !== comment) {
|
||||
this.setState({private: comment});
|
||||
this.props.onTopicDescUpdate(this.props.topic, null, comment || Tinode.DEL_CHAR);
|
||||
}
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
this.setState({avatar: img});
|
||||
this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null);
|
||||
}
|
||||
|
||||
handleMuted(ignored, checked) {
|
||||
this.setState({muted: checked});
|
||||
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
|
||||
}
|
||||
|
||||
handlePermissionsChanged(perm) {
|
||||
switch (this.state.showPermissionEditorFor) {
|
||||
case 'auth':
|
||||
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
|
||||
break;
|
||||
case 'anon':
|
||||
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
|
||||
break;
|
||||
case 'mode':
|
||||
case 'want':
|
||||
this.props.onChangePermissions(this.props.topic, perm);
|
||||
break;
|
||||
case 'given':
|
||||
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
|
||||
break;
|
||||
case 'user':
|
||||
this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
|
||||
break;
|
||||
}
|
||||
|
||||
this.setState({showPermissionEditorFor: undefined});
|
||||
}
|
||||
|
||||
handleLaunchPermissionsEditor(which, uid) {
|
||||
const {formatMessage} = this.props.intl;
|
||||
let toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar;
|
||||
switch (which) {
|
||||
case 'mode':
|
||||
toEdit = this.state.access;
|
||||
break;
|
||||
case 'want':
|
||||
toEdit = this.state.modeWant;
|
||||
toCompare = this.state.modeGiven;
|
||||
if (this.state.owner) {
|
||||
// Don't allow owner to unset 'O' permission. All other permissions are OK.
|
||||
toSkip = 'O';
|
||||
} else {
|
||||
// Allow accepting any of 'ASDO' permissions but don't allow asking for them.
|
||||
toSkip = Tinode.AccessMode.encode(Tinode.AccessMode.diff('ASDO', this.state.modeGiven));
|
||||
if (this.state.channel) {
|
||||
// Channels are read-only.
|
||||
toSkip += 'W';
|
||||
}
|
||||
}
|
||||
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': {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
const user = topic.subscriber(uid);
|
||||
if (!user || !user.acs) {
|
||||
return;
|
||||
}
|
||||
toEdit = user.acs.getGiven();
|
||||
toCompare = user.acs.getWant();
|
||||
toSkip = this.state.owner ? '' : 'O';
|
||||
titleEdit = formatMessage(messages.granted);
|
||||
titleCompare = formatMessage(messages.requested);
|
||||
if (user.public) {
|
||||
userTitle = user.public.fn;
|
||||
userAvatar = user.public.photo;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log("Unknown permission editing mode '" + which + "'");
|
||||
break;
|
||||
}
|
||||
this.setState({
|
||||
showPermissionEditorFor: which,
|
||||
userPermissionsEdited: uid,
|
||||
userPermissionsTitle: userTitle,
|
||||
userPermissionsAvatar: userAvatar,
|
||||
editedPermissions: toEdit,
|
||||
immutablePermissions: toCompare,
|
||||
editedPermissionsTitle: titleEdit,
|
||||
immutablePermissionsTitle: titleCompare,
|
||||
editedPermissionsSkipped: toSkip,
|
||||
});
|
||||
}
|
||||
|
||||
handleHidePermissionsEditor() {
|
||||
this.setState({showPermissionEditorFor: undefined});
|
||||
}
|
||||
|
||||
handleShowAddMembers(e) {
|
||||
e.preventDefault();
|
||||
this.props.onInitFind();
|
||||
this.setState({showMemberPanel: true});
|
||||
}
|
||||
|
||||
handleHideAddMembers() {
|
||||
this.setState({showMemberPanel: false});
|
||||
}
|
||||
|
||||
handleMemberUpdateRequest(members, added, removed) {
|
||||
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
|
||||
this.setState({showMemberPanel: false});
|
||||
}
|
||||
|
||||
handleDeleteTopic(e) {
|
||||
e.preventDefault();
|
||||
const {formatMessage} = this.props.intl;
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.topic_delete), // title
|
||||
formatMessage(messages.topic_delete_warning), // content
|
||||
(() => { this.props.onDeleteTopic(this.props.topic); }), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
handleDeleteMessages(e) {
|
||||
e.preventDefault();
|
||||
const {formatMessage} = this.props.intl;
|
||||
this.props.onShowAlert(
|
||||
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages), // title
|
||||
formatMessage(this.state.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
|
||||
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
handleLeave(e) {
|
||||
e.preventDefault();
|
||||
const {formatMessage} = this.props.intl;
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.leave_chat), // title
|
||||
formatMessage(messages.leave_chat_warning), // content
|
||||
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
handleBlock(e) {
|
||||
e.preventDefault();
|
||||
const {formatMessage} = this.props.intl;
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.block_contact), // title
|
||||
formatMessage(messages.block_contact_warning), // content
|
||||
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
handleReport(e) {
|
||||
e.preventDefault();
|
||||
const {formatMessage} = this.props.intl;
|
||||
this.props.onShowAlert(
|
||||
formatMessage(messages.report_chat), // title
|
||||
formatMessage(messages.report_chat_warning), // content
|
||||
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
handleMemberSelected(uid) {
|
||||
this.setState({selectedContact: uid});
|
||||
}
|
||||
|
||||
handleMoreInfo(open) {
|
||||
this.setState({moreInfoExpanded: open});
|
||||
}
|
||||
|
||||
handleTagsUpdated(tags) {
|
||||
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||
this.props.onTopicTagsUpdate(this.props.topic, tags);
|
||||
}
|
||||
}
|
||||
|
||||
handleContextMenu(params) {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const instance = this;
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
const user = topic.subscriber(params.topicName);
|
||||
if (!user || !user.acs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{title: formatMessage(messages.edit_permissions), handler: function() {
|
||||
instance.handleLaunchPermissionsEditor('user', params.topicName);
|
||||
}},
|
||||
'member_delete',
|
||||
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
|
||||
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
|
||||
];
|
||||
this.props.showContextMenu({
|
||||
topicName: this.props.topic,
|
||||
x: params.x,
|
||||
y: params.y,
|
||||
user: params.topicName}, menuItems);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
return (
|
||||
<div id="info-view">
|
||||
<div className="caption-panel" id="info-caption-panel">
|
||||
<div className="panel-title" id="info-title">
|
||||
<FormattedMessage id="title_info" defaultMessage="Info" description="Title for InfoView" />
|
||||
</div>
|
||||
<div>
|
||||
<MenuCancel onCancel={this.props.onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} /> : null}
|
||||
{this.state.showMemberPanel ?
|
||||
<GroupManager
|
||||
members={this.state.contactList}
|
||||
requiredMember={this.props.myUserId}
|
||||
keepInitialMembers={!this.state.admin && !this.state.owner}
|
||||
myUserId={this.props.myUserId}
|
||||
contacts={this.props.searchableContacts}
|
||||
onCancel={this.handleHideAddMembers}
|
||||
onSubmit={this.handleMemberUpdateRequest} />
|
||||
:
|
||||
this.state.showPermissionEditorFor ?
|
||||
<PermissionsEditor
|
||||
mode={this.state.editedPermissions}
|
||||
compare={this.state.immutablePermissions}
|
||||
skip={this.state.editedPermissionsSkipped}
|
||||
modeTitle={this.state.editedPermissionsTitle}
|
||||
compareTitle={this.state.immutablePermissionsTitle}
|
||||
userTitle={this.state.userPermissionsTitle}
|
||||
item={this.state.userPermissionsEdited}
|
||||
userAvatar={this.state.userPermissionsAvatar}
|
||||
onSubmit={this.handlePermissionsChanged}
|
||||
onCancel={this.handleHidePermissionsEditor}
|
||||
/>
|
||||
:
|
||||
<div id="info-view-content" className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<div><label className="small">
|
||||
<FormattedMessage id="label_topic_name" defaultMessage="Name"
|
||||
description="Label for editing topic name" />
|
||||
</label></div>
|
||||
<div><InPlaceEdit
|
||||
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
|
||||
readOnly={!this.state.owner}
|
||||
value={this.state.fullName}
|
||||
required={true}
|
||||
onFinished={this.handleFullNameUpdate} /></div>
|
||||
<div>
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_private" defaultMessage="Private comment"
|
||||
description="Label for editing 'private'" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage id="private_editing_placeholder"
|
||||
defaultMessage="Visible to you only"
|
||||
description="Placeholder for editing 'private'">{
|
||||
(private_placeholder) => <InPlaceEdit
|
||||
placeholder={private_placeholder}
|
||||
value={this.state.private}
|
||||
onFinished={this.handlePrivateUpdate} />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
avatar={this.state.avatar}
|
||||
readOnly={!this.state.owner}
|
||||
uid={this.props.topic}
|
||||
title={this.state.fullName}
|
||||
onImageChanged={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label>
|
||||
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
|
||||
description="Label for Muting/unmuting the topic" />
|
||||
</label>
|
||||
<CheckBox name="P" checked={this.state.muted}
|
||||
onChange={this.handleMuted} />
|
||||
</div>
|
||||
<FormattedMessage id="action_more" defaultMessage="More"
|
||||
description="Action for showing more content">{
|
||||
(more) => <MoreButton
|
||||
title={more}
|
||||
open={this.state.moreInfoExpanded}
|
||||
onToggle={this.handleMoreInfo} />
|
||||
}</FormattedMessage>
|
||||
{this.state.moreInfoExpanded ?
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (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" defaultMessage="Tags (user discovery)"
|
||||
description="Section title for TagManager">{
|
||||
(tags) => <TagManager
|
||||
title={tags}
|
||||
tags={this.state.tags}
|
||||
activated={false}
|
||||
tinode={this.props.tinode}
|
||||
onSubmit={this.handleTagsUpdated} />
|
||||
}</FormattedMessage>
|
||||
<div className="hr" />
|
||||
</>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className="panel-form-column">
|
||||
{!this.state.channel ?
|
||||
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
|
||||
<i className="material-icons">delete_outline</i> {
|
||||
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
|
||||
}
|
||||
</a>
|
||||
:
|
||||
null
|
||||
}
|
||||
{this.state.owner ?
|
||||
<a href="#" className="danger flat-button" onClick={this.handleDeleteTopic}>
|
||||
<i className="material-icons">delete</i> {formatMessage(messages.topic_delete)}
|
||||
</a>
|
||||
:
|
||||
<a href="#" className="danger flat-button" onClick={this.handleLeave}>
|
||||
<i className="material-icons">exit_to_app</i> {formatMessage(messages.leave_chat)}
|
||||
</a>
|
||||
}
|
||||
{!this.state.groupTopic ?
|
||||
<a href="#" className="danger flat-button" onClick={this.handleBlock}>
|
||||
<i className="material-icons">block</i> {formatMessage(messages.block_contact)}
|
||||
</a>
|
||||
:
|
||||
null
|
||||
}
|
||||
{!this.state.owner ?
|
||||
<a href="#" className="danger flat-button" onClick={this.handleReport}>
|
||||
<i className="material-icons">report</i> {formatMessage(messages.report_chat)}
|
||||
</a>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
{this.state.groupTopic && this.state.sharer ?
|
||||
<>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
|
||||
description="Section title or label" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
|
||||
<i className="material-icons">person_add</i> <FormattedMessage id="button_add_members"
|
||||
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
|
||||
</a>
|
||||
</div>
|
||||
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
|
||||
description="Shown in place of group members">{
|
||||
(no_members) => <ContactList
|
||||
contacts={this.state.contactList}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage={no_members}
|
||||
topicSelected={this.state.selectedContact}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showMode={true}
|
||||
noScroll={true}
|
||||
onTopicSelected={this.handleMemberSelected}
|
||||
showContextMenu={this.state.admin ? this.handleContextMenu : false}
|
||||
/>
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(InfoView);
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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: props.persist
|
||||
};
|
||||
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() {
|
||||
this.props.onPersistenceChange(!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() {
|
||||
let submitClasses = 'primary';
|
||||
if (this.props.disabled) {
|
||||
submitClasses += ' disabled';
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="login-form" onSubmit={this.handleSubmit}>
|
||||
<FormattedMessage id="login_prompt" defaultMessage="Login"
|
||||
description="Placeholer for username/login">
|
||||
{
|
||||
(login_prompt) => <input type="text" id="inputLogin"
|
||||
placeholder={login_prompt}
|
||||
autoComplete="username"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
value={this.state.login}
|
||||
onChange={this.handleLoginChange}
|
||||
required autoFocus />
|
||||
}
|
||||
</FormattedMessage>
|
||||
<FormattedMessage id="password_prompt" defaultMessage="Password"
|
||||
description="Placeholder/prompt for entering password">
|
||||
{
|
||||
(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,27 @@
|
||||
/* 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() {
|
||||
const version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
|
||||
return (
|
||||
<div id="dummy-view">
|
||||
<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:"
|
||||
description="Label for a client version" /> {version}</p>
|
||||
<p><FormattedMessage id="label_server" defaultMessage="Server:"
|
||||
description="Label for a server version" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,987 @@
|
||||
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 DocPreview from '../widgets/doc-preview.jsx';
|
||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import GroupSubs from '../widgets/group-subs.jsx';
|
||||
import ImagePreview from '../widgets/image-preview.jsx';
|
||||
import Invitation from '../widgets/invitation.jsx';
|
||||
import LetterTile from '../widgets/letter-tile.jsx';
|
||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||
import LogoView from './logo-view.jsx';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { DEFAULT_P2P_ACCESS_MODE, IMAGE_PREVIEW_DIM, KEYPRESS_DELAY, MESSAGES_PAGE,
|
||||
MAX_EXTERN_ATTACHMENT_SIZE, MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
|
||||
import { SUPPORTED_IMAGE_FORMATS, blobToBase64, filePasted, fileToBase64,
|
||||
imageScaled, makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
// Run timer with this frequency (ms) for checking notification queue.
|
||||
const NOTIFICATION_EXEC_INTERVAL = 300;
|
||||
|
||||
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'
|
||||
},
|
||||
channel: {
|
||||
id: 'channel',
|
||||
defaultMessage: 'channel',
|
||||
description: 'Subtitle shown for channels in MessagesView instead of last seen'
|
||||
},
|
||||
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'
|
||||
}
|
||||
});
|
||||
|
||||
// Checks if the access permissions are granted but not yet accepted.
|
||||
function isUnconfirmed(acs) {
|
||||
if (acs) {
|
||||
const ex = acs.getExcessive() || '';
|
||||
return acs.isJoiner('given') && (ex.includes('R') || ex.includes('W'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPeerRestricted(acs) {
|
||||
if (acs) {
|
||||
const ms = acs.getMissing() || '';
|
||||
return acs.isJoiner('want') && (ms.includes('R') || ms.includes('W'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class MessagesView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = MessagesView.getDerivedStateFromProps(props, {});
|
||||
|
||||
this.leave = this.leave.bind(this);
|
||||
this.sendImageAttachment = this.sendImageAttachment.bind(this);
|
||||
this.sendFileAttachment = this.sendFileAttachment.bind(this);
|
||||
this.sendKeyPress = this.sendKeyPress.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.handleMessageUpdate = this.handleMessageUpdate.bind(this);
|
||||
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
|
||||
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
|
||||
this.handleImagePostview = this.handleImagePostview.bind(this);
|
||||
this.handleClosePreview = this.handleClosePreview.bind(this);
|
||||
this.handleFormResponse = this.handleFormResponse.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
|
||||
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
|
||||
this.handleEnablePeer = this.handleEnablePeer.bind(this);
|
||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||
this.handleAttachImage = this.handleAttachImage.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.postReadNotification = this.postReadNotification.bind(this);
|
||||
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
|
||||
|
||||
this.readNotificationQueue = [];
|
||||
this.readNotificationTimer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// this.propsChange(this.props, this.state);
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.messagesScroller) {
|
||||
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
|
||||
}
|
||||
|
||||
// Flush all notifications.
|
||||
this.clearNotificationQueue();
|
||||
}
|
||||
|
||||
// Scroll last message into view on component update e.g. on message received
|
||||
// or vertical shrinking.
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (this.messagesScroller) {
|
||||
if (prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount) {
|
||||
// New message
|
||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
|
||||
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
|
||||
// Componet changed height.
|
||||
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const topic = this.props.tinode ? this.props.tinode.getTopic(this.state.topic) : undefined;
|
||||
if (this.state.topic != prevState.topic) {
|
||||
if (prevState.topic && !Tinode.isNewGroupTopicName(prevState.topic)) {
|
||||
this.leave(prevState.topic);
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
topic.onData = this.handleMessageUpdate;
|
||||
topic.onAllMessagesReceived = this.handleAllMessagesReceived;
|
||||
topic.onInfo = this.handleInfoReceipt;
|
||||
topic.onMetaDesc = this.handleDescChange;
|
||||
topic.onSubsUpdated = this.handleSubsUpdated;
|
||||
topic.onPres = this.handleSubsUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.props.applicationVisible) {
|
||||
// If application is not visible, flush all unsent 'read' notifications.
|
||||
this.clearNotificationQueue();
|
||||
} else {
|
||||
// Otherwise assume there are unread messages.
|
||||
this.postReadNotification(0);
|
||||
}
|
||||
|
||||
if (topic && !topic.isSubscribed() && this.props.ready &&
|
||||
((this.state.topic != prevState.topic) || !prevProps.ready)) {
|
||||
// Is this a new topic?
|
||||
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
|
||||
|
||||
// Don't request the tags. They are useless unless the user
|
||||
// is the owner and is editing the topic.
|
||||
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
|
||||
if (this.state.isReader || newTopic) {
|
||||
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
|
||||
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
|
||||
if (this.state.isReader) {
|
||||
getQuery = getQuery.withLaterDel();
|
||||
}
|
||||
// And show "loading" spinner.
|
||||
this.setState({ fetchingMessages: true });
|
||||
}
|
||||
const setQuery = newTopic ? this.props.newTopicParams : undefined;
|
||||
topic.subscribe(getQuery.build(), setQuery)
|
||||
.then((ctrl) => {
|
||||
if (ctrl.code == 303) {
|
||||
// Redirect to another topic requested.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
|
||||
return;
|
||||
}
|
||||
if (this.state.topic != ctrl.topic) {
|
||||
this.setState({topic: ctrl.topic});
|
||||
}
|
||||
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
|
||||
// If there are unsent messages, try sending them now.
|
||||
topic.queuedMessages((pub) => {
|
||||
if (!pub._sending && topic.isSubscribed()) {
|
||||
topic.publishMessage(pub);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed subscription to", this.state.topic);
|
||||
this.props.onError(err.message, 'err');
|
||||
const blankState = MessagesView.getDerivedStateFromProps({}, {});
|
||||
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
||||
this.setState(blankState);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
let nextState = {};
|
||||
if (!nextProps.topic) {
|
||||
// Default state: no topic.
|
||||
nextState = {
|
||||
messageCount: 0,
|
||||
latestClearId: -1,
|
||||
onlineSubs: [],
|
||||
topic: null,
|
||||
title: '',
|
||||
avatar: null,
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
fetchingMessages: false,
|
||||
peerMessagingDisabled: false,
|
||||
channel: false
|
||||
};
|
||||
} else if (nextProps.topic != prevState.topic) {
|
||||
const topic = nextProps.tinode.getTopic(nextProps.topic);
|
||||
nextState = {
|
||||
topic: nextProps.topic,
|
||||
docPreview: null,
|
||||
imagePreview: null,
|
||||
imagePostview: null,
|
||||
typingIndicator: false,
|
||||
scrollPosition: 0,
|
||||
fetchingMessages: false
|
||||
};
|
||||
|
||||
if (topic) {
|
||||
// Topic exists.
|
||||
const subs = [];
|
||||
|
||||
if (nextProps.connected) {
|
||||
topic.subscribers((sub) => {
|
||||
if (sub.online && sub.user != nextProps.myUserId) {
|
||||
subs.push(sub);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(nextState, {
|
||||
onlineSubs: subs
|
||||
});
|
||||
|
||||
if (topic.public) {
|
||||
Object.assign(nextState, {
|
||||
title: topic.public.fn,
|
||||
avatar: makeImageDataUrl(topic.public.photo)
|
||||
});
|
||||
} else {
|
||||
Object.assign(nextState, {
|
||||
title: '',
|
||||
avatar: null
|
||||
});
|
||||
}
|
||||
|
||||
const peer = topic.p2pPeerDesc();
|
||||
if (peer) {
|
||||
Object.assign(nextState, {
|
||||
peerMessagingDisabled: isPeerRestricted(peer.acs)
|
||||
});
|
||||
} else if (prevState.peerMessagingDisabled) {
|
||||
Object.assign(nextState, {
|
||||
peerMessagingDisabled: false
|
||||
});
|
||||
}
|
||||
Object.assign(nextState, {
|
||||
messageCount: topic.messageCount(),
|
||||
latestClearId: topic.maxClearId(),
|
||||
channel: topic.isChannelType()
|
||||
});
|
||||
} else {
|
||||
// Invalid topic.
|
||||
Object.assign(nextState, {
|
||||
messageCount: 0,
|
||||
latestClearId: -1,
|
||||
onlineSubs: [],
|
||||
title: '',
|
||||
avatar: null,
|
||||
peerMessagingDisabled: false,
|
||||
channel: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (nextProps.acs) {
|
||||
if (nextProps.acs.isWriter() != prevState.isWriter) {
|
||||
nextState.isWriter = !prevState.isWriter;
|
||||
}
|
||||
if (nextProps.acs.isReader() != prevState.isReader) {
|
||||
nextState.isReader = !prevState.isReader;
|
||||
}
|
||||
if (!nextProps.acs.isReader('given') != prevState.readingBlocked) {
|
||||
nextState.readingBlocked = !prevState.readingBlocked;
|
||||
}
|
||||
if (nextProps.acs.isSharer() != prevState.isSharer) {
|
||||
nextState.isSharer = !prevState.isSharer;
|
||||
}
|
||||
} else {
|
||||
if (prevState.isWriter) {
|
||||
nextState.isWriter = false;
|
||||
}
|
||||
if (prevState.isReader) {
|
||||
nextState.isReader = false;
|
||||
}
|
||||
if (!prevState.readingBlocked) {
|
||||
prevState.readingBlocked = true;
|
||||
}
|
||||
if (prevState.isSharer) {
|
||||
nextState.isSharer = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
|
||||
nextState.unconfirmed = !prevState.unconformed;
|
||||
}
|
||||
|
||||
// Clear subscribers online when there is no connection.
|
||||
if (!nextProps.connected && prevState.onlineSubs && prevState.onlineSubs.length > 0) {
|
||||
nextState.onlineSubs = [];
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
leave(oldTopicName) {
|
||||
if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) {
|
||||
return;
|
||||
}
|
||||
const 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;
|
||||
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// Get older messages
|
||||
handleScrollEvent(event) {
|
||||
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
|
||||
if (this.state.fetchingMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.scrollTop <= 0) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
|
||||
this.setState({fetchingMessages: true}, () => {
|
||||
topic.getMessagesPage(MESSAGES_PAGE)
|
||||
.catch((err) => this.props.onError(err.message, 'err'))
|
||||
.finally(() => this.setState({fetchingMessages: false}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDescChange(desc) {
|
||||
if (desc.public) {
|
||||
this.setState({
|
||||
title: desc.public.fn,
|
||||
avatar: makeImageDataUrl(desc.public.photo)
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
title: '',
|
||||
avatar: null
|
||||
});
|
||||
}
|
||||
|
||||
if (desc.acs) {
|
||||
this.setState({
|
||||
isWriter: desc.acs.isWriter(),
|
||||
isReader: desc.acs.isReader(),
|
||||
readingBlocked: !desc.acs.isReader('given'),
|
||||
unconfirmed: isUnconfirmed(desc.acs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
postReadNotification(seq) {
|
||||
// Ignore notifications if the app is invisible.
|
||||
if (!this.props.applicationVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the timer if it's not running already.
|
||||
if (!this.readNotificationTimer) {
|
||||
this.readNotificationTimer = setInterval(() => {
|
||||
if (this.readNotificationQueue.length == 0) {
|
||||
// Shut down the timer if the queue is empty.
|
||||
clearInterval(this.readNotificationTimer);
|
||||
this.readNotificationTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let seq = -1;
|
||||
while (this.readNotificationQueue.length > 0) {
|
||||
const n = this.readNotificationQueue[0];
|
||||
if (n.topicName != this.state.topic) {
|
||||
// Topic has changed. Drop the notification.
|
||||
this.readNotificationQueue.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (n.sendAt <= now) {
|
||||
// Remove expired notification from queue.
|
||||
this.readNotificationQueue.shift();
|
||||
seq = Math.max(seq, n.seq);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Send only one notification for the whole batch of messages.
|
||||
if (seq >= 0) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic) {
|
||||
topic.noteRead(seq);
|
||||
}
|
||||
}
|
||||
}, NOTIFICATION_EXEC_INTERVAL);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
this.readNotificationQueue.push({
|
||||
topicName: this.state.topic,
|
||||
seq: seq,
|
||||
sendAt: now.setMilliseconds(now.getMilliseconds() + READ_DELAY)
|
||||
});
|
||||
}
|
||||
|
||||
// Clear notification queue and timer.
|
||||
clearNotificationQueue() {
|
||||
this.readNotificationQueue = [];
|
||||
if (this.readNotificationTimer) {
|
||||
clearInterval(this.readNotificationTimer);
|
||||
this.readNotificationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleSubsUpdated() {
|
||||
if (this.state.topic) {
|
||||
const subs = [];
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
topic.subscribers((sub) => {
|
||||
if (sub.online && sub.user != this.props.myUserId) {
|
||||
subs.push(sub);
|
||||
}
|
||||
});
|
||||
const newState = {onlineSubs: subs};
|
||||
const peer = topic.p2pPeerDesc();
|
||||
if (peer) {
|
||||
Object.assign(newState, {
|
||||
peerMessagingDisabled: isPeerRestricted(peer.acs)
|
||||
});
|
||||
} else if (this.state.peerMessagingDisabled) {
|
||||
Object.assign(newState, {
|
||||
peerMessagingDisabled: false
|
||||
});
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
// The 'msg' could be false-ish if some message ranges were deleted.
|
||||
handleMessageUpdate(msg) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (!msg) {
|
||||
// msg could be null if one or more messages were deleted.
|
||||
// Updating state to force redraw.
|
||||
this.setState({latestClearId: topic.maxClearId()});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({messageCount: topic.messageCount()});
|
||||
|
||||
// Scroll to the bottom if the message is added to the end of the message list.
|
||||
// TODO: This should be replaced by showing a "scroll to bottom" button.
|
||||
if (topic.isNewMessage(msg.seq)) {
|
||||
this.setState({scrollPosition: 0});
|
||||
}
|
||||
|
||||
// Aknowledge messages except own messages. They are
|
||||
// automatically assumed to be read and recived.
|
||||
const status = topic.msgStatus(msg, true);
|
||||
if (status >= Tinode.MESSAGE_STATUS_SENT && msg.from != this.props.myUserId) {
|
||||
this.postReadNotification(msg.seq);
|
||||
}
|
||||
// This will send "received" notifications right away.
|
||||
this.props.onData(msg);
|
||||
}
|
||||
|
||||
handleAllMessagesReceived(count) {
|
||||
this.setState({fetchingMessages: false});
|
||||
if (count > 0) {
|
||||
// 0 means "latest".
|
||||
this.postReadNotification(0);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
handleImagePostview(content) {
|
||||
this.setState({ imagePostview: content });
|
||||
}
|
||||
|
||||
handleClosePreview() {
|
||||
if (this.state.imagePreview && this.state.imagePreview.url) {
|
||||
URL.revokeObjectURL(this.state.imagePreview.url);
|
||||
}
|
||||
this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
|
||||
}
|
||||
|
||||
handleFormResponse(action, text, data) {
|
||||
if (action == 'pub') {
|
||||
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
|
||||
} else if (action == 'url') {
|
||||
const url = new URL(data.ref);
|
||||
const params = url.searchParams;
|
||||
for (let key in data.resp) {
|
||||
if (data.resp.hasOwnProperty(key)) {
|
||||
params.set(key, data.resp[key]);
|
||||
}
|
||||
}
|
||||
['name', 'seq'].map((key) => {
|
||||
if (data[key]) {
|
||||
params.set(key, data[key]);
|
||||
}
|
||||
});
|
||||
params.set('uid', this.props.myUserId);
|
||||
params.set('topic', this.state.topic);
|
||||
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, messageSpecificMenuItems) {
|
||||
params.topicName = this.state.topic;
|
||||
const menuItems = messageSpecificMenuItems || [];
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (topic) {
|
||||
if (!topic.isChannelType()) {
|
||||
menuItems.push('message_delete');
|
||||
}
|
||||
const acs = topic.getAccessMode();
|
||||
if (acs && acs.isDeleter()) {
|
||||
menuItems.push('message_delete_hard');
|
||||
}
|
||||
}
|
||||
this.props.showContextMenu(params, menuItems);
|
||||
}
|
||||
|
||||
handleNewChatAcceptance(action) {
|
||||
this.props.onNewChat(this.state.topic, action);
|
||||
}
|
||||
|
||||
handleEnablePeer(e) {
|
||||
e.preventDefault();
|
||||
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
|
||||
}
|
||||
|
||||
sendKeyPress() {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
if (topic.isSubscribed()) {
|
||||
topic.noteKeyPress();
|
||||
}
|
||||
}
|
||||
|
||||
// Send attachment as Drafty message:
|
||||
// - if file is too large, upload it and send a s link.
|
||||
// - if file is small enough, just send it in-band.
|
||||
sendFileAttachment(file) {
|
||||
// Server-provided limit reduced for base64 encoding and overhead.
|
||||
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
|
||||
MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
|
||||
|
||||
if (file.size > maxInbandAttachmentSize) {
|
||||
// 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(this.props.intl.formatMessage(messages.cannot_initiate_upload));
|
||||
return;
|
||||
}
|
||||
const uploadCompletionPromise = uploader.upload(file);
|
||||
const msg = Drafty.attachFile(null, {
|
||||
mime: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
urlPromise: 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: mime, data: bits, filename: fname}));
|
||||
},
|
||||
this.props.onError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// handleAttachFile method is called when [Attach file] button is clicked.
|
||||
handleAttachFile(file) {
|
||||
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
||||
|
||||
if (file.size > maxExternAttachmentSize) {
|
||||
// Too large.
|
||||
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
|
||||
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(maxExternAttachmentSize)}), 'err');
|
||||
} else {
|
||||
this.setState({ docPreview: {
|
||||
file: file,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}});
|
||||
}
|
||||
}
|
||||
|
||||
// sendImageAttachment sends the image bits inband as Drafty message.
|
||||
sendImageAttachment(caption, blob) {
|
||||
const mime = this.state.imagePreview.type;
|
||||
const width = this.state.imagePreview.width;
|
||||
const height = this.state.imagePreview.height;
|
||||
const fname = this.state.imagePreview.filename;
|
||||
|
||||
// Server-provided limit reduced for base64 encoding and overhead.
|
||||
const maxInbandAttachmentSize = (this.props.tinode.getServerLimit('maxMessageSize',
|
||||
MAX_INBAND_ATTACHMENT_SIZE) * 0.75 - 1024) | 0;
|
||||
|
||||
if (blob.size > maxInbandAttachmentSize) {
|
||||
// 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(this.props.intl.formatMessage(messages.cannot_initiate_upload));
|
||||
return;
|
||||
}
|
||||
const uploadCompletionPromise = uploader.upload(blob);
|
||||
|
||||
// Make small preview to show while uploading.
|
||||
imageScaled(blob, IMAGE_PREVIEW_DIM, IMAGE_PREVIEW_DIM, -1, false,
|
||||
(tinyMine, tinyBlob) => {
|
||||
// Convert tiny image into base64 for serialization and previewing.
|
||||
blobToBase64(tinyBlob, (blobMime, tinyBits64) => {
|
||||
let msg = Drafty.insertImage(null, 0, {
|
||||
mime: mime,
|
||||
_tempPreview: tinyBits64, // This preview will not be serialized.
|
||||
width: width,
|
||||
height: height,
|
||||
filename: fname,
|
||||
size: blob.size,
|
||||
urlPromise: uploadCompletionPromise
|
||||
});
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.init(caption));
|
||||
}
|
||||
// Pass data and the uploader to the TinodeWeb.
|
||||
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
|
||||
}
|
||||
)},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the image if it's too big to be send inband.
|
||||
blobToBase64(blob, (blobMime, bits64) => {
|
||||
let msg = Drafty.insertImage(null, 0, {
|
||||
mime: blobMime,
|
||||
preview: bits64, // Serializable preview
|
||||
width: width,
|
||||
height: height,
|
||||
filename: fname,
|
||||
size: blob.size
|
||||
});
|
||||
if (caption) {
|
||||
msg = Drafty.appendLineBreak(msg);
|
||||
msg = Drafty.append(msg, Drafty.init(caption));
|
||||
}
|
||||
this.props.sendMessage(msg);
|
||||
});
|
||||
}
|
||||
|
||||
// handleAttachImage method is called when [Attach image] button is clicked.
|
||||
handleAttachImage(file) {
|
||||
const maxExternAttachmentSize = this.props.tinode.getServerLimit('maxFileUploadSize', MAX_EXTERN_ATTACHMENT_SIZE);
|
||||
|
||||
// Get image dimensions and size, optionally scale it down.
|
||||
imageScaled(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, maxExternAttachmentSize, false,
|
||||
// Success
|
||||
(mime, blob, width, height, fname) => {
|
||||
this.setState({imagePreview: {
|
||||
url: URL.createObjectURL(blob),
|
||||
blob: blob,
|
||||
filename: fname,
|
||||
width: width,
|
||||
height: height,
|
||||
size: blob.size,
|
||||
type: mime
|
||||
}});
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
handleCancelUpload(seq, uploader) {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const found = topic.findMessage(seq);
|
||||
if (found) {
|
||||
found._cancelled = true;
|
||||
}
|
||||
uploader.cancel();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
let component;
|
||||
if (this.props.hideSelf) {
|
||||
component = null;
|
||||
} else if (!this.state.topic) {
|
||||
component = (
|
||||
<LogoView
|
||||
serverVersion={this.props.serverVersion}
|
||||
serverAddress={this.props.serverAddress} />
|
||||
);
|
||||
} else {
|
||||
let component2;
|
||||
if (this.state.imagePreview) {
|
||||
// Preview image before sending.
|
||||
component2 = (
|
||||
<ImagePreview
|
||||
content={this.state.imagePreview}
|
||||
onClose={this.handleClosePreview}
|
||||
onSendMessage={this.sendImageAttachment} />
|
||||
);
|
||||
} else if (this.state.imagePostview) {
|
||||
// Expand received image.
|
||||
component2 = (
|
||||
<ImagePreview
|
||||
content={this.state.imagePostview}
|
||||
onClose={this.handleClosePreview} />
|
||||
);
|
||||
} else if (this.state.docPreview) {
|
||||
// Preview attachment before sending.
|
||||
component2 = (
|
||||
<DocPreview
|
||||
content={this.state.docPreview}
|
||||
onClose={this.handleClosePreview}
|
||||
onSendMessage={this.sendFileAttachment} />
|
||||
);
|
||||
} else {
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const isChannel = topic.isChannelType();
|
||||
const groupTopic = topic.isGroupType() && !isChannel;
|
||||
let messageNodes = [];
|
||||
let previousFrom = null;
|
||||
let chatBoxClass = null;
|
||||
topic.messages((msg, prev, next, i) => {
|
||||
let nextFrom = next ? (next.from || null) : 'chan';
|
||||
|
||||
let sequence = 'single';
|
||||
let thisFrom = msg.from || 'chan';
|
||||
if (thisFrom == previousFrom) {
|
||||
if (thisFrom == nextFrom) {
|
||||
sequence = 'middle';
|
||||
} else {
|
||||
sequence = 'last';
|
||||
}
|
||||
} else if (thisFrom == nextFrom) {
|
||||
sequence = 'first';
|
||||
}
|
||||
previousFrom = thisFrom;
|
||||
|
||||
const isReply = !(thisFrom == this.props.myUserId);
|
||||
const deliveryStatus = topic.msgStatus(msg, true);
|
||||
|
||||
let userName, userAvatar, userFrom;
|
||||
if (groupTopic) {
|
||||
const user = topic.userDesc(thisFrom);
|
||||
if (user && user.public) {
|
||||
userName = user.public.fn;
|
||||
userAvatar = makeImageDataUrl(user.public.photo);
|
||||
}
|
||||
userFrom = thisFrom;
|
||||
chatBoxClass='chat-box group';
|
||||
} else {
|
||||
chatBoxClass='chat-box';
|
||||
}
|
||||
|
||||
messageNodes.push(
|
||||
<ChatMessage
|
||||
tinode={this.props.tinode}
|
||||
content={msg.content}
|
||||
deleted={msg.hi}
|
||||
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.state.channel? false : this.handleShowContextMenuMessage}
|
||||
onImagePreview={this.handleImagePostview}
|
||||
onFormResponse={this.handleFormResponse}
|
||||
onError={this.props.onError}
|
||||
onCancelUpload={this.handleCancelUpload}
|
||||
key={msg.seq} />
|
||||
);
|
||||
});
|
||||
|
||||
let lastSeen = null;
|
||||
if (isChannel) {
|
||||
lastSeen = formatMessage(messages.channel);
|
||||
} else {
|
||||
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
|
||||
if (cont && Tinode.isP2PTopicName(cont.topic)) {
|
||||
if (cont.online) {
|
||||
lastSeen = formatMessage(messages.online_now);
|
||||
} else if (cont.seen) {
|
||||
lastSeen = formatMessage(messages.last_seen) + ": " +
|
||||
shortDateFormat(cont.seen.when, this.props.intl.locale);
|
||||
// TODO: also handle user agent in c.seen.ua
|
||||
}
|
||||
}
|
||||
}
|
||||
const avatar = this.state.avatar || true;
|
||||
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
|
||||
|
||||
component2 = (
|
||||
<>
|
||||
<div id="topic-caption-panel" className="caption-panel">
|
||||
{this.props.displayMobile ?
|
||||
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
|
||||
<i className="material-icons">arrow_back</i>
|
||||
</a>
|
||||
:
|
||||
null}
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={avatar}
|
||||
topic={this.state.topic}
|
||||
title={this.state.title} />
|
||||
{!isChannel ? <span className={online} /> : null}
|
||||
</div>
|
||||
<div id="topic-title-group">
|
||||
<div id="topic-title" className="panel-title">{
|
||||
this.state.title ||
|
||||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
|
||||
description="Title shown when the topic has no name" /></i>
|
||||
}</div>
|
||||
<div id="topic-last-seen">{lastSeen}</div>
|
||||
</div>
|
||||
{groupTopic ?
|
||||
<GroupSubs
|
||||
subscribers={this.state.onlineSubs} /> :
|
||||
<div id="topic-users" />
|
||||
}
|
||||
<div>
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">more_vert</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} />
|
||||
: null}
|
||||
<LoadSpinner show={this.state.fetchingMessages} />
|
||||
<div id="messages-container">
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
{!this.state.isReader ?
|
||||
<div id="write-only-background">
|
||||
{this.state.readingBlocked ?
|
||||
<div id="write-only-note">
|
||||
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
|
||||
description="Message shown in topic without the read access" />
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
: null }
|
||||
</div>
|
||||
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
|
||||
<div id="peer-messaging-disabled-note">
|
||||
<i className="material-icons secondary">block</i> <FormattedMessage
|
||||
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
|
||||
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
|
||||
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
|
||||
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
|
||||
</div> : null}
|
||||
{this.state.unconfirmed ?
|
||||
<Invitation onAction={this.handleNewChatAcceptance} />
|
||||
:
|
||||
<SendMessage
|
||||
disabled={!this.state.isWriter}
|
||||
onSendMessage={this.props.sendMessage}
|
||||
onKeyPress={this.sendKeyPress}
|
||||
onAttachFile={this.handleAttachFile}
|
||||
onAttachImage={this.handleAttachImage}
|
||||
onError={this.props.onError} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
component = <div id="topic-view">{component2}</div>
|
||||
}
|
||||
return component;
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(MessagesView);
|
||||
@@ -0,0 +1,128 @@
|
||||
// Create new topic and invite users or send an invite.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
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 { theCard } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
search_for_contacts: {
|
||||
id: "search_for_contacts",
|
||||
defaultMessage: "Use search to find contacts",
|
||||
description: "Text shown in contacts view when user entered no search query."
|
||||
},
|
||||
search_no_results: {
|
||||
id: "search_no_results",
|
||||
defaultMessage: "Search returned no results",
|
||||
description: "Text shown in contacts view when query returned no results."
|
||||
}
|
||||
});
|
||||
|
||||
class NewTopicView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tabSelected: 'p2p',
|
||||
searchQuery: null
|
||||
};
|
||||
|
||||
this.handleTabClick = this.handleTabClick.bind(this);
|
||||
this.handleSearchContacts = this.handleSearchContacts.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});
|
||||
}
|
||||
|
||||
handleSearchContacts(query) {
|
||||
this.props.onSearchContacts(query);
|
||||
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
||||
}
|
||||
|
||||
handleContactSelected(sel) {
|
||||
if (this.state.tabSelected === 'p2p') {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
this.props.onCreateTopic(sel);
|
||||
}
|
||||
}
|
||||
|
||||
handleNewGroupSubmit(name, dataUrl, priv, tags, isChannel) {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
this.props.onCreateTopic(undefined, theCard(name, dataUrl), priv, tags, isChannel);
|
||||
}
|
||||
|
||||
handleGroupByID(topicName) {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
|
||||
messages.search_no_results : messages.search_for_contacts);
|
||||
return (
|
||||
<div className="flex-column">
|
||||
<ul className="tabbar">
|
||||
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
|
||||
<a href="#" data-id="p2p" onClick={this.handleTabClick}>
|
||||
<FormattedMessage id="tabtitle_find_user" defaultMessage="find"
|
||||
description="Tab title Find" />
|
||||
</a>
|
||||
</li>
|
||||
<li className={this.state.tabSelected === "grp" ? "active" : null}>
|
||||
<a href="#" data-id="grp" onClick={this.handleTabClick}>
|
||||
<FormattedMessage id="tabtitle_new_group" defaultMessage="new group"
|
||||
description="Tab title New Group" />
|
||||
</a>
|
||||
</li>
|
||||
<li className={this.state.tabSelected === "byid" ? "active" : null}>
|
||||
<a href="#" data-id="byid" onClick={this.handleTabClick}>
|
||||
<FormattedMessage id="tabtitle_group_by_id" defaultMessage="by id"
|
||||
description="Tab title Find topic by ID" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{this.state.tabSelected === 'grp' ?
|
||||
<NewTopicGroup
|
||||
tinode={this.props.tinode}
|
||||
onSubmit={this.handleNewGroupSubmit} /> :
|
||||
this.state.tabSelected === 'byid' ?
|
||||
<NewTopicById
|
||||
onSubmit={this.handleGroupByID}
|
||||
onError={this.props.onError} /> :
|
||||
<div className="flex-column">
|
||||
<SearchContacts
|
||||
type="p2p"
|
||||
onSearchContacts={this.handleSearchContacts} />
|
||||
<ContactList
|
||||
contacts={this.props.searchResults}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage={no_contacts_placeholder}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(NewTopicView);
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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: '',
|
||||
sent: false
|
||||
};
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const 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 if (this.state.sent) {
|
||||
this.props.onCancel();
|
||||
} else {
|
||||
const email = this.state.email.trim();
|
||||
this.setState({email: email});
|
||||
this.props.onRequest('email', email).then(() => {
|
||||
this.setState({sent: true});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleEmailChange(e) {
|
||||
this.setState({email: e.target.value});
|
||||
}
|
||||
|
||||
handlePasswordChange(e) {
|
||||
this.setState({password: e.target.value});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<form id="password-reset-form" onSubmit={this.handleSubmit}>
|
||||
{(this.state.token && this.state.scheme) ?
|
||||
<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>
|
||||
: this.state.sent ?
|
||||
<>
|
||||
<br/>
|
||||
<center><i className="material-icons huge green">task_alt</i></center>
|
||||
<br/>
|
||||
<center><FormattedMessage id="password_reset_email_sent"
|
||||
defaultMessage="An email has been sent to {email}. Follow the directions in the email to reset your password."
|
||||
values={{ email: <tt>{this.state.email}</tt> }}
|
||||
description="Notification that the email with password reset instructions has been sent" /></center>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<label htmlFor="inputEmail">
|
||||
<FormattedMessage id="label_reset_password"
|
||||
defaultMessage="Send a password reset email:"
|
||||
description="Label for password reset field" />
|
||||
</label>
|
||||
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
|
||||
description="Placeholder for entering email">{
|
||||
(placeholder) => <input type="email" id="inputEmail"
|
||||
placeholder={placeholder}
|
||||
autoComplete="email"
|
||||
value={this.state.email}
|
||||
onChange={this.handleEmailChange}
|
||||
required autoFocus />
|
||||
}</FormattedMessage>
|
||||
</>
|
||||
}
|
||||
<div className="dialog-buttons">
|
||||
<button className="primary" type="submit">{
|
||||
(this.state.token && this.state.scheme) ?
|
||||
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
|
||||
: this.state.sent ?
|
||||
<FormattedMessage id="button_ok" defaultMessage="OK"
|
||||
description="Button [OK]" />
|
||||
:
|
||||
<FormattedMessage id="button_send_request" defaultMessage="Send request"
|
||||
description="Button [Send request]" />
|
||||
}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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"};
|
||||
const transportOptions = [];
|
||||
['def', 'ws', 'lp'].map((item) => {
|
||||
const id = 'transport-' + item;
|
||||
const name = names[item];
|
||||
transportOptions.push(
|
||||
<li key={item}>
|
||||
<input type="radio" id={id} name="transport-select" value={item}
|
||||
checked={this.state.transport === item}
|
||||
onChange={this.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="primary">
|
||||
<FormattedMessage id="button_update" defaultMessage="Update"
|
||||
description="Button [Update]" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||
import SideNavbar from '../widgets/side-navbar.jsx';
|
||||
|
||||
import ContactsView from './contacts-view.jsx';
|
||||
import CreateAccountView from './create-account-view.jsx';
|
||||
import EditAccountView from './edit-account-view.jsx';
|
||||
import AccGeneralView from './acc-general-view.jsx';
|
||||
import AccNotificationsView from './acc-notifications-view.jsx';
|
||||
import AccSecurityView from './acc-security-view.jsx';
|
||||
import AccSupportView from './acc-support-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_account_settings',
|
||||
description: 'Sidepanel title for EditAccountView.',
|
||||
defaultMessage: 'Account Settings'
|
||||
},
|
||||
general: {
|
||||
id: 'sidepanel_title_acc_general',
|
||||
description: 'Sidepanel title for AccGeneralView.',
|
||||
defaultMessage: 'General'
|
||||
},
|
||||
security: {
|
||||
id: 'sidepanel_title_acc_security',
|
||||
description: 'Sidepanel title for AccSecurityView.',
|
||||
defaultMessage: 'Security'
|
||||
},
|
||||
notif: {
|
||||
id: 'sidepanel_title_acc_notifications',
|
||||
description: 'Sidepanel title for AccNotificationsView.',
|
||||
defaultMessage: 'Notifications'
|
||||
},
|
||||
support: {
|
||||
id: 'sidepanel_title_acc_support',
|
||||
description: 'Sidepanel title for AccSupportView.',
|
||||
defaultMessage: 'Support'
|
||||
},
|
||||
newtpk: {
|
||||
id: 'sidepanel_title_newtpk',
|
||||
description: 'Sidepanel title for NewTopicView.',
|
||||
defaultMessage: 'Start New Chat'
|
||||
},
|
||||
cred: {
|
||||
id: 'sidepanel_title_cred',
|
||||
description: 'Sidepanel title for ValidationView.',
|
||||
defaultMessage: 'Confirm Credentials'
|
||||
},
|
||||
reset: {
|
||||
id: 'sidepanel_title_reset',
|
||||
description: 'Sidepanel title for PasswordResetView.',
|
||||
defaultMessage: 'Reset Password'
|
||||
},
|
||||
archive: {
|
||||
id: 'sidepanel_title_archive',
|
||||
description: 'Sidepanel title for ContactsView-Archive.',
|
||||
defaultMessage: 'Archived Chats'
|
||||
},
|
||||
blocked: {
|
||||
id: 'sidepanel_title_blocked',
|
||||
description: 'Sidepanel title for ContactsView-Blocked.',
|
||||
defaultMessage: 'Blocked Chats'
|
||||
}
|
||||
});
|
||||
|
||||
class SidepanelView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleNewTopic = this.handleNewTopic.bind(this);
|
||||
}
|
||||
|
||||
handleNewTopic() {
|
||||
this.props.onBasicNavigate('newtpk');
|
||||
}
|
||||
|
||||
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.handleNewTopic}
|
||||
onCancel={onCancel} />
|
||||
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
action={this.props.errorAction}
|
||||
actionText={this.props.errorActionText}
|
||||
onClearError={this.props.onError} />
|
||||
|
||||
<LoadSpinner show={this.props.loadSpinnerVisible} />
|
||||
|
||||
{view === 'login' ?
|
||||
<LoginView
|
||||
login={this.props.login}
|
||||
disabled={this.props.loginDisabled}
|
||||
persist={this.props.persist}
|
||||
onLogin={this.props.onLoginRequest}
|
||||
onPersistenceChange={this.props.onPersistenceChange} /> :
|
||||
|
||||
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}
|
||||
onBasicNavigate={this.props.onBasicNavigate} /> :
|
||||
|
||||
view === 'general' ?
|
||||
<AccGeneralView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
onUpdateAccount={this.props.onUpdateAccount}
|
||||
onUpdateTags={this.props.onUpdateAccountTags}
|
||||
onCredAdd={this.props.onCredAdd}
|
||||
onCredDelete={this.props.onCredDelete}
|
||||
onCredConfirm={this.props.onCredConfirm}
|
||||
onBasicNavigate={this.props.onBasicNavigate}
|
||||
onError={this.props.onError} /> :
|
||||
|
||||
view === 'notif' ?
|
||||
<AccNotificationsView
|
||||
messageSounds={this.props.messageSounds}
|
||||
desktopAlerts={this.props.desktopAlerts}
|
||||
desktopAlertsEnabled={this.props.desktopAlertsEnabled}
|
||||
incognitoMode={this.props.incognitoMode}
|
||||
onTogglePushNotifications={this.props.onTogglePushNotifications}
|
||||
onToggleMessageSounds={this.props.onToggleMessageSounds}
|
||||
onToggleIncognitoMode={this.props.onToggleIncognitoMode} /> :
|
||||
|
||||
view === 'security' ?
|
||||
<AccSecurityView
|
||||
tinode={this.props.tinode}
|
||||
onUpdateAccount={this.props.onUpdateAccount}
|
||||
onLogout={this.props.onLogout}
|
||||
onDeleteAccount={this.props.onDeleteAccount}
|
||||
onShowAlert={this.props.onShowAlert}
|
||||
onShowBlocked={this.props.onShowBlocked} /> :
|
||||
|
||||
view === 'support' ?
|
||||
<AccSupportView
|
||||
serverAddress={this.props.serverAddress}
|
||||
serverVersion={this.props.serverVersion} /> :
|
||||
|
||||
(view === 'contacts' || view == 'archive' || view == 'blocked') ?
|
||||
<ContactsView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
connected={this.props.connected}
|
||||
topicSelected={this.props.topicSelected}
|
||||
archive={view == 'archive'}
|
||||
blocked={view == 'blocked'}
|
||||
chatList={this.props.chatList}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
onTopicSelected={this.props.onTopicSelected}
|
||||
onShowArchive={this.props.onShowArchive} /> :
|
||||
|
||||
view === 'newtpk' ?
|
||||
<NewTopicView
|
||||
tinode={this.props.tinode}
|
||||
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,93 @@
|
||||
// ValidationView: panel for confirming credentials, like email or phone.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
phone: {
|
||||
id: 'phone_dative',
|
||||
defaultMessage: 'phone',
|
||||
description: "Dative case of 'phone', i.e. 'phone' in 'by phone'",
|
||||
},
|
||||
email: {
|
||||
id: 'email_dative',
|
||||
defaultMessage: 'email',
|
||||
description: "Dative case of 'email', i.e. 'email' in 'by email'",
|
||||
}
|
||||
});
|
||||
|
||||
class ValidationView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
code: props.credCode || ''
|
||||
};
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
this.setState({code: e.target.value});
|
||||
}
|
||||
|
||||
handleKeyPress(e) {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleSubmit(e);
|
||||
} else if (e.key == 'Escape') {
|
||||
this.handleCancel(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.code && this.state.code.trim()) {
|
||||
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
|
||||
}
|
||||
}
|
||||
|
||||
handleCancel(e) {
|
||||
e.preventDefault();
|
||||
this.props.onCancel();
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { formatMessage } = this.props.intl;
|
||||
const methods = {'email': formatMessage(messages.email), 'tel': formatMessage(messages.phone)};
|
||||
let method = methods[this.props.credMethod] || this.props.credMethod;
|
||||
return (
|
||||
<div className="panel-form">
|
||||
<div className="panel-form-row">
|
||||
<label className="small" htmlFor="enter-confirmation-code">
|
||||
<FormattedMessage id="enter_confirmation_code_prompt"
|
||||
defaultMessage="Enter confirmation code sent to you by {method}:"
|
||||
description="Request to enter confirmation code"
|
||||
values={{method: method}} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<FormattedMessage id="numeric_confirmation_code_prompt"
|
||||
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
|
||||
(numbers_only) => <input type="text" id="enter-confirmation-code"
|
||||
placeholder={numbers_only}
|
||||
value={this.state.code} onChange={this.handleChange}
|
||||
onKeyPress={this.handleKeyPress} required />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleCancel}>
|
||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(ValidationView);
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class Alert extends React.PureComponent {
|
||||
render() {
|
||||
return this.props.visible ?
|
||||
<div className="alert-container">
|
||||
<div className="alert">
|
||||
<div className="title">{this.props.title}</div>
|
||||
<div className="content">{this.props.content}</div>
|
||||
<div className="dialog-buttons">
|
||||
{this.props.onReject ?
|
||||
<button className="outline" onClick={this.props.onReject}>
|
||||
{this.props.reject || <FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />}
|
||||
</button>
|
||||
:
|
||||
null
|
||||
}
|
||||
<button className="primary" onClick={this.props.onConfirm}>
|
||||
{this.props.confirm || <FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FileProgress from './file-progress.jsx';
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
|
||||
|
||||
export default class Attachment extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
downloader: null,
|
||||
progress: 0
|
||||
};
|
||||
|
||||
this.downloadFile = this.downloadFile.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
}
|
||||
|
||||
downloadFile(url, filename, mimetype) {
|
||||
if (!url) {
|
||||
this.props.onError("Invalid download URL '" + url + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
const downloader = this.props.tinode.getLargeFileHelper();
|
||||
this.setState({downloader: downloader});
|
||||
downloader.download(url, filename, mimetype,
|
||||
(loaded) => {
|
||||
this.setState({progress: loaded / this.props.size});
|
||||
},
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
}
|
||||
).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.uploading) {
|
||||
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.uploading && !this.state.downloader && isUrlRelative(this.props.downloadUrl)) {
|
||||
// Relative URL. Use download helper.
|
||||
url = '#';
|
||||
helperFunc = (e) => {
|
||||
e.preventDefault();
|
||||
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
|
||||
};
|
||||
} else {
|
||||
url = sanitizeUrl(this.props.downloadUrl);
|
||||
helperFunc = null;
|
||||
}
|
||||
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
|
||||
defaultMessage="save" description="Call to save an attachment" /></>;
|
||||
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.uploading || this.state.downloader ?
|
||||
<FileProgress progress={this.props.uploading ? this.props.progress : this.state.progress}
|
||||
onCancel={this.handleCancel} />
|
||||
:
|
||||
<div>{url ?
|
||||
<a href={url} download={this.props.filename} onClick={helperFunc}>{downloadWidget}</a>
|
||||
:
|
||||
<span className="light-gray">{downloadWidget}</span>
|
||||
}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js';
|
||||
import { imageScaled, blobToBase64, makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
export default class AvatarUpload extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
dataUrl: props.avatar
|
||||
};
|
||||
|
||||
this.handleFileUpload = this.handleFileUpload.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.avatar != prevProps.avatar) {
|
||||
this.setState({dataUrl: this.props.avatar})
|
||||
}
|
||||
}
|
||||
|
||||
handleFileUpload(e) {
|
||||
imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
|
||||
// Image successfully scaled and converted.
|
||||
(mime, blob) => {
|
||||
// Convert blob to base64-encoded bits.
|
||||
blobToBase64(blob, (unused, base64bits) => {
|
||||
const du = makeImageDataUrl({data: base64bits, type: mime});
|
||||
this.setState({dataUrl: du});
|
||||
this.props.onImageChanged(du);
|
||||
});
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
});
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
render() {
|
||||
// Randomize id value in case more than one AvatarUpload is shown
|
||||
// at the same time.
|
||||
const randId = 'file-input-avatar-' + (Math.random() + '').substr(2);
|
||||
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
|
||||
return (
|
||||
<div className={className}>
|
||||
{this.props.readOnly || !this.state.dataUrl ?
|
||||
null :
|
||||
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageChanged(null);}}>
|
||||
<i className="material-icons">clear</i>
|
||||
</a>}
|
||||
{this.state.dataUrl ?
|
||||
<img src={this.state.dataUrl} className="preview" /> :
|
||||
this.props.readOnly && this.props.uid ?
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={true}
|
||||
topic={this.props.uid}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
:
|
||||
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
||||
{this.props.readOnly ? null :
|
||||
<input type="file" id={randId} className="inputfile hidden"
|
||||
accept="image/*" onChange={this.handleFileUpload} />}
|
||||
{this.props.readOnly ? null :
|
||||
<label htmlFor={randId} className="round">
|
||||
<i className="material-icons">file_upload</i>
|
||||
</label>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// The <- button to be displayed in title bars.
|
||||
import React from 'react';
|
||||
|
||||
export default class ButtonBack extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onBack();}}>
|
||||
<i className="material-icons">arrow_back</i>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Single message, sent or received.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import Attachment from './attachment.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import ReceivedMarker from './received-marker.jsx'
|
||||
import UploadingImage from './uploading-image.jsx'
|
||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
||||
|
||||
export default class ChatMessage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
progress: 0
|
||||
};
|
||||
|
||||
if (props.uploader) {
|
||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
||||
}
|
||||
|
||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
}
|
||||
|
||||
handleImagePreview(e) {
|
||||
e.preventDefault();
|
||||
this.props.onImagePreview({
|
||||
url: e.target.src,
|
||||
filename: e.target.title,
|
||||
width: e.target.dataset.width,
|
||||
height: e.target.dataset.height,
|
||||
size: e.target.dataset.size,
|
||||
type: e.target.dataset.mime
|
||||
});
|
||||
}
|
||||
|
||||
handleFormButtonClick(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
seq: this.props.seq
|
||||
};
|
||||
data.resp = {};
|
||||
if (e.target.dataset.name) {
|
||||
data.resp[e.target.dataset.name] = e.target.dataset.val ? e.target.dataset.val :
|
||||
e.target.dataset.val === undefined ? 1 : '' + e.target.dataset.val;
|
||||
}
|
||||
if (e.target.dataset.act == 'url') {
|
||||
data.ref = sanitizeUrl(e.target.dataset.ref) || 'about:blank';
|
||||
}
|
||||
const text = e.target.dataset.title || 'unknown';
|
||||
this.props.onFormResponse(e.target.dataset.act, text, data);
|
||||
}
|
||||
|
||||
handleContextClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
|
||||
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
|
||||
y: e.pageY, x: e.pageX }, menuItems);
|
||||
}
|
||||
|
||||
handleProgress(ratio) {
|
||||
this.setState({progress: ratio});
|
||||
}
|
||||
|
||||
handleCancelUpload() {
|
||||
this.props.onCancelUpload(this.props.seq, this.props.uploader);
|
||||
}
|
||||
|
||||
render() {
|
||||
const sideClass = this.props.deleted ? 'center' :
|
||||
(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.deleted ? null : (this.props.userAvatar || true);
|
||||
const fullDisplay = (this.props.userFrom && this.props.response && !this.props.deleted &&
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
|
||||
let content = this.props.content;
|
||||
const attachments = [];
|
||||
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
||||
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}
|
||||
uploading={Drafty.isProcessing(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(React.Fragment, null, Drafty.format(content, draftyFormatter, this));
|
||||
} else if (this.props.deleted) {
|
||||
// Message represents a range of deleted messages.
|
||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||
<FormattedMessage id="deleted_content"
|
||||
defaultMessage="content deleted" description="Shown when messages are deleted" />
|
||||
</i></>
|
||||
} else if (typeof content != 'string') {
|
||||
content = <>
|
||||
<i className="material-icons gray">error_outline</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when message is unreadable" /></i>
|
||||
</>
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={sideClass}>
|
||||
{this.props.userFrom && this.props.response ?
|
||||
<div className="avatar-box">
|
||||
{fullDisplay ?
|
||||
<LetterTile
|
||||
topic={this.props.userFrom}
|
||||
title={this.props.userName}
|
||||
avatar={avatar} /> :
|
||||
null}
|
||||
</div> :
|
||||
null}
|
||||
<div>
|
||||
<div className={bubbleClass}>
|
||||
<div className="content-meta">
|
||||
<div className="message-content">
|
||||
{content}
|
||||
{attachments}
|
||||
</div>
|
||||
{this.props.timestamp ?
|
||||
<ReceivedMarker
|
||||
timestamp={this.props.timestamp}
|
||||
received={this.props.received} />
|
||||
: null}
|
||||
</div>
|
||||
{this.props.deleted || !this.props.showContextMenu ?
|
||||
null :
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
{fullDisplay ?
|
||||
<div className="author">
|
||||
<FormattedMessage id="user_not_found" defaultMessage="Not found"
|
||||
description="In place of a user's full name when the user is not found.">{
|
||||
(notFound) => {return this.props.userName || <i>{notFound}</i>}
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert Drafty object to a tree of React elements.
|
||||
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||
|
||||
// Converts Drafty elements into React classes.
|
||||
// 'this' is set by the caller.
|
||||
function draftyFormatter(style, data, values, key) {
|
||||
if (style == 'EX') {
|
||||
// attachments are handled elsewhere.
|
||||
return null;
|
||||
}
|
||||
|
||||
let el = Drafty.tagName(style);
|
||||
if (el) {
|
||||
const attr = Drafty.attrValue(style, data) || {};
|
||||
attr.key = key;
|
||||
switch (style) {
|
||||
case 'HL':
|
||||
// Highlighted text. Assign class name.
|
||||
attr.className = 'highlight';
|
||||
break;
|
||||
case 'IM':
|
||||
// Additional processing for images
|
||||
if (data) {
|
||||
attr.className = 'inline-image';
|
||||
const dim = fitImageSize(data.width, data.height,
|
||||
Math.min(this.props.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5), REM_SIZE * 24, false) ||
|
||||
{dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
||||
attr.style = {
|
||||
width: dim.dstWidth + 'px',
|
||||
height: dim.dstHeight + 'px',
|
||||
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||||
minWidth: dim.dstWidth + 'px',
|
||||
minHeight: dim.dstHeight + 'px'
|
||||
};
|
||||
if (!Drafty.isProcessing(data)) {
|
||||
attr.src = this.props.tinode.authorizeURL(sanitizeImageUrl(attr.src));
|
||||
attr.alt = data.name;
|
||||
if (attr.src) {
|
||||
attr.onClick = this.handleImagePreview;
|
||||
attr.className += ' image-clickable';
|
||||
attr.loading = 'lazy';
|
||||
} else {
|
||||
attr.src = 'img/broken_image.png';
|
||||
}
|
||||
} else {
|
||||
// Use custom element instead of <img>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'BN':
|
||||
// Button
|
||||
attr.onClick = this.handleFormButtonClick;
|
||||
let inner = React.Children.map(values, (child) => {
|
||||
return typeof child == 'string' ? child : undefined;
|
||||
});
|
||||
if (!inner || inner.length == 0) {
|
||||
inner = [attr.name]
|
||||
}
|
||||
// Get text which will be sent back when the button is clicked.
|
||||
attr['data-title'] = inner.join('');
|
||||
break;
|
||||
case 'FM':
|
||||
// Form
|
||||
attr.className = 'bot-form';
|
||||
break;
|
||||
case 'RW':
|
||||
// Form element formatting is dependent on element content.
|
||||
break;
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
// Unknown element.
|
||||
// TODO: make it prettier.
|
||||
el = <><span className="material-icons">extension</span></>;
|
||||
}
|
||||
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,148 @@
|
||||
import React from 'react';
|
||||
|
||||
import Chip from './chip.jsx';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
/* BEGIN ChipInput: group membership widget */
|
||||
export default class ChipInput extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = ChipInput.deriveStateFromProps(props);
|
||||
this.state.input = '';
|
||||
this.state.focused = false;
|
||||
|
||||
this.handleTextInput = this.handleTextInput.bind(this);
|
||||
this.removeChipAt = this.removeChipAt.bind(this);
|
||||
this.handleChipCancel = this.handleChipCancel.bind(this);
|
||||
this.handleFocusGained = this.handleFocusGained.bind(this);
|
||||
this.handleFocusLost = this.handleFocusLost.bind(this);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
static deriveStateFromProps(props) {
|
||||
return {
|
||||
placeholder: props.chips ? '' : props.prompt,
|
||||
sortedChips: ChipInput.sortChips(props.chips, props.staticMembers),
|
||||
chipIndex: ChipInput.indexChips(props.chips)
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.chips != this.props.chips ||
|
||||
prevProps.staticMembers != this.props.staticMembers ||
|
||||
prevProps.prompt != this.props.prompt) {
|
||||
this.setState(ChipInput.deriveStateFromProps(this.props));
|
||||
}
|
||||
if (!prevState || this.props.chips.length > prevState.sortedChips.length) {
|
||||
this.setState({input: ''});
|
||||
}
|
||||
}
|
||||
|
||||
// Map chip index to user name
|
||||
static indexChips(chips) {
|
||||
const index = {};
|
||||
let count = 0;
|
||||
chips.map((item) => {
|
||||
index[item.user] = count;
|
||||
count ++;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
// Have non-removable chips appear before all other chips.
|
||||
static sortChips(chips, keep) {
|
||||
const required = [];
|
||||
const normal = [];
|
||||
chips.map((item) => {
|
||||
if (keep && keep.includes(item.user)) {
|
||||
required.push(item);
|
||||
} else {
|
||||
normal.push(item);
|
||||
}
|
||||
});
|
||||
return required.concat(normal);
|
||||
}
|
||||
|
||||
handleTextInput(e) {
|
||||
this.setState({input: e.target.value});
|
||||
if (this.props.filterFunc) {
|
||||
this.props.filterFunc(e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
removeChipAt(idx) {
|
||||
const removed = this.state.sortedChips[idx];
|
||||
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
|
||||
}
|
||||
|
||||
handleChipCancel(item, idx) {
|
||||
this.removeChipAt(idx);
|
||||
}
|
||||
|
||||
handleFocusGained() {
|
||||
this.setState({focused: true});
|
||||
}
|
||||
|
||||
handleFocusLost() {
|
||||
this.setState({focused: false});
|
||||
if (this.props.onFocusLost) {
|
||||
this.props.onFocusLost(this.state.input);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.key === 'Backspace') {
|
||||
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
|
||||
const at = this.state.sortedChips.length - 1;
|
||||
if (this.state.sortedChips[at].user !== this.props.staticMembers) {
|
||||
this.removeChipAt(at);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
if (this.props.onEnter) {
|
||||
this.props.onEnter(this.state.input);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
if (this.props.onCancel) {
|
||||
this.props.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const chips = [];
|
||||
let count = 0;
|
||||
const staticMembers = this.props.staticMembers || [];
|
||||
this.state.sortedChips.map((item) => {
|
||||
chips.push(
|
||||
<Chip
|
||||
onCancel={this.handleChipCancel}
|
||||
avatar={makeImageDataUrl(item.public ? item.public.photo : null)}
|
||||
title={item.public ? item.public.fn : undefined}
|
||||
noAvatar={this.props.avatarDisabled}
|
||||
topic={item.user}
|
||||
required={staticMembers.includes(item.user)}
|
||||
invalid={item.invalid}
|
||||
index={count}
|
||||
key={item.user} />
|
||||
);
|
||||
count++;
|
||||
});
|
||||
const className = "chip-input" + (this.state.focused ? ' focused' : '');
|
||||
return (
|
||||
<div className={className}>
|
||||
{chips}
|
||||
<input type="text"
|
||||
placeholder={this.state.placeholder}
|
||||
onChange={this.handleTextInput}
|
||||
onFocus={this.handleFocusGained}
|
||||
onBlur={this.handleFocusLost}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
value={this.state.input}
|
||||
autoFocus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
export default class Chip extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
}
|
||||
|
||||
handleCancel(e) {
|
||||
e.preventDefault();
|
||||
this.props.onCancel(this.props.topic, this.props.index);
|
||||
}
|
||||
|
||||
render() {
|
||||
const title = this.props.title || this.props.topic;
|
||||
const className = this.props.invalid ? 'chip invalid' : 'chip';
|
||||
return (
|
||||
<div className={className}>
|
||||
{this.props.noAvatar ?
|
||||
<span className="spacer" /> :
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={this.props.avatar || true}
|
||||
topic={this.props.topic}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
}
|
||||
<span>{title}</span>
|
||||
{this.props.onCancel && !this.props.required ?
|
||||
<a href="#" onClick={this.handleCancel} >×</a>
|
||||
: <span className="spacer" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// A panel included into a list of contacts with an action text.
|
||||
import React from 'react';
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
class ContactAction extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onAction(this.props.action);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
return (
|
||||
<li onClick={this.handleClick} className="action">
|
||||
<div className="action-text">{formatMessage(this.props.title, this.props.values)}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(ContactAction);
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block'};
|
||||
|
||||
/* Contact's labels: [you], or icons [muted], [blocked] */
|
||||
export default class ContactBadges extends React.PureComponent {
|
||||
render() {
|
||||
let badges = null;
|
||||
if (this.props.badges && this.props.badges.length > 0) {
|
||||
badges = [];
|
||||
this.props.badges.map(function(b) {
|
||||
if (b.icon) {
|
||||
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>);
|
||||
} else {
|
||||
const style = 'badge' + (b.color ? ' ' + b.color : '');
|
||||
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
|
||||
}
|
||||
});
|
||||
return <>{badges}</>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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 { Drafty } from 'tinode-sdk';
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import Contact from './contact.jsx';
|
||||
import ContactAction from './contact-action.jsx';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
import { MESSAGE_PREVIEW_LENGTH } from '../config.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
badge_you: {
|
||||
id: 'badge_you',
|
||||
defaultMessage: 'you',
|
||||
description: 'Badge for indicating the current user'
|
||||
},
|
||||
badge_owner: {
|
||||
id: 'badge_owner',
|
||||
defaultMessage: 'owner',
|
||||
description: 'Badge for indicating the owner'
|
||||
}
|
||||
});
|
||||
|
||||
class ContactList extends React.Component {
|
||||
render() {
|
||||
const { formatMessage } = this.props.intl;
|
||||
const showCheckmark = Array.isArray(this.props.topicSelected);
|
||||
const contactNodes = [];
|
||||
let contactsCount = 0;
|
||||
if (this.props.contacts && this.props.contacts.length > 0) {
|
||||
this.props.contacts.map((c) => {
|
||||
if (c.action) {
|
||||
// Action item
|
||||
contactNodes.push(
|
||||
<ContactAction
|
||||
title={c.title} action={c.action} values={c.values}
|
||||
key={c.action}
|
||||
onAction={this.props.onAction}
|
||||
/>);
|
||||
} else {
|
||||
// Normal contact
|
||||
const key = this.props.showMode ? c.user : (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) {
|
||||
const filterOn = [key];
|
||||
if (c.private && c.private.comment) {
|
||||
filterOn.push(('' + c.private.comment).toLowerCase());
|
||||
}
|
||||
if (c.public && c.public.fn) {
|
||||
filterOn.push(('' + c.public.fn).toLowerCase());
|
||||
}
|
||||
if (!this.props.filterFunc(this.props.filter, filterOn)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isChannel = Tinode.isChannelTopicName(key);
|
||||
const selected = showCheckmark ?
|
||||
(this.props.topicSelected.indexOf(key) > -1) :
|
||||
(this.props.topicSelected === key);
|
||||
const badges = [];
|
||||
if (this.props.showMode) {
|
||||
if (key == this.props.myUserId) {
|
||||
badges.push({name: formatMessage(messages.badge_you), color: 'green'});
|
||||
}
|
||||
if (c.acs && c.acs.isOwner()) {
|
||||
badges.push({name: formatMessage(messages.badge_owner), color: 'blue'});
|
||||
}
|
||||
}
|
||||
|
||||
const comment = Array.isArray(c.private) ?
|
||||
c.private.join(',') : (c.private ? c.private.comment : null);
|
||||
let preview;
|
||||
let deliveryStatus;
|
||||
if (!this.props.showMode && c.latestMessage) {
|
||||
const msg = c.latestMessage(true);
|
||||
if (msg) {
|
||||
deliveryStatus = msg._status || c.msgStatus(msg, true);
|
||||
preview = typeof msg.content == 'string' ?
|
||||
msg.content.substr(0, MESSAGE_PREVIEW_LENGTH) :
|
||||
Drafty.preview(msg.content, MESSAGE_PREVIEW_LENGTH);
|
||||
}
|
||||
}
|
||||
contactNodes.push(
|
||||
<Contact
|
||||
title={c.public ? c.public.fn : null}
|
||||
avatar={makeImageDataUrl(c.public ? c.public.photo : null)}
|
||||
comment={comment}
|
||||
preview={preview}
|
||||
received={deliveryStatus}
|
||||
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 && !isChannel}
|
||||
isChannel={isChannel}
|
||||
onSelected={this.props.onTopicSelected}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
item={key}
|
||||
index={contactNodes.length}
|
||||
key={key} />
|
||||
);
|
||||
contactsCount ++;
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={this.props.noScroll ? null : "scrollable-panel"}>
|
||||
{contactsCount == 0 ?
|
||||
<div className="center-medium-text"
|
||||
dangerouslySetInnerHTML={{__html: this.props.emptyListMessage}} />
|
||||
:
|
||||
null
|
||||
}
|
||||
{contactNodes.length > 0 ?
|
||||
<ul className="contact-box">
|
||||
{contactNodes}
|
||||
</ul>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(ContactList);
|
||||
@@ -0,0 +1,180 @@
|
||||
// A single topic or user.
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import ContactBadges from './contact-badges.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import ReceivedMarker from './received-marker.jsx'
|
||||
import UnreadBadge from './unread-badge.jsx';
|
||||
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import { deliveryMarker } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
drafty_image: {
|
||||
id: 'drafty_image',
|
||||
defaultMessage: 'Picture',
|
||||
description: 'Comment for embedded images in drafty preview'
|
||||
},
|
||||
drafty_form: {
|
||||
id: 'drafty_form',
|
||||
defaultMessage: 'Form: ',
|
||||
description: 'Comment for form in drafty preview'
|
||||
},
|
||||
drafty_attachment: {
|
||||
id: 'drafty_attachment',
|
||||
defaultMessage: 'Attachment',
|
||||
description: 'Comment for attachment in drafty preview'
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
// Index this.props.index is used by GroupManager.
|
||||
this.props.onSelected(this.props.item, this.props.index);
|
||||
}
|
||||
}
|
||||
|
||||
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" defaultMessage="Unnamed"
|
||||
description="Title shown when the topic has no name" /></i>;
|
||||
} else if (title.length > 30) {
|
||||
// FIXME: this is probably wrong for RTL languages.
|
||||
title = title.substring(0, 28) + '…';
|
||||
}
|
||||
const online = this.props.now ? 'online' : 'offline';
|
||||
const avatar = this.props.avatar ? this.props.avatar : true;
|
||||
const badges = this.props.badges ? this.props.badges.slice() : [];
|
||||
const icon_badges = [];
|
||||
if (this.props.acs) {
|
||||
if (this.props.showMode) {
|
||||
badges.push({name: this.props.acs.getMode(), key: 'mode'});
|
||||
}
|
||||
if (this.props.acs.isMuted()) {
|
||||
icon_badges.push({icon: 'muted'});
|
||||
}
|
||||
if (!this.props.acs.isJoiner()) {
|
||||
icon_badges.push({icon: 'banned'});
|
||||
}
|
||||
}
|
||||
|
||||
const subtitle = this.props.preview ?
|
||||
(typeof this.props.preview == 'string' ? this.props.preview :
|
||||
React.createElement(React.Fragment, null, Drafty.format(this.props.preview, draftyFormatter, this))) :
|
||||
this.props.comment;
|
||||
|
||||
const icon = deliveryMarker(this.props.received);
|
||||
const marker = icon ? <i className={'material-icons small space-right' +
|
||||
(icon.color ? ' ' + icon.color : '')}>{icon.name}</i> : null;
|
||||
|
||||
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.isChannel ? <img src="/img/channel.png" className="channel" alt="channel" /> : null}
|
||||
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
|
||||
</div>
|
||||
{this.props.showMode ?
|
||||
<span><ContactBadges badges={badges} /></span> :
|
||||
<div className="contact-comment">{marker}{subtitle || '\u00A0'}</div>}
|
||||
</div>
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Converts Drafty object into a one-line preview.
|
||||
function draftyFormatter(style, data, values, key) {
|
||||
let el = Drafty.tagName(style);
|
||||
const attr = { key: key };
|
||||
if (el) {
|
||||
const { formatMessage } = this.props.intl;
|
||||
switch (style) {
|
||||
case 'BR':
|
||||
// Replace new line with a space.
|
||||
el = React.Fragment;
|
||||
values = [' '];
|
||||
break;
|
||||
case 'HL':
|
||||
// Make highlight less prominent in preview.
|
||||
attr.className = 'highlight preview';
|
||||
break;
|
||||
case 'LN':
|
||||
// Disable links in previews.
|
||||
el = 'span';
|
||||
break;
|
||||
case 'IM':
|
||||
// Replace image with '[icon] Image'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">photo</i>, formatMessage(messages.drafty_image)];
|
||||
break;
|
||||
case 'BN':
|
||||
el = 'span';
|
||||
attr.className = 'flat-button faux';
|
||||
break;
|
||||
case 'FM':
|
||||
el = React.Fragment;
|
||||
values = [<i key="fm" className="material-icons">dashboard</i>,
|
||||
formatMessage(messages.drafty_form)].concat(values || []);
|
||||
break;
|
||||
case 'RW':
|
||||
el = React.Fragment;
|
||||
break;
|
||||
case 'EX':
|
||||
if (data && data.mime == 'application/json') {
|
||||
// Ignore JSON attachments: they are form response payloads.
|
||||
return null;
|
||||
}
|
||||
el = React.Fragment;
|
||||
values = [<i key="ex" className="material-icons">attachment</i>, formatMessage(messages.drafty_attachment)];
|
||||
break;
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
el = React.Fragment;
|
||||
values = [<i key="unkn" className="material-icons">extension</i>];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return React.createElement(el, attr, values);
|
||||
} else {
|
||||
return values;
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(Contact);
|
||||
@@ -0,0 +1,455 @@
|
||||
// Context Menu: popup/dropdown menu.
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { REM_SIZE } from '../config.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
id: 'menu_item_info',
|
||||
defaultMessage: 'Info',
|
||||
description: 'Show extended topic information'
|
||||
},
|
||||
clear_messages: {
|
||||
id: 'menu_item_clear_messages',
|
||||
defaultMessage: 'Clear messages',
|
||||
description: 'Delete all messages'
|
||||
},
|
||||
clear_for_all: {
|
||||
id: 'menu_item_clear_messages_for_all',
|
||||
defaultMessage: 'Clear for All',
|
||||
description: 'Delete all message(s) for all members'
|
||||
},
|
||||
delete: {
|
||||
id: 'menu_item_delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete selected messages'
|
||||
},
|
||||
delete_for_all: {
|
||||
id: 'menu_item_delete_for_all',
|
||||
defaultMessage: 'Delete for All',
|
||||
description: 'Delete selected message(s) for all members'
|
||||
},
|
||||
send_retry: {
|
||||
id: 'menu_item_send_retry',
|
||||
defaultMessage: 'Retry',
|
||||
description: 'Retry sending message'
|
||||
},
|
||||
mute: {
|
||||
id: 'menu_item_mute',
|
||||
defaultMessage: 'Mute',
|
||||
description: 'Turn off notifications'
|
||||
},
|
||||
unmute: {
|
||||
id: 'menu_item_unmute',
|
||||
defaultMessage: 'Unmute',
|
||||
description: 'Turn notifications on'
|
||||
},
|
||||
topic_delete: {
|
||||
id: 'menu_item_delete_topic',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete entire topic'
|
||||
},
|
||||
topic_delete_warning: {
|
||||
id: 'topic_delete_warning',
|
||||
defaultMessage: 'Are you sure you want to delete this conversation? It cannot be undone.',
|
||||
description: 'Alert warning when deleting entire topic'
|
||||
},
|
||||
delete_messages_warning: {
|
||||
id: 'delete_messages_warning',
|
||||
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
|
||||
description: 'Alert dialog warning when hard-deleting all messages.'
|
||||
},
|
||||
unblock: {
|
||||
id: 'menu_item_unblock',
|
||||
defaultMessage: 'Unblock',
|
||||
description: 'Unblock topic or user'
|
||||
},
|
||||
// Generic string suitable for either topic or user.
|
||||
block: {
|
||||
id: 'menu_item_block',
|
||||
defaultMessage: 'Block',
|
||||
description: 'Block topic or user'
|
||||
},
|
||||
topic_block_warning: {
|
||||
id: 'topic_block_warning',
|
||||
defaultMessage: 'Are you sure you want to block this conversation?',
|
||||
description: 'Alert warning when blocking a topic.'
|
||||
},
|
||||
member_delete: {
|
||||
id: 'menu_item_member_delete',
|
||||
defaultMessage: 'Remove',
|
||||
description: 'Remove user from topic'
|
||||
},
|
||||
archive: {
|
||||
id: 'menu_item_archive_topic',
|
||||
defaultMessage: 'Archive',
|
||||
description: 'Move topic from the list of active chats to archive'
|
||||
},
|
||||
unarchive: {
|
||||
id: 'menu_item_restore_topic',
|
||||
defaultMessage: 'Restore',
|
||||
description: 'Restore topic from archive'
|
||||
},
|
||||
edit_permissions: {
|
||||
id: 'menu_item_edit_permissions',
|
||||
defaultMessage: 'Edit permissions',
|
||||
description: 'Menu item [Edit permissions]'
|
||||
},
|
||||
clear_messages_warning: {
|
||||
id: 'clear_messages_warning',
|
||||
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
|
||||
description: 'Alert dialog warning when deleting all messages.'
|
||||
},
|
||||
});
|
||||
|
||||
class ContextMenu extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const {formatMessage} = props.intl;
|
||||
|
||||
this.handlePageClick = this.handlePageClick.bind(this);
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
|
||||
// Preconfigured menu items.
|
||||
this.MenuItems = {
|
||||
'topic_info': {
|
||||
id: 'topic_info',
|
||||
title: formatMessage(messages.info),
|
||||
handler: null
|
||||
},
|
||||
'messages_clear': {
|
||||
id: 'messages_clear',
|
||||
title: formatMessage(messages.clear_messages),
|
||||
handler: (params, errorHandler) => {
|
||||
return props.onShowAlert(
|
||||
formatMessage(messages.clear_messages), // title
|
||||
formatMessage(messages.clear_messages_warning), // content
|
||||
(() => { this.deleteMessages(true, false, params, errorHandler); }), // onConfirm
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
},
|
||||
'messages_clear_hard': {
|
||||
id: 'messages_clear_hard',
|
||||
title: formatMessage(messages.clear_for_all),
|
||||
handler: (params, errorHandler) => {
|
||||
return props.onShowAlert(
|
||||
formatMessage(messages.clear_for_all), // title
|
||||
formatMessage(message.delete_messages_warning), // content
|
||||
(() => { return this.deleteMessages(true, true, params, errorHandler); }),
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
},
|
||||
'message_delete': {
|
||||
id: 'message_delete',
|
||||
title: formatMessage(messages.delete),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.deleteMessages(false, false, params, errorHandler);
|
||||
}
|
||||
},
|
||||
'message_delete_hard': {
|
||||
id: 'message_delete_hard',
|
||||
title: formatMessage(messages.delete_for_all),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.deleteMessages(false, true, params, errorHandler);
|
||||
}
|
||||
},
|
||||
'menu_item_send_retry': {
|
||||
id: 'menu_item_send_retry',
|
||||
title: formatMessage(messages.send_retry),
|
||||
handler: (params, errorHandler) => {
|
||||
return this.retryMessage(params, errorHandler);
|
||||
}
|
||||
},
|
||||
'topic_unmute': {
|
||||
id: 'topic_unmute',
|
||||
title: formatMessage(messages.unmute),
|
||||
handler: this.topicPermissionSetter.bind(this, '+P')
|
||||
},
|
||||
'topic_mute': {
|
||||
id: 'topic_mute',
|
||||
title: formatMessage(messages.mute),
|
||||
handler: this.topicPermissionSetter.bind(this, '-P')
|
||||
},
|
||||
'topic_unblock': {
|
||||
id: 'topic_unblock',
|
||||
title: formatMessage(messages.unblock),
|
||||
handler: this.topicPermissionSetter.bind(this, '+JP')
|
||||
},
|
||||
'topic_block': {
|
||||
id: 'topic_block',
|
||||
title: formatMessage(messages.block),
|
||||
handler: (params, errorHandler) => {
|
||||
return props.onShowAlert(
|
||||
formatMessage(messages.block), // title
|
||||
formatMessage(messages.topic_block_warning), // content
|
||||
(() => {
|
||||
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
|
||||
this.props.onTopicRemoved(params.topicName);
|
||||
return ctrl;
|
||||
});
|
||||
}),
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
},
|
||||
'topic_delete': {
|
||||
id: 'topic_delete',
|
||||
title: formatMessage(messages.topic_delete),
|
||||
handler: (params, errorHandler) => {
|
||||
return props.onShowAlert(
|
||||
formatMessage(messages.topic_delete), // title
|
||||
formatMessage(messages.topic_delete_warning), // content
|
||||
(() => {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic) {
|
||||
console.log("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
return topic.delTopic(true).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}),
|
||||
null, // "OK"
|
||||
true, // Show Reject button
|
||||
null // "Cancel"
|
||||
);
|
||||
}
|
||||
},
|
||||
'topic_archive': {
|
||||
id: 'topic_archive',
|
||||
title: formatMessage(messages.archive),
|
||||
handler: (params, errorHandler) => {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic) {
|
||||
console.log("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
return topic.archive(true).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
'topic_restore': {
|
||||
id: 'topic_restore',
|
||||
title: formatMessage(messages.unarchive),
|
||||
handler: (params, errorHandler) => {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic) {
|
||||
console.log("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
return topic.archive(false).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
// menu_item_edit_permissions is defined elsewhere.
|
||||
'permissions': {
|
||||
id: 'permissions',
|
||||
title: formatMessage(messages.edit_permissions),
|
||||
handler: null
|
||||
},
|
||||
'member_delete': {
|
||||
id: 'member_delete',
|
||||
title: formatMessage(messages.member_delete),
|
||||
handler: (params, errorHandler) => {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic || !params.user) {
|
||||
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
|
||||
return;
|
||||
}
|
||||
return topic.delSubscription(params.user).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
'member_mute': {
|
||||
id: 'member_mute',
|
||||
title: formatMessage(messages.mute),
|
||||
handler: this.topicPermissionSetter.bind(this, '-P')
|
||||
},
|
||||
'member_unmute': {
|
||||
id: 'member_unmute',
|
||||
title: formatMessage(messages.unmute),
|
||||
handler: this.topicPermissionSetter.bind(this, '+P')
|
||||
},
|
||||
'member_block': {
|
||||
id: 'member_block',
|
||||
title: formatMessage(messages.block),
|
||||
handler: this.topicPermissionSetter.bind(this, '-JP')
|
||||
},
|
||||
'member_unblock': {
|
||||
id: 'member_unblock',
|
||||
title: formatMessage(messages.unblock),
|
||||
handler: this.topicPermissionSetter.bind(this, '+JP')
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.handlePageClick, false);
|
||||
document.addEventListener('keyup', this.handleEscapeKey, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.handlePageClick, false);
|
||||
document.removeEventListener('keyup', this.handleEscapeKey, false);
|
||||
}
|
||||
|
||||
handlePageClick(e) {
|
||||
if (ReactDOM.findDOMNode(this).contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.hide();
|
||||
}
|
||||
|
||||
handleEscapeKey(e) {
|
||||
if (e.keyCode === 27) {
|
||||
this.props.hide();
|
||||
}
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.hide();
|
||||
let item = this.props.items[e.currentTarget.dataset.id];
|
||||
if (typeof item == 'string') {
|
||||
item = this.MenuItems[item];
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
|
||||
} else {
|
||||
this.props.onAction(
|
||||
item.id,
|
||||
item.handler(this.props.params, this.props.onError),
|
||||
this.props.params);
|
||||
}
|
||||
}
|
||||
|
||||
// Menu Actions
|
||||
|
||||
deleteMessages(all, hard, params, errorHandler) {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic) {
|
||||
console.log("Topic not found: ", params.topicName);
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't know if the message is still pending (e.g. attachment is being uploaded),
|
||||
// so try cancelling first. No harm if we can't cancel.
|
||||
// The message can be cancelled if transmission to the server has not
|
||||
// started yet or if the message send has failed.
|
||||
if (!all && topic.cancelSend(params.seq)) {
|
||||
return;
|
||||
}
|
||||
// Can't cancel. Delete instead.
|
||||
const promise = all ?
|
||||
topic.delMessagesAll(hard) :
|
||||
topic.delMessagesList([params.seq], hard);
|
||||
|
||||
return promise.catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Retries sending failed message.
|
||||
retryMessage(params, errorHandler) {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
// Remove the existing message entry.
|
||||
if (!topic || !topic.flushMessage(params.seq)) {
|
||||
return;
|
||||
}
|
||||
const msg = topic.createMessage(params.content, false);
|
||||
return topic.publishDraft(msg).catch((err) => {
|
||||
if (errorHandler) {
|
||||
errorHandler(err.message, 'err');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function is used by context menu to set permissions.
|
||||
topicPermissionSetter(mode, params, errorHandler) {
|
||||
const topic = this.props.tinode.getTopic(params.topicName);
|
||||
if (!topic) {
|
||||
console.log("Topic not found", params.topicName);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = topic.updateMode(params.user, mode);
|
||||
if (errorHandler) {
|
||||
result = result.catch((err) => {
|
||||
errorHandler(err.message, 'err');
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
render() {
|
||||
let count = 0;
|
||||
let menu = [];
|
||||
this.props.items.map((item) => {
|
||||
if (typeof item == 'string') {
|
||||
item = this.MenuItems[item];
|
||||
}
|
||||
if (item && item.title) {
|
||||
menu.push(
|
||||
item.title == '-' ?
|
||||
<li className="separator" key={count} />
|
||||
:
|
||||
<li onClick={this.handleClick} data-id={count} key={count}>{item.title}</li>
|
||||
);
|
||||
}
|
||||
count++;
|
||||
});
|
||||
|
||||
// Ensure that menu is inside the app-container.
|
||||
const hSize = 12 * REM_SIZE;
|
||||
const vSize = REM_SIZE * (0.7 + menu.length * 2.5);
|
||||
const left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
|
||||
(this.props.clickAt.x - this.props.bounds.left - hSize) :
|
||||
(this.props.clickAt.x - this.props.bounds.left);
|
||||
const top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
|
||||
(this.props.clickAt.y - this.props.bounds.top - vSize) :
|
||||
(this.props.clickAt.y - this.props.bounds.top);
|
||||
|
||||
const position = {
|
||||
left: left + 'px',
|
||||
top: top + 'px'
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="menu" style={position}>
|
||||
{menu}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(ContextMenu);
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
|
||||
// Get material icon name from mime type.
|
||||
function iconFromMime(mime) {
|
||||
// If more icons become available in material icons, add them to this mime-to-icon mapping.
|
||||
const mimeToIcon = {default: 'insert_drive_file', image: 'image', text: 'description', video: 'theaters'};
|
||||
|
||||
return mimeToIcon[mime] || mimeToIcon[(mime || '').split('/')[0]] || mimeToIcon['default'];
|
||||
}
|
||||
|
||||
export default class DocPreview extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleSendDoc = this.handleSendDoc.bind(this);
|
||||
}
|
||||
|
||||
handleSendDoc(caption) {
|
||||
this.props.onClose();
|
||||
this.props.onSendMessage(this.props.content.file);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div id="image-preview">
|
||||
<div id="image-preview-caption-panel">
|
||||
<span>{this.props.content.filename}</span>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container">
|
||||
<div className="flex-column narrow">
|
||||
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
|
||||
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
|
||||
description="Label for file content type (mime)" /></b> {this.props.content.type || 'application/octet-stream'}</div>
|
||||
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
|
||||
description="Label for file size" /></b> {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SendMessage
|
||||
noInput={true}
|
||||
onSendMessage={this.handleSendDoc}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
import MenuCancel from './menu-cancel.jsx';
|
||||
|
||||
export default class ErrorPanel extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
show: false,
|
||||
};
|
||||
|
||||
this.hide = this.hide.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.level !== this.props.level) {
|
||||
this.setState({
|
||||
show: !(!this.props.level)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.setState({show: false});
|
||||
if (this.props.onClearError) {
|
||||
this.props.onClearError();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const icons = {err: 'error', warn: 'warning', info: 'info'}
|
||||
const level = icons[this.props.level] || '';
|
||||
const className = 'info-box ' + level;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="icon"><i className="material-icons">{level}</i></div>
|
||||
<span>
|
||||
{this.props.text}
|
||||
{this.props.action ?
|
||||
<>
|
||||
 <a href="#"
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
onClick={(e) => {e.preventDefault(); this.props.action();}}>
|
||||
{this.props.actionText}
|
||||
</a>
|
||||
</>
|
||||
: null}
|
||||
</span>
|
||||
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}>
|
||||
<i className="material-icons">close</i> <FormattedMessage id="action_cancel"
|
||||
defaultMessage="cancel" description="Call to action [cancel]" />
|
||||
</a>
|
||||
:
|
||||
<FormattedMessage id="upload_finishing" defaultMessage="finishing..."
|
||||
description="Notification that upload is finishing" />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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_short: {
|
||||
id: 'contacts_not_found_short',
|
||||
defaultMessage: "No contacts match ''{query}''",
|
||||
description: 'Shown in ContactsView when search returned no results'
|
||||
}
|
||||
});
|
||||
|
||||
class GroupManager extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
// Array of topic members
|
||||
members: props.members,
|
||||
index: GroupManager.indexMembers(props.members),
|
||||
staticMembers: GroupManager.staticMembers(props.members, props.keepInitialMembers, props.requiredMember),
|
||||
contactFilter: '',
|
||||
noContactsMessage: props.intl.formatMessage(messages.no_contacts),
|
||||
selectedContacts: GroupManager.selectedContacts(props.members)
|
||||
};
|
||||
|
||||
this.handleContactSelected = this.handleContactSelected.bind(this);
|
||||
this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
|
||||
this.handleContactFilter = this.handleContactFilter.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
}
|
||||
|
||||
static indexMembers(members) {
|
||||
let index = {};
|
||||
members.map((m) => {
|
||||
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
static staticMembers(members, keepInitial, requiredMember) {
|
||||
let stat = [];
|
||||
members.map((m) => {
|
||||
if (keepInitial || m.user == requiredMember) {
|
||||
stat.push(m.user);
|
||||
}
|
||||
});
|
||||
return stat;
|
||||
}
|
||||
|
||||
static selectedContacts(members) {
|
||||
let sel = [];
|
||||
members.map((m) => {
|
||||
sel.push(m.user);
|
||||
});
|
||||
return sel;
|
||||
}
|
||||
|
||||
handleContactSelected(userId, index) {
|
||||
let status = this.state.index[userId];
|
||||
if (status) {
|
||||
if (status.present) {
|
||||
// Prevent duplicate members
|
||||
return;
|
||||
}
|
||||
status.delta += 1;
|
||||
status.present = true;
|
||||
} else {
|
||||
status = {delta: 1, present: true};
|
||||
}
|
||||
|
||||
let m = this.state.members.slice();
|
||||
m.push(this.props.contacts[index]);
|
||||
|
||||
const sel = GroupManager.selectedContacts(m);
|
||||
|
||||
const i = this.state.index;
|
||||
i[userId] = status;
|
||||
|
||||
this.setState({members: m, index: i, selectedContacts: sel});
|
||||
}
|
||||
|
||||
handleMemberRemoved(userId, index) {
|
||||
const status = this.state.index[userId];
|
||||
if (!status || !status.present) {
|
||||
return;
|
||||
}
|
||||
status.present = false;
|
||||
status.delta -= 1;
|
||||
|
||||
let m = this.state.members.slice();
|
||||
m.splice(index, 1);
|
||||
|
||||
const sel = GroupManager.selectedContacts(m);
|
||||
|
||||
const i = this.state.index;
|
||||
i[userId] = status;
|
||||
|
||||
this.setState({members: m, index: i, selectedContacts: sel});
|
||||
}
|
||||
|
||||
handleContactFilter(val) {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const msg = !val ?
|
||||
formatMessage(messages.no_contacts) :
|
||||
formatMessage(messages.contacts_not_found_short, {query: val});
|
||||
|
||||
this.setState({contactFilter: val, noContactsMessage: msg});
|
||||
}
|
||||
|
||||
static doContactFiltering(filter, values) {
|
||||
if (filter) {
|
||||
for (let i=0; i<values.length; i++) {
|
||||
if (values[i].indexOf(filter) >= 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
var instance = this;
|
||||
var members = [];
|
||||
var added = [];
|
||||
var removed = [];
|
||||
|
||||
var keys = Object.keys(this.state.index);
|
||||
keys.map(function(k) {
|
||||
if (instance.state.index[k].present) {
|
||||
members.push(k);
|
||||
}
|
||||
|
||||
if (instance.state.index[k].delta > 0) {
|
||||
added.push(k);
|
||||
} else if (instance.state.index[k].delta < 0) {
|
||||
removed.push(k);
|
||||
}
|
||||
});
|
||||
this.props.onSubmit(members, added, removed);
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
this.props.onCancel();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
return (
|
||||
<div id="group-manager">
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="title_group_members" defaultMessage="Group Members" description="Section title" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<ChipInput
|
||||
chips={this.state.members}
|
||||
staticMembers={this.state.staticMembers}
|
||||
prompt="add members"
|
||||
filterFunc={this.handleContactFilter}
|
||||
onChipRemoved={this.handleMemberRemoved} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="title_all_contacts" defaultMessage="All Contacts"
|
||||
description="Section title [All Contacts]" />
|
||||
</label>
|
||||
</div>
|
||||
<ContactList
|
||||
contacts={this.props.contacts}
|
||||
myUserId={this.props.myUserId}
|
||||
topicSelected={this.state.selectedContacts}
|
||||
filter={this.state.contactFilter}
|
||||
filterFunc={GroupManager.doContactFiltering}
|
||||
emptyListMessage={this.state.noContactsMessage}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
<div id="group-manager-buttons" className="panel-form-row">
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleCancel}>
|
||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(GroupManager);
|
||||
@@ -0,0 +1,43 @@
|
||||
// GroupSubs: a list of group subscribers currently online.
|
||||
// Show in the topic title bar
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
export default class GroupSubs extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
const usersOnline = [];
|
||||
const totalCount = (this.props.subscribers || []).length;
|
||||
const countToShow = Math.min(MAX_ONLINE_IN_TOPIC, totalCount);
|
||||
|
||||
(this.props.subscribers || []).some((sub) => {
|
||||
usersOnline.push(
|
||||
<div className="avatar-box" key={sub.user}>
|
||||
<LetterTile
|
||||
topic={sub.user}
|
||||
avatar={makeImageDataUrl(sub.public ? sub.public.photo : null) || true}
|
||||
title={sub.public ? sub.public.fn : null} />
|
||||
</div>
|
||||
);
|
||||
return usersOnline.length == countToShow;
|
||||
});
|
||||
|
||||
return (
|
||||
<div id="topic-users">{usersOnline} {totalCount > countToShow ?
|
||||
<span>
|
||||
<FormattedMessage id="more_online_members" defaultMessage="+{overflow} more"
|
||||
description="Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
|
||||
values={{ overflow: (totalCount - countToShow) }} />
|
||||
</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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,99 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import SendMessage from '../widgets/send-message.jsx';
|
||||
|
||||
import { REM_SIZE } from '../config.js';
|
||||
import { fitImageSize } from '../lib/blob-helpers.js';
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
|
||||
export default class ImagePreview extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
|
||||
this.handleSendImage = this.handleSendImage.bind(this);
|
||||
}
|
||||
|
||||
assignWidth(node) {
|
||||
if (node && !this.state.width) {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
this.setState({
|
||||
width: bounds.width | 0,
|
||||
height: bounds.height | 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleSendImage(caption) {
|
||||
this.props.onClose();
|
||||
this.props.onSendMessage(caption, this.props.content.blob);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dim = fitImageSize(this.props.content.width, this.props.content.height,
|
||||
this.state.width, this.state.height, false);
|
||||
const size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
|
||||
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
|
||||
size.maxWidth = '100%';
|
||||
size.maxHeight = '100%';
|
||||
|
||||
let filename = this.props.content.filename;
|
||||
// Average font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
|
||||
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
|
||||
if (filename.length > maxlength) {
|
||||
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
|
||||
}
|
||||
const width = this.props.content.width || '-';
|
||||
const height = this.props.content.height || '-';
|
||||
return (
|
||||
<div id="image-preview">
|
||||
<div id="image-preview-caption-panel">
|
||||
{!this.props.onSendMessage ?
|
||||
<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>
|
||||
:
|
||||
<span>{this.props.content.filename}</span>
|
||||
}
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
|
||||
<img src={this.props.content.url} style={size} className="image-preview" alt={this.props.content.filename} />
|
||||
</div>
|
||||
{this.props.onSendMessage ?
|
||||
<SendMessage
|
||||
messagePrompt="add_image_caption"
|
||||
acceptBlank={true}
|
||||
onSendMessage={this.handleSendImage}
|
||||
onError={this.props.onError} />
|
||||
:
|
||||
<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);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// If text has changed while in read mode, update text and discard changes.
|
||||
// Ignore update if in edit mode.
|
||||
const newValue = this.props.value || '';
|
||||
if (prevState.initialValue != newValue && !prevState.active) {
|
||||
this.setState({
|
||||
initialValue: newValue,
|
||||
value: newValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handeTextChange(e) {
|
||||
this.setState({value: e.target.value});
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.keyCode === 27) {
|
||||
// Escape pressed
|
||||
this.setState({value: this.props.value, active: false});
|
||||
} else if (e.keyCode === 13) {
|
||||
// Enter pressed
|
||||
this.handleEditingFinished(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleStartEditing() {
|
||||
if (!this.props.readOnly) {
|
||||
ReactDOM.findDOMNode(this).focus();
|
||||
this.setState({active: true});
|
||||
}
|
||||
}
|
||||
|
||||
handleEditingFinished(event) {
|
||||
if (this.props.required && !event.target.checkValidity()) {
|
||||
// Empty input
|
||||
this.setState({value: this.props.value, active: false});
|
||||
return;
|
||||
}
|
||||
this.setState({active: false});
|
||||
let value = this.state.value.trim();
|
||||
if ((value || this.props.value) && (value !== this.props.value)) {
|
||||
this.props.onFinished(value);
|
||||
}
|
||||
}
|
||||
|
||||
handlePasswordFinished(value) {
|
||||
this.setState({active: false});
|
||||
if (value && (value !== this.props.value)) {
|
||||
this.props.onFinished(value);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.active) {
|
||||
var fieldType = this.props.type || 'text';
|
||||
} else {
|
||||
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
|
||||
var spanClass = 'in-place-edit' +
|
||||
(this.props.readOnly ? ' disabled' : '');
|
||||
if (!spanText) {
|
||||
spanText = this.props.placeholder;
|
||||
spanClass += ' placeholder';
|
||||
}
|
||||
if (spanText.length > 20) {
|
||||
// FIXME: this is wrong for RTL languages.
|
||||
spanText = spanText.substring(0, 19) + '...';
|
||||
}
|
||||
}
|
||||
return (
|
||||
this.state.active ?
|
||||
(fieldType == 'password' ?
|
||||
<VisiblePassword
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
autoFocus={true}
|
||||
onFinished={this.handlePasswordFinished}/>
|
||||
:
|
||||
<input type={fieldType}
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
autoFocus
|
||||
onChange={this.handeTextChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleEditingFinished} />
|
||||
)
|
||||
:
|
||||
<span className={spanClass} onClick={this.handleStartEditing}>
|
||||
<span className="content">{spanText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// A single topic or user.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class Invitation extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleButtonAction = this.handleButtonAction.bind(this);
|
||||
}
|
||||
|
||||
handleButtonAction(evt, data) {
|
||||
evt.preventDefault();
|
||||
this.props.onAction(data);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="accept-invite-panel">
|
||||
<div className="title">
|
||||
<FormattedMessage id="chat_invitation"
|
||||
defaultMessage="You are invited to start a new chat. What would you like to do?"
|
||||
description="New chat invitation message: [Accept] [Ignore] [Block]." />
|
||||
</div>
|
||||
<div className="footer">
|
||||
<button className="primary" onClick={event => { this.handleButtonAction(event, "accept"); }}>
|
||||
<FormattedMessage id="chat_invitation_accept"
|
||||
defaultMessage="Accept" description="Action [Accept] for chat invitation." />
|
||||
</button>
|
||||
<button className="secondary" onClick={event => { this.handleButtonAction(event, "delete"); }}>
|
||||
<FormattedMessage id="chat_invitation_ignore"
|
||||
defaultMessage="Ignore" description="Action [Ignore] for chat invitation." />
|
||||
</button>
|
||||
<button className="secondary" onClick={event => { this.handleButtonAction(event, "block"); }}>
|
||||
<FormattedMessage id="chat_invitation_block"
|
||||
defaultMessage="Block" description="Action [Block] for chat invitation." />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import { stringHash } from '../lib/strformat.js';
|
||||
|
||||
export default class LetterTile extends React.PureComponent {
|
||||
render() {
|
||||
let avatar;
|
||||
if (this.props.avatar === true) {
|
||||
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
|
||||
const iconColor = (isGroup ? 'light-color' : 'dark-color')
|
||||
+ (Math.abs(stringHash(this.props.topic)) % 16);
|
||||
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
||||
const letter = this.props.title.trim().charAt(0);
|
||||
const className = 'lettertile ' + iconColor;
|
||||
avatar = (<div className={className}><div>{letter}</div></div>)
|
||||
} else {
|
||||
const className = 'material-icons ' + iconColor;
|
||||
avatar = isGroup ?
|
||||
<i className={className}>group</i> : <i className={className}>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,12 @@
|
||||
// The X menu to be displayed in title bars.
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuCancel extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}>
|
||||
<i className="material-icons">close</i>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuContacts extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onNewTopic();}}><i className="material-icons">chat</i></a>
|
||||
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSettings();}}><i className="material-icons">settings</i></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuStart extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSignUp();}}><i className="material-icons">person_add</i></a>
|
||||
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSettings();}}><i className="material-icons">settings</i></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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() {
|
||||
const open = !this.state.open;
|
||||
this.setState({open: open});
|
||||
if (this.props.onToggle) {
|
||||
this.props.onToggle(open);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<label className="small clean-clickable" onClick={this.handleToggle}>{this.props.title}...
|
||||
{this.state.open ? <i className="material-icons">expand_more</i> :
|
||||
<i className="material-icons">chevron_right</i>}
|
||||
</label>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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) {
|
||||
const name = this.state.groupId.trim();
|
||||
const prefix = name.substr(0, 3);
|
||||
if (name.length > 3 && ['usr', 'grp', 'chn'].includes(prefix)) {
|
||||
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 autoFocus />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_subscribe" defaultMessage="Subscribe"
|
||||
description="Button [Subscribe]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(NewTopicById);
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import AvatarUpload from './avatar-upload.jsx';
|
||||
import CheckBox from './checkbox.jsx';
|
||||
import TagManager from './tag-manager.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
||||
|
||||
export default class NewTopicGroup extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
fn: '', // full/formatted name
|
||||
private: '',
|
||||
imageDataUrl: null,
|
||||
tags: [],
|
||||
isChannel: false
|
||||
};
|
||||
|
||||
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.handleChannelToggle = this.handleChannelToggle.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});
|
||||
}
|
||||
|
||||
handleChannelToggle() {
|
||||
this.setState({isChannel: !this.state.isChannel});
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH);
|
||||
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
|
||||
if (fn) {
|
||||
this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags, this.state.isChannel);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let submitClasses = 'primary';
|
||||
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" defaultMessage="Name"
|
||||
description="Label for editing 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" defaultMessage="Private comment"
|
||||
description="Label for editing 'private'" />
|
||||
</label>
|
||||
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
|
||||
description="Placeholder for editing 'private'">{
|
||||
(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>
|
||||
<div className="panel-form-row">
|
||||
<CheckBox checked={this.state.isChannel} onChange={this.handleChannelToggle}/>
|
||||
<label onClick={this.handleChannelToggle}><FormattedMessage id="channel_prompt"
|
||||
defaultMessage="This is a channel"
|
||||
description="Checkbox label when creating a channel" /></label>
|
||||
</div>
|
||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
||||
description="Section title for TagManager">{
|
||||
(title) => <TagManager
|
||||
tags={this.state.tags}
|
||||
activated={true}
|
||||
onTagsChanged={this.handleTagsChanged}
|
||||
tinode={this.props.tinode}
|
||||
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 { makeImageDataUrl } 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={makeImageDataUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
|
||||
<label className="small"><FormattedMessage id="title_permissions"
|
||||
defaultMessage="Permissions" description="Section title"/></label>
|
||||
<table className="permission-editor">
|
||||
{this.props.compare ?
|
||||
<thead><tr>
|
||||
<th></th><th>{this.props.modeTitle}</th>
|
||||
<th>{this.props.compareTitle}</th>
|
||||
</tr></thead> :
|
||||
null}
|
||||
<tbody>
|
||||
{items}
|
||||
</tbody></table>
|
||||
<br />
|
||||
<div className="dialog-buttons">
|
||||
<button className="outline" onClick={this.handleCancel}>
|
||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||
</button>
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(PermissionsEditor);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Received/read indicator.
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import { shortDateFormat } from '../lib/strformat.js';
|
||||
import { deliveryMarker } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
message_sending: {
|
||||
id: 'message_sending',
|
||||
defaultMessage: 'sending...',
|
||||
description: 'Message being sent, in place of time stamp'
|
||||
},
|
||||
message_sending_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.message_sending);
|
||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||
timestamp = formatMessage(messages.message_sending_failed);
|
||||
} else {
|
||||
timestamp = shortDateFormat(this.props.timestamp, this.props.intl.locale);
|
||||
}
|
||||
|
||||
const icon = deliveryMarker(this.props.received);
|
||||
const marker = icon ? <i className={'material-icons small ' + icon.color}>{icon.name}</i> : null;
|
||||
|
||||
return (
|
||||
<span className="timestamp">
|
||||
{timestamp}{'\u00a0'}{marker}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(ReceivedMarker);
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
export default class SearchContacts extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
edited: false,
|
||||
search: ''
|
||||
};
|
||||
|
||||
this.handleSearchChange = this.handleSearchChange.bind(this);
|
||||
this.handleSearch = this.handleSearch.bind(this);
|
||||
this.handleClear = this.handleClear.bind(this);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.edited) {
|
||||
this.setState({search: '', edited: false});
|
||||
this.props.onSearchContacts(Tinode.DEL_CHAR);
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchChange(e) {
|
||||
this.setState({search: e.target.value});
|
||||
}
|
||||
|
||||
handleSearch(e) {
|
||||
e.preventDefault();
|
||||
var query = this.state.search.trim();
|
||||
this.setState({edited: (query.length > 0)});
|
||||
this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
|
||||
}
|
||||
|
||||
handleClear(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.edited) {
|
||||
this.props.onSearchContacts(Tinode.DEL_CHAR);
|
||||
}
|
||||
this.setState({search: '', edited: false});
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
this.handleSearch(e);
|
||||
} else if (e.key === 'Escape') {
|
||||
this.handleClear();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="panel-form">
|
||||
<div className="panel-form-row">
|
||||
<i className="material-icons search">search</i>
|
||||
<FormattedMessage id="search_placeholder" defaultMessage="List like email:alice@example.com, tel:17025550003..."
|
||||
description="Placeholder in contacts search field">{
|
||||
(search_placeholder) => <input className="search" type="text"
|
||||
placeholder={search_placeholder}
|
||||
value={this.state.search} onChange={this.handleSearchChange}
|
||||
onKeyDown={this.handleKeyDown} required autoFocus />
|
||||
}</FormattedMessage>
|
||||
<a href="#" onClick={this.handleClear}>
|
||||
<i className="material-icons">close</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||