Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a884d2546 | ||
|
|
16e688d4c1 | ||
|
|
16bfaddf2c | ||
|
|
1085d1caf6 | ||
|
|
abf5b19758 | ||
|
|
44e2fc3a23 | ||
|
|
e505b0a607 | ||
|
|
8ca7f5cd3e | ||
|
|
a74ad9dd63 | ||
|
|
c70e5ebf7e | ||
|
|
679276c2e2 | ||
|
|
5faacc1f53 | ||
|
|
4818ee7a25 | ||
|
|
0fb5132d24 | ||
|
|
ed6be52228 | ||
|
|
1dc5ea1655 | ||
|
|
6e04638441 | ||
|
|
89e9b85551 | ||
|
|
5bb829a6bc | ||
|
|
92611680e4 | ||
|
|
78f31b7cf1 | ||
|
|
79a1bc98b2 | ||
|
|
0b05d480c7 | ||
|
|
7d1011400e | ||
|
|
3a4794bacb | ||
|
|
56790262f3 | ||
|
|
fa436b61be | ||
|
|
bb1a3f4cea | ||
|
|
36cafbe87b | ||
|
|
a84a5a677a | ||
|
|
6bec95e084 | ||
|
|
290571f305 | ||
|
|
7ce9470bf3 | ||
|
|
9eba65b711 | ||
|
|
0400c804aa | ||
|
|
7ea1a24857 | ||
|
|
80d3f5336b | ||
|
|
6a208cf040 | ||
|
|
9cef61d447 | ||
|
|
2cb9f69a9d | ||
|
|
276f2bed34 | ||
|
|
2f05cd0651 | ||
|
|
1ba3671873 | ||
|
|
6d02cc903c | ||
|
|
ff60b3902e | ||
|
|
e641dc0d32 | ||
|
|
8535da995f | ||
|
|
baad48f044 | ||
|
|
72009a693b | ||
|
|
b3adf15f12 | ||
|
|
e8b48dfd84 | ||
|
|
7c6fb38b02 | ||
|
|
28f332f56a | ||
|
|
6fdc208ca7 | ||
|
|
2f74357aac | ||
|
|
e692f80bcf | ||
|
|
a411b80fc2 | ||
|
|
837013e97c | ||
|
|
360b37ae13 | ||
|
|
c69229934e | ||
|
|
8288ad4733 | ||
|
|
7d627edfce | ||
|
|
04279395bc | ||
|
|
14b38af8da | ||
|
|
3ae00646c7 | ||
|
|
43fde20643 | ||
|
|
6527c067c6 | ||
|
|
2f374e52ca | ||
|
|
b1acfecd4f | ||
|
|
5448f10b1a | ||
|
|
3b78971a39 | ||
|
|
11436a80ee | ||
|
|
3567f323c7 | ||
|
|
6f05fd102f | ||
|
|
4897d63029 | ||
|
|
5e92189b97 | ||
|
|
30bf77d4ba | ||
|
|
b2772924bd | ||
|
|
6c1c8cf336 | ||
|
|
f91d8a979d | ||
|
|
17b91c4bb8 | ||
|
|
a3a4fe7505 | ||
|
|
02e8bd2f74 | ||
|
|
bc8f20977e | ||
|
|
12a97cacc8 | ||
|
|
4052bb72b6 | ||
|
|
458b519845 | ||
|
|
07a22564c2 | ||
|
|
31a95676b7 | ||
|
|
ba81261ee8 | ||
|
|
944cf32bb9 | ||
|
|
ed34a46865 | ||
|
|
a4befc4cd3 | ||
|
|
7b15cf1f19 | ||
|
|
df03509029 | ||
|
|
354ce3b8a3 | ||
|
|
66192a8d53 | ||
|
|
7f2a89d86c | ||
|
|
9eaa172030 | ||
|
|
f932b973f8 | ||
|
|
40916e2fc3 | ||
|
|
5faedc35f3 | ||
|
|
de32da49f3 | ||
|
|
f5714b3b69 | ||
|
|
1155888ced | ||
|
|
d128cec90d | ||
|
|
3a8ee3e2dd | ||
|
|
1164d3a5bd | ||
|
|
fb2a39280a | ||
|
|
a1faba6b0c | ||
|
|
ca24b82f54 | ||
|
|
395edb3e4c | ||
|
|
691879b2b2 | ||
|
|
8187f09d26 | ||
|
|
e82951722d | ||
|
|
1042b9c39a | ||
|
|
3177b667b8 | ||
|
|
5fb85ce822 | ||
|
|
500d72702a | ||
|
|
31b13401a2 | ||
|
|
338e0f6023 | ||
|
|
df4b9c312a | ||
|
|
07d32dbfd8 | ||
|
|
0d887b73a0 | ||
|
|
685a1cd2a4 | ||
|
|
8f9f41efe5 | ||
|
|
dde4e565c8 | ||
|
|
94575b5a07 | ||
|
|
3bf822170c | ||
|
|
deca04ed3e | ||
|
|
97908ec84b | ||
|
|
20dcb48ec5 | ||
|
|
7bad07f717 | ||
|
|
2f85f01a1a | ||
|
|
a86d07f854 | ||
|
|
414129d968 | ||
|
|
5d664b86cb | ||
|
|
d6c904d38a | ||
|
|
ab04a872e2 | ||
|
|
7f0a16d701 | ||
|
|
43e89e2119 | ||
|
|
baf211ff82 | ||
|
|
c5c55fd9fd | ||
|
|
8403e3e9b4 | ||
|
|
fd08d19939 | ||
|
|
b14717bb48 | ||
|
|
f969c04c47 | ||
|
|
2fe35f9ded | ||
|
|
852a1a7c4e | ||
|
|
f7120d824d | ||
|
|
c15092e850 | ||
|
|
7ca827559b | ||
|
|
f7beabacf9 | ||
|
|
848bb19f76 | ||
|
|
981ef684a7 | ||
|
|
55fc4356da | ||
|
|
c17118d157 | ||
|
|
95e9891825 | ||
|
|
0984c73c01 | ||
|
|
0425bd4348 | ||
|
|
f9e7a71920 | ||
|
|
ba72fe7602 | ||
|
|
6da591beb1 | ||
|
|
16c9e2bb23 | ||
|
|
3689b5d527 | ||
|
|
c277c16a53 | ||
|
|
1bc3e15da3 | ||
|
|
3ac895ec66 | ||
|
|
08bdc7bc08 | ||
|
|
03a137da4a | ||
|
|
8df6413705 | ||
|
|
5d56c17128 | ||
|
|
87c74f9396 | ||
|
|
2b7c80ef2d | ||
|
|
823215aea6 | ||
|
|
5ad734cdea | ||
|
|
48405bcb94 | ||
|
|
0f9b88e64e | ||
|
|
10a827504b | ||
|
|
1d24e9135b | ||
|
|
a5c190f6db | ||
|
|
f61f68d2ac | ||
|
|
2c5705c3ae | ||
|
|
c4547bbeb4 | ||
|
|
e7296d5d14 | ||
|
|
364cd2c488 | ||
|
|
855f871041 | ||
|
|
8dd32a341b | ||
|
|
ebe47b345d | ||
|
|
454dc204c8 | ||
|
|
d0c88fbd0d | ||
|
|
e68ebc8336 | ||
|
|
cca97d410e | ||
|
|
eafd87fb17 | ||
|
|
71ee3fbec4 | ||
|
|
11fe81fdf1 | ||
|
|
9640c604b9 | ||
|
|
6273b3287f | ||
|
|
9c5e3a3eca | ||
|
|
9098c1f6a6 | ||
|
|
a2439be540 | ||
|
|
a2fb3fe1a0 | ||
|
|
560e3369e4 | ||
|
|
491175cecc | ||
|
|
6d96d327f1 | ||
|
|
6ee2851943 | ||
|
|
192a3d4ec2 | ||
|
|
c6f7911c82 | ||
|
|
9a1f0c734a | ||
|
|
52f879490e | ||
|
|
8df3c34cf4 | ||
|
|
2fb6a9bb0a | ||
|
|
5ae58fb2a6 | ||
|
|
43798ea1d9 | ||
|
|
323042bd96 | ||
|
|
172b9eb26d | ||
|
|
1b90c613f7 | ||
|
|
c6b4460389 | ||
|
|
497318b722 | ||
|
|
423f53880d | ||
|
|
a93ede25e9 | ||
|
|
b4d3e0274f | ||
|
|
4f116ab3e6 | ||
|
|
75b2880b63 | ||
|
|
1149980a47 | ||
|
|
87b48cd67d | ||
|
|
4be09a034e | ||
|
|
3c0b57333c | ||
|
|
0c9b23eee9 | ||
|
|
d11f94095f | ||
|
|
133ed3cc53 | ||
|
|
7bc50fac36 | ||
|
|
fbdd017d38 | ||
|
|
8fdb4a8a4c | ||
|
|
e1b1ae503f | ||
|
|
7b44913724 | ||
|
|
7e3b2fc254 | ||
|
|
fc6a8fbafc | ||
|
|
111b9d8641 | ||
|
|
eeea27ca82 | ||
|
|
fc2ff79329 | ||
|
|
f755314c3b | ||
|
|
caf02056ab | ||
|
|
4b26d5ed40 | ||
|
|
885d9932e2 | ||
|
|
f6c355498b | ||
|
|
455f0f5c67 | ||
|
|
e99370683b | ||
|
|
9fb1551bef | ||
|
|
8d40301ddf | ||
|
|
93fb2b8bca | ||
|
|
97178a603f | ||
|
|
f13c1d21c0 | ||
|
|
7e28c8693c | ||
|
|
c95181b0cf | ||
|
|
b2c5051d31 | ||
|
|
6e46a8dfda | ||
|
|
99c9a698a0 | ||
|
|
aa5e9ef5c7 | ||
|
|
4f6d617829 | ||
|
|
05c5ffa43c | ||
|
|
9d7bfec6d6 | ||
|
|
d8f7a41bdd | ||
|
|
6c36324ed4 | ||
|
|
e2f3201ddb | ||
|
|
34f17f45b1 | ||
|
|
1cf4722830 | ||
|
|
7c4327ac71 | ||
|
|
48caa605c7 | ||
|
|
edb1383d60 | ||
|
|
2a653f2299 | ||
|
|
eba83fdec8 | ||
|
|
5f173dd395 | ||
|
|
2115f6d638 | ||
|
|
5228d44ade | ||
|
|
ce19ebbbdd | ||
|
|
5e60a5e04d | ||
|
|
9f523b54a4 | ||
|
|
6c13943f1a | ||
|
|
b0bf93a4d9 | ||
|
|
b766e827c9 | ||
|
|
424a8b64bf | ||
|
|
9108efc8c9 | ||
|
|
f94c3612be | ||
|
|
95ddfd21eb | ||
|
|
ae5618c072 | ||
|
|
d083a286df | ||
|
|
e0db170aec | ||
|
|
e9d0f0f1f3 | ||
|
|
08d4c08cbc | ||
|
|
15d24c6607 | ||
|
|
c085ef8ce1 | ||
|
|
2d76befea1 | ||
|
|
20c54fb7e3 | ||
|
|
c5834fad4d | ||
|
|
640704ee2a | ||
|
|
18dd37b402 | ||
|
|
54d6719307 | ||
|
|
255e7fda98 | ||
|
|
1a6e8c8e74 | ||
|
|
b642d05a10 | ||
|
|
920619a988 | ||
|
|
acc24b552d | ||
|
|
ba94788fbe | ||
|
|
2758bb9293 | ||
|
|
4635456c1a | ||
|
|
89f9821255 | ||
|
|
8690ae6a1b | ||
|
|
483d7ef58d | ||
|
|
e1d7ebcc22 | ||
|
|
45fbff1b4a | ||
|
|
f99f5a223f | ||
|
|
c0e4cd4965 | ||
|
|
7d796e3dae | ||
|
|
1d3a9040cb | ||
|
|
29cce0a4cc | ||
|
|
c59c3eeb0b | ||
|
|
cab80e4d62 | ||
|
|
f7c5322e36 | ||
|
|
cb64e1f247 | ||
|
|
afee6b5dd9 | ||
|
|
9f01eab324 | ||
|
|
80d48077c9 | ||
|
|
1bc6f87844 | ||
|
|
0ea112b145 | ||
|
|
768edd744d | ||
|
|
1aadf4e7bd | ||
|
|
2d92e475b5 | ||
|
|
a28c3eb5cd | ||
|
|
97208e1448 | ||
|
|
9fbc601870 | ||
|
|
4489646144 | ||
|
|
261b2dbdd7 | ||
|
|
224323f443 | ||
|
|
6d031f033e | ||
|
|
c38e1832dc | ||
|
|
13036da36b | ||
|
|
f5c628fbb5 | ||
|
|
5673d82b33 | ||
|
|
8c3287941a | ||
|
|
830d15f7ec | ||
|
|
4e42d96d66 | ||
|
|
ed77f48189 | ||
|
|
ed3107405f | ||
|
|
03e87697cf | ||
|
|
bccf0d3b6b | ||
|
|
36b7d1b721 | ||
|
|
2294410fb9 | ||
|
|
10140c6450 | ||
|
|
489a05663a | ||
|
|
f31a3ebdcc | ||
|
|
6360708255 | ||
|
|
24a87f3deb | ||
|
|
a8d7e6be50 | ||
|
|
b434c4ef17 | ||
|
|
0d33c0f0a8 | ||
|
|
e95aea17be | ||
|
|
d820da2ee2 | ||
|
|
692e6e1d8f | ||
|
|
6aca7e527e | ||
|
|
ae34795c2d | ||
|
|
310b352a08 | ||
|
|
b8e9ca8d0a | ||
|
|
7731c6700b | ||
|
|
563f82d265 | ||
|
|
10b24cc16e | ||
|
|
f6cd3048e1 | ||
|
|
4ad723b854 | ||
|
|
4af7a39527 | ||
|
|
0e11ae9382 | ||
|
|
a2014826e3 | ||
|
|
3a002f224d | ||
|
|
fa78e678b9 | ||
|
|
5fdcc65b5b | ||
|
|
476452c59e | ||
|
|
b0c0790b91 | ||
|
|
9d83dee14c | ||
|
|
09223c9efc | ||
|
|
d0a23b0792 | ||
|
|
d54e4a8153 | ||
|
|
57a062266f | ||
|
|
ed71c6bb6e | ||
|
|
307665d8ec | ||
|
|
297aef7bf1 | ||
|
|
161f544442 | ||
|
|
22e782fa2d | ||
|
|
7135553e42 | ||
|
|
1913ed0c11 | ||
|
|
6de41f4988 | ||
|
|
c5e744aacf | ||
|
|
983de97d7f | ||
|
|
7236c913da | ||
|
|
72ec3b51a0 | ||
|
|
3653453897 | ||
|
|
d32c1636ce | ||
|
|
a99ad81196 | ||
|
|
eec52a590c | ||
|
|
c7b8ce11e0 | ||
|
|
2156aa0877 | ||
|
|
966dec9654 | ||
|
|
0b2ab61422 | ||
|
|
092b4697a9 | ||
|
|
6c5fe836f4 | ||
|
|
40b99f7332 | ||
|
|
d45a141704 | ||
|
|
ee106a17f6 | ||
|
|
22320808fc | ||
|
|
5006a6fb34 | ||
|
|
df203f48bd | ||
|
|
0e53234d02 | ||
|
|
14a4725c94 | ||
|
|
740cb7faf8 | ||
|
|
384955777d | ||
|
|
8b80f59d3a | ||
|
|
6a6e3ad754 | ||
|
|
d15aa9372d | ||
|
|
5144f581e7 | ||
|
|
8250c5f372 | ||
|
|
ae26e0ba3d | ||
|
|
6a52591e1a | ||
|
|
81afca1efe | ||
|
|
eac59ea11f | ||
|
|
2f3a066838 | ||
|
|
3922db3aa6 | ||
|
|
9cd778534a | ||
|
|
69282bab89 | ||
|
|
af08392260 | ||
|
|
a90f1f4fef | ||
|
|
781009b745 | ||
|
|
6357f9227e | ||
|
|
7dfb90d8f3 | ||
|
|
7348fdef7f | ||
|
|
e42de09504 | ||
|
|
5c842ef212 | ||
|
|
c94b397222 | ||
|
|
c013885bec | ||
|
|
4a2667015f | ||
|
|
e209ef2483 | ||
|
|
2a00791a1b | ||
|
|
dcf9e1d47e | ||
|
|
6c0612dc8f | ||
|
|
315631f67c | ||
|
|
c31c0c2c07 | ||
|
|
c7c2cffbbf | ||
|
|
e77e8441cf | ||
|
|
d95c53ed24 | ||
|
|
300b4f8d64 | ||
|
|
36977b429b | ||
|
|
0d7643ee42 | ||
|
|
145ac78de8 | ||
|
|
245603e8b9 | ||
|
|
3eab7ec09b | ||
|
|
a50261ab42 | ||
|
|
8cd8ffb659 | ||
|
|
932ab0a047 | ||
|
|
b5686f45b3 | ||
|
|
88c4cb42cd | ||
|
|
cd6a87037d | ||
|
|
deaea078ea | ||
|
|
bdfe163ecf | ||
|
|
1fb3a233a2 | ||
|
|
0c91e4c615 | ||
|
|
b138fa2c44 | ||
|
|
70c4a775e3 | ||
|
|
978e858592 | ||
|
|
eca15317ce | ||
|
|
a9961c2cc1 | ||
|
|
557cbffe48 | ||
|
|
31258a75fa | ||
|
|
f884874d43 | ||
|
|
762ccc1f31 | ||
|
|
10b3268d43 | ||
|
|
fd1c13830c | ||
|
|
4e720a0d5b | ||
|
|
6dc91ad518 | ||
|
|
ae15a043b1 | ||
|
|
3af0b4409c | ||
|
|
feb2e4b3e3 | ||
|
|
276d8c631a | ||
|
|
720c9ae2d4 | ||
|
|
ed7e246f61 | ||
|
|
0dea77b5f2 | ||
|
|
e287755f13 | ||
|
|
eef880c448 | ||
|
|
1159efde80 | ||
|
|
9507aeb28d | ||
|
|
95011e4a04 | ||
|
|
fc94d7454f | ||
|
|
9dc1f6f3a7 | ||
|
|
3c87341280 | ||
|
|
df460c6b61 | ||
|
|
fec6a5bfa6 | ||
|
|
43e3395350 | ||
|
|
535d276665 | ||
|
|
b37ce5e8cf | ||
|
|
147dfe0ea3 | ||
|
|
91eacd23ef | ||
|
|
97f3699e97 | ||
|
|
50c8c61078 | ||
|
|
ff605369be | ||
|
|
3d5b2bf7b3 | ||
|
|
92aa11be39 | ||
|
|
4e7c3419c0 | ||
|
|
feae1c9f46 | ||
|
|
293d71dea6 | ||
|
|
7d2c7a634c | ||
|
|
d78dc0e22a | ||
|
|
203612b421 | ||
|
|
f40fd295b1 | ||
|
|
b26239f1ff | ||
|
|
13c1fcf811 | ||
|
|
a7d98136c5 | ||
|
|
2a7c85c61c | ||
|
|
4ff315329c | ||
|
|
2b51f19fbd | ||
|
|
a9677cee8b | ||
|
|
46b6a816eb | ||
|
|
6ffc03e8bd | ||
|
|
ccfa7dacdf | ||
|
|
ec01ac2a50 | ||
|
|
a9e4f644a2 | ||
|
|
5087a70d5e | ||
|
|
57466f61b3 | ||
|
|
b4a5ad121b | ||
|
|
f9a563248b | ||
|
|
a4d765da14 | ||
|
|
de2f38bbd5 | ||
|
|
b45451893d | ||
|
|
2c4f005d76 | ||
|
|
30d57e11f2 | ||
|
|
d477fd4e82 | ||
|
|
06945a01ea | ||
|
|
cc75d81538 | ||
|
|
f50e79ea1e | ||
|
|
706a7e0aaa | ||
|
|
ed62fe17c5 | ||
|
|
e7290e6c62 | ||
|
|
e938c909a1 | ||
|
|
510a3d7113 | ||
|
|
c36fb2657a | ||
|
|
cc66356ad5 | ||
|
|
c4d4aa77db | ||
|
|
0daef53c87 | ||
|
|
ad98bec73e | ||
|
|
517f457182 | ||
|
|
0e92ace6f4 | ||
|
|
3675812353 | ||
|
|
4b9078e64a | ||
|
|
ca8235e1cc | ||
|
|
56b9bb7814 | ||
|
|
78116ef36f | ||
|
|
408b221bac | ||
|
|
2a11885ac3 | ||
|
|
62e4a4283f | ||
|
|
8fd49cc55e | ||
|
|
60b52b342d | ||
|
|
570ef47455 | ||
|
|
ae2d7a12ad | ||
|
|
b310a94180 | ||
|
|
45ad8bb2ad | ||
|
|
2bc89f67f8 | ||
|
|
34ba874091 | ||
|
|
9ddd103ac5 | ||
|
|
601c048ea5 | ||
|
|
237e97c323 | ||
|
|
891780bb52 | ||
|
|
c9391cf451 | ||
|
|
74b756d817 | ||
|
|
f3def8cf8a | ||
|
|
3d2af23f14 | ||
|
|
7044ce08c1 | ||
|
|
c5011a4383 | ||
|
|
d0c4251eda | ||
|
|
2e0ed44763 | ||
|
|
afea2aeec0 | ||
|
|
e121b1645f | ||
|
|
2ab56887b7 | ||
|
|
7e3a616983 | ||
|
|
e852e2972a | ||
|
|
ff7f8faf37 | ||
|
|
589b46a8c2 | ||
|
|
824f15aa29 | ||
|
|
cc3b1190fd | ||
|
|
025d4cfadb | ||
|
|
d3cc0571ce | ||
|
|
539a37e5ee | ||
|
|
e42a7ca6de | ||
|
|
609d3e3a9f | ||
|
|
038f62d535 | ||
|
|
37451b9aff | ||
|
|
00a1b656e4 | ||
|
|
e0c412a33c | ||
|
|
a2ca095c24 | ||
|
|
42ae953055 | ||
|
|
9c2d3f3693 | ||
|
|
9d8a1fdfc4 | ||
|
|
713cb4ea03 | ||
|
|
c6a9df9fe9 | ||
|
|
470d38e583 | ||
|
|
65dff7d168 | ||
|
|
d2d83a0865 | ||
|
|
aa498a7d1f | ||
|
|
d106f6382a | ||
|
|
cdff2ee697 | ||
|
|
d76756c67a | ||
|
|
4edb48fd24 | ||
|
|
860ae42513 | ||
|
|
0598a42164 | ||
|
|
9a811708ba | ||
|
|
bb63cb204b | ||
|
|
f68057fe87 | ||
|
|
fb45c442a8 | ||
|
|
acf4f0fa2a | ||
|
|
6cea3e97db | ||
|
|
e5545f5f14 | ||
|
|
24d75dfbbc | ||
|
|
5370b39fb6 | ||
|
|
84a78ce6b5 | ||
|
|
c8756c3a01 | ||
|
|
e3cb8a7291 | ||
|
|
3c11cbbdf1 | ||
|
|
9657a1e61e | ||
|
|
728b3a3a1b | ||
|
|
41bf6f3941 | ||
|
|
a02be831f2 | ||
|
|
5cfcd5789f | ||
|
|
28c34bcf7d | ||
|
|
e87a36421c | ||
|
|
479752036d | ||
|
|
c2d95725e4 | ||
|
|
9acca449e3 | ||
|
|
bd2def7882 | ||
|
|
b201f019ef | ||
|
|
1d59d29519 | ||
|
|
9582a9f2cd | ||
|
|
363104755e | ||
|
|
ab7160170d | ||
|
|
afcb8d8616 | ||
|
|
69cf5817f0 | ||
|
|
572fc66a69 | ||
|
|
6a877620e6 | ||
|
|
c7a60b9608 | ||
|
|
3feca32d13 | ||
|
|
081eb8723d | ||
|
|
a1034654a9 | ||
|
|
faa3f60b55 | ||
|
|
31345eb298 | ||
|
|
a9332495cf | ||
|
|
e4c5da3178 | ||
|
|
f3c6581fa5 | ||
|
|
d6cf419266 | ||
|
|
939804eae0 | ||
|
|
2fc8826403 | ||
|
|
298bca4042 | ||
|
|
f5d3b3af0b | ||
|
|
733295f7e6 | ||
|
|
a706729f1b | ||
|
|
e180f526fb | ||
|
|
990f929b4e | ||
|
|
07292dffa4 | ||
|
|
63b13f8cdd | ||
|
|
70c15653a9 | ||
|
|
caf381496f | ||
|
|
00b57cd4db | ||
|
|
2c32c102d4 | ||
|
|
57a6a33122 | ||
|
|
73990fb907 | ||
|
|
e3ae013875 | ||
|
|
0f031d2f0d | ||
|
|
9d61168cf2 | ||
|
|
3feca6cb41 | ||
|
|
825aa3fb51 | ||
|
|
fb2b4b337b | ||
|
|
8f672405bc | ||
|
|
a17b3d5b9c | ||
|
|
2e45ac6bf9 | ||
|
|
ab5f2179ea | ||
|
|
b8d1cd6f70 | ||
|
|
4ad0baf99a | ||
|
|
4f1c9e161a | ||
|
|
a223d910c1 | ||
|
|
2e589bef0c | ||
|
|
730426041a | ||
|
|
338e42dd2a | ||
|
|
35e3466a79 | ||
|
|
a1e3cfb577 | ||
|
|
360a092584 | ||
|
|
a9e356b927 | ||
|
|
b6abc76f3f | ||
|
|
b1589a8790 | ||
|
|
03a2168761 | ||
|
|
3989d8cee9 | ||
|
|
eae565edfa | ||
|
|
f9bf9b07d9 | ||
|
|
fb3fae8c9b | ||
|
|
3a7a806420 | ||
|
|
03f076a1bc | ||
|
|
3a4d1f03c0 | ||
|
|
96c64b4b27 | ||
|
|
4175cffa4d | ||
|
|
9e8b55391a | ||
|
|
c2c3b743b3 | ||
|
|
8de413c5b5 | ||
|
|
4791f3dcc3 | ||
|
|
760289c6cf | ||
|
|
ae528ef8d6 | ||
|
|
3e5e9932d8 | ||
|
|
e72dc20192 | ||
|
|
707dfcf579 | ||
|
|
6343d3db81 | ||
|
|
7117955126 | ||
|
|
e98c7d5ed7 | ||
|
|
7bcfd974cb | ||
|
|
f51ba69fa7 | ||
|
|
228cc9669c | ||
|
|
3a46599e79 | ||
|
|
84df30a767 | ||
|
|
58ae1359fa | ||
|
|
3d55ae1a89 | ||
|
|
c00c1002e3 | ||
|
|
8ee83a9b26 | ||
|
|
68e1287fb8 | ||
|
|
f57c5736e6 | ||
|
|
531a7ed74a | ||
|
|
4746b18ffd | ||
|
|
067eba01fc | ||
|
|
80b7a33e9f | ||
|
|
4a938b9aa8 | ||
|
|
55bfc5d2b6 | ||
|
|
dd81190ff3 | ||
|
|
3e4ef8347f | ||
|
|
b833844d09 | ||
|
|
2ace6b8ad7 | ||
|
|
2fc1b97124 | ||
|
|
21d3115346 | ||
|
|
c408cc12c8 | ||
|
|
0fd3fc2e23 | ||
|
|
ffca6573df | ||
|
|
9e7c12f9e5 | ||
|
|
df39bccf78 | ||
|
|
186d9006fc | ||
|
|
25bd8d2ada | ||
|
|
2edfaeecde | ||
|
|
331c17a7cc | ||
|
|
53936f11e0 | ||
|
|
9660b9016b | ||
|
|
d706cc4f6b | ||
|
|
a45843fd39 | ||
|
|
656cfb3932 | ||
|
|
0f186b6b3b | ||
|
|
efc0dfddc0 | ||
|
|
667d0e6d99 | ||
|
|
292e974d29 | ||
|
|
8294248e2d | ||
|
|
e938b4ecd5 | ||
|
|
bf19cd1351 | ||
|
|
ae197dac8f | ||
|
|
5ea4fdf05e | ||
|
|
33eed09c84 | ||
|
|
dd79587a63 | ||
|
|
775915e6b2 | ||
|
|
9c0bdb4848 | ||
|
|
13432292cc | ||
|
|
62d289a270 | ||
|
|
b893f28145 | ||
|
|
ea816859b6 | ||
|
|
9508797ab9 | ||
|
|
9626995b1c | ||
|
|
b2a02fb78c | ||
|
|
29e3bd4f9f | ||
|
|
c522b90150 | ||
|
|
454b594b96 | ||
|
|
2cc2258682 | ||
|
|
529486feb3 | ||
|
|
fb3de109e0 | ||
|
|
982b40d6e8 | ||
|
|
93548d644f | ||
|
|
ce07c81bf7 | ||
|
|
34afdcac1a | ||
|
|
8af1727c3d | ||
|
|
08c295b704 | ||
|
|
7eb3ca32bb | ||
|
|
d22b35aacc | ||
|
|
e065d71aed | ||
|
|
b482f6649d | ||
|
|
d6ca3f9f3c | ||
|
|
d741f6ec92 | ||
|
|
1e08211d17 | ||
|
|
9a2e0519d3 | ||
|
|
47e7632399 | ||
|
|
58e319b58e | ||
|
|
17a658e373 | ||
|
|
1c0b464bed | ||
|
|
fcb61dcc3b | ||
|
|
6d4ff9d625 | ||
|
|
642821db9f | ||
|
|
dafc3161c5 | ||
|
|
34712ba1f0 | ||
|
|
47304c7baf | ||
|
|
37e8912fa5 | ||
|
|
274da95b71 | ||
|
|
59138c9f5f | ||
|
|
b555595f64 | ||
|
|
e636c4a30b | ||
|
|
4005b01a85 | ||
|
|
43be8d6662 | ||
|
|
8570677388 | ||
|
|
081548b35a | ||
|
|
fd14638c83 | ||
|
|
87fa1ee107 | ||
|
|
3da0984ae0 | ||
|
|
1961e76699 | ||
|
|
62fc84912e | ||
|
|
eb299b73d2 | ||
|
|
b35ff16faa | ||
|
|
094173fe02 | ||
|
|
fdfede5069 | ||
|
|
92f1d53e48 | ||
|
|
f81aaea837 | ||
|
|
f1ba810765 | ||
|
|
b704499308 | ||
|
|
0cd432a52f | ||
|
|
0afef6c587 | ||
|
|
4219e47529 | ||
|
|
8320bf28ce | ||
|
|
ba5b82115f | ||
|
|
bb9d47c7e0 | ||
|
|
f1196e17ff | ||
|
|
ab962bfe7d | ||
|
|
07854ba9fc | ||
|
|
f2cd1ba0f2 | ||
|
|
0195aae6ff | ||
|
|
6b8c3949a0 | ||
|
|
bc6e2ab8d2 | ||
|
|
72f4f47e8f | ||
|
|
ecb5729227 | ||
|
|
fd8e2d9a17 | ||
|
|
4975084eef | ||
|
|
e30868c072 | ||
|
|
c5c08aa4f9 | ||
|
|
bc67378bc9 | ||
|
|
649d6e0494 | ||
|
|
1a8f6351ef | ||
|
|
58426cd0ee | ||
|
|
3d0d7048aa | ||
|
|
9033c1ade0 |
@@ -1,8 +1,4 @@
|
||||
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
|
||||
umd/* binary
|
||||
package-lock.json binary
|
||||
*.min.css binary
|
||||
src/i18n.min/* binary
|
||||
|
||||
@@ -32,7 +32,7 @@ If applicable, add screenshots to help explain your problem.
|
||||
- TinodeWeb Version [e.g. 0.16.6]
|
||||
|
||||
**Console log**
|
||||
Please attach or insert console log which illustrates the problem.
|
||||
Please attach or insert console log which illustrates the problem. Please use the log produced by `index-dev.html`, not `index.html`; `index.html` uses minified javascript which produces unusable logs.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
src/i18n/src/
|
||||
src/i18n/srci/
|
||||
webapp.code-workspace.code-workspace
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"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"]
|
||||
["25:9A:71:EE:BD:2E:9F:D3:2D:64:1E:90:49:33:BD:F3:E3:42:BD:C4:DD:FA:BA:68:EE:D2:EB:76:65:33:DC:92"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -22,4 +22,5 @@
|
||||
"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"]
|
||||
}
|
||||
}]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@ 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/.
|
||||
Try possibly newer or older version live at https://sandbox.tinode.co/.
|
||||
|
||||
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
|
||||
|
||||
@@ -16,27 +16,43 @@ For demo access and other instructions [see here](https://github.com/tinode/chat
|
||||
|
||||
## 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).
|
||||
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). See instructions for [enabling push notifications](push.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).
|
||||
* Use https://tinode.co/contact for commercial inquiries.
|
||||
|
||||
## Helping out
|
||||
|
||||
* If you appreciate our work, please help spread the word! Sharing on Reddit, HN, and other communities helps more than you think.
|
||||
* Consider buying paid support: https://tinode.co/support.html
|
||||
* If you are a software developer, send us your pull requests with bug fixes and new features.
|
||||
* If you use the app and discover bugs or missing features, let us know by filing bug reports and feature requests. Vote for existing [feature requests](https://github.com/tinode/chat/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22feature+request%22) you find most valuable.
|
||||
* If you speak a language other than English, [translate](https://github.com/tinode/chat/blob/devel/docs/translations.md#webapp) the apps into your language. You may also review and improve existing translations.
|
||||
* If you are a UI/UX expert, help us polish the app UI.
|
||||
* Use it: install it for your colleagues or friends at work or at home.
|
||||
|
||||
## 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 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=fr.
|
||||
|
||||
The app is currently available in the following languages:
|
||||
* English
|
||||
* Chinese (simplified)
|
||||
* Chinese (traditional)
|
||||
* English - default
|
||||
* Arabic
|
||||
* Chinese simplified
|
||||
* Chinese traditional
|
||||
* French
|
||||
* German
|
||||
* Italian
|
||||
* Korean
|
||||
* Romanian
|
||||
* Russian
|
||||
* Spanish
|
||||
* Thai
|
||||
* Ukrainian
|
||||
|
||||
More translations are welcome. See [instructions](https://github.com/tinode/chat/blob/devel/docs/translations.md#webapp).
|
||||
|
||||
@@ -46,7 +62,9 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
|
||||
* End-to-End encryption.
|
||||
* Emoji support is weak.
|
||||
* Mentions, hashtags.
|
||||
* Previews not generated for videos, audio, links or docs.
|
||||
* Previews not generated for links and docs.
|
||||
* Location and contact sharing.
|
||||
* Recording video messages.
|
||||
|
||||
## Other
|
||||
|
||||
@@ -55,18 +73,6 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
|
||||
* [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
|
||||
@@ -78,5 +84,5 @@ If you want to use the app with your own server and want web push notification t
|
||||
### 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> <kbd><img src="web-mob-avatar.png" alt="Mobile web: change avatar" width=250 /></kbd>
|
||||
<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-forward.png" alt="Mobile web: forward message" width=250 /></kbd> <kbd><img src="web-mob-video-call.png" alt="Mobile web: video call" width=250 /></kbd> <kbd><img src="web-mob-avatar.png" alt="Mobile web: change avatar" width=250 /></kbd>
|
||||
</p>
|
||||
|
||||
@@ -9,5 +9,7 @@ const FIREBASE_INIT = {
|
||||
// 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"
|
||||
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58",
|
||||
// Google analytics tag. Uncomment and use one you got from https://analytics.google.com/.
|
||||
// measurementId: "G-ABCDEF1234"
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 620 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="24" viewBox="0 -960 960 960" width="24" fill="#5f6368" xmlns="http://www.w3.org/2000/svg"><path d="M197-706l-84-85 56-57 85 85zM791-112l-85-85 57-57 84 85zM0-440v-80h120v80zM840-440v-80h120v80zM169-113l-57-56 85-85 57 57zM763-706l-57-57 85-84 57 56zM440 0v-120h80V0zM440-840v-120h80v120zM743-380a766.09 766.09 0 01-39.5-11c-13-4-25.833-8.667-38.5-14 5.333-12 9.167-24.167 11.5-36.5 2.333-12.333 3.5-25.167 3.5-38.5 0-55.333-19.5-102.5-58.5-141.5S535.333-680 480-680c-13.333 0-26.167 1.167-38.5 3.5-12.333 2.333-24.5 6.167-36.5 11.5-5.333-12.667-9.833-25.333-13.5-38a1098.034 1098.034 0 01-10.5-39c16-6 32.333-10.5 49-13.5s33.667-4.5 51-4.5c78 0 144.167 27.167 198.5 81.5S761-558 761-480c0 17.333-1.5 34.333-4.5 51s-7.5 33-13.5 49z" fill="orange"/><path d="M453-452c34 34 72 63.167 114 87.5s87 43.167 135 56.5c-26.667 34-59.333 60.5-98 79.5S524.333-200 481-200c-78 0-144.167-27.167-198.5-81.5S201-402 201-480c0-43.333 9.5-84.333 28.5-123s45.5-71.333 79.5-98c13.333 48 32.167 93 56.5 135S419-486 453-452z" fill="#0c80d6"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -16,6 +16,7 @@
|
||||
<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="og:description" content="Tinode instant messenger, development version" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
@@ -28,14 +29,15 @@
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<!-- Styles for local elements -->
|
||||
<link rel="stylesheet" href="css/base.css" />
|
||||
<!-- Polyfill Intl.Segmenter for Firefox 124 and earlier. This can be safely removed in the end of 2024. -->
|
||||
<script crossorigin="anonymous" src="https://polyfill-fastly.io/v3/polyfill.min.js?features=Intl.Segmenter"></script>
|
||||
<!-- 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>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@18/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@9/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9/firebase-messaging.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@7/react-intl.iife.js"></script>
|
||||
<!-- Displaying QR code with ID of the topic -->
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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="description" content="Tinode instant messenger in a browser" />
|
||||
<meta name="application-url" content="https://web.tinode.co/">
|
||||
<meta name="theme-color" content="#3949AB" />
|
||||
<meta name="google" value="notranslate" />
|
||||
@@ -13,6 +13,7 @@
|
||||
<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="og:description" content="Tinode instant messenger in a browser" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
@@ -22,11 +23,11 @@
|
||||
<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@9.4.0/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@9.4.0/firebase-messaging.js"></script>
|
||||
<script crossorigin="anonymous" src="https://polyfill-fastly.io/v3/polyfill.min.js?features=Intl.Segmenter"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@7.1.6/react-intl.iife.min.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||
|
||||
@@ -36,5 +36,10 @@
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
|
||||
}
|
||||
]
|
||||
],
|
||||
"permissions": {
|
||||
"audio-capture": {
|
||||
"description": "Required for recording voice messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.18.0-beta1",
|
||||
"version": "0.25.0-beta1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tinode/example-react-js.git"
|
||||
"url": "git+https://github.com/tinode/webapp.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "echo 'Read installation instructions'",
|
||||
"build": "npm run vers && npm run build:css && npm run extract && npm run build:i18n && npm run build:prod && npm run build:dev",
|
||||
"clean": "rm umd/* && rm css/base.min.css && rm src/i18n.min/*",
|
||||
"build:css": "postcss css/base.css > css/base.min.css",
|
||||
"build:dev": "webpack --mode development",
|
||||
"build:prod": "webpack --mode production",
|
||||
@@ -16,17 +18,14 @@
|
||||
"i18n:obsolete": "node scripts/i18n.js obsolete",
|
||||
"vers": "node scripts/gen-version.js",
|
||||
"extract": "formatjs extract \"src/**/*.{js,jsx}\" --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
||||
"compile": "formatjs compile"
|
||||
"compile": "formatjs compile",
|
||||
"test": "jest"
|
||||
},
|
||||
"browserslist": "> 0.5%, not IE 11, not op_mini all, not and_uc >0",
|
||||
"browserslist": "> 0.5%, last 2 versions, Firefox ESR, not dead",
|
||||
"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",
|
||||
"umd/*.js",
|
||||
"umd/*.map",
|
||||
"audio/*.m4a",
|
||||
"css/base.css",
|
||||
"css/base.min.css",
|
||||
"img/*.png",
|
||||
@@ -36,7 +35,6 @@
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"service-worker.js",
|
||||
"firebase-init.js",
|
||||
"version.js"
|
||||
],
|
||||
"keywords": [
|
||||
@@ -48,45 +46,53 @@
|
||||
"web"
|
||||
],
|
||||
"locales": [
|
||||
"ar",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"ko",
|
||||
"ro",
|
||||
"ru",
|
||||
"th",
|
||||
"uk",
|
||||
"zh",
|
||||
"zh-TW"
|
||||
],
|
||||
"main": "umd/index.prod.js",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tinode/example-react-js/issues"
|
||||
"url": "https://github.com/tinode/webapp/issues"
|
||||
},
|
||||
"homepage": "https://github.com/tinode/chat",
|
||||
"email": "info@tinode.co",
|
||||
"author": "Tinode Authors <info@tinode.co>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"firebase": "^9.4.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"tinode-sdk": "^0.18.0-beta1"
|
||||
"firebase": "^11.0.0",
|
||||
"libphonenumber-js": "^1.10.40",
|
||||
"qrcodejs": "^1.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-intl": "^7.0.0",
|
||||
"tinode-sdk": "^0.25.0-beta1",
|
||||
"webm-duration-fix": "^1.0.4"
|
||||
},
|
||||
"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": "^9.0.2",
|
||||
"webpack": "^5.38.1",
|
||||
"webpack-cli": "^4.7.2"
|
||||
"@babel/core": "^7.22.10",
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"@babel/preset-react": "^7.22.5",
|
||||
"@formatjs/cli": "^6.1.3",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-formatjs": "^10.5.3",
|
||||
"browserslist": "^4.21.10",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"cssnano": "^7.0.6",
|
||||
"glob": "^11.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"postcss": "^8.4.27",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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).
|
||||
@@ -5,19 +5,17 @@
|
||||
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 BASE_LANG = 'en';
|
||||
const LANG_DIR = './src/i18n/';
|
||||
const LANG_PATTERN = './src/i18n/*.json';
|
||||
const LANG_PATTERN_ONE = './src/i18n/%s.json';
|
||||
const FINAL_FILE = './src/messages.json';
|
||||
const OUTPUT_PATTERN = './src/i18n.min/%s.json';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getLocaleFromFileName(filename) {
|
||||
const parts = filename.split('/');
|
||||
const parts = filename.split(path.sep);
|
||||
return parts[parts.length - 1].split('.json')[0];
|
||||
}
|
||||
|
||||
@@ -99,8 +97,9 @@ globSync(LANG_PATTERN)
|
||||
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
||||
});
|
||||
|
||||
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||
// Legacy: Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||
// while removing messages marked as missing or obsolete.
|
||||
// New way: Emit one file per language with all unnecessary data stripped.
|
||||
|
||||
// Read all translations.
|
||||
const messages = globSync(LANG_PATTERN)
|
||||
@@ -122,6 +121,7 @@ const messages = globSync(LANG_PATTERN)
|
||||
return { ...collection, ...descriptors };
|
||||
}, {});
|
||||
|
||||
// Write a file with all translations combined.
|
||||
mkdirpSync(path.dirname(FINAL_FILE));
|
||||
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages));
|
||||
// Write minified translation files, one per language.
|
||||
for (let lang in messages) {
|
||||
fs.writeFileSync(printf(OUTPUT_PATTERN, lang), JSON.stringify(messages[lang]));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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('https://cdn.jsdelivr.net/npm/firebase@9.14.0/firebase-app-compat.js');
|
||||
importScripts('https://cdn.jsdelivr.net/npm/firebase@9.14.0/firebase-messaging-compat.js');
|
||||
importScripts('firebase-init.js');
|
||||
importScripts('version.js');
|
||||
|
||||
@@ -18,10 +18,18 @@ const i18n = {
|
||||
'new_message': "New message",
|
||||
'new_chat': "New chat",
|
||||
},
|
||||
'fr': {
|
||||
'new_message': "Nouveau message",
|
||||
'new_chat': "Nouvelle conversation",
|
||||
},
|
||||
'es': {
|
||||
'new_message': "Nuevo mensaje",
|
||||
'new_chat': "Nueva conversación",
|
||||
},
|
||||
'it': {
|
||||
'new_message': "Nuovo Messaggio",
|
||||
'new_chat': "Nuova Chat",
|
||||
},
|
||||
'ko': {
|
||||
'new_message': "새로운 메시지",
|
||||
'new_chat': "새로운 채팅",
|
||||
@@ -34,6 +42,14 @@ const i18n = {
|
||||
'new_message': "Новое сообщение",
|
||||
'new_chat': "Новый чат",
|
||||
},
|
||||
'th': {
|
||||
'new_message': "ข้อความใหม่",
|
||||
'new_chat': "แชทใหม่",
|
||||
},
|
||||
'uk': {
|
||||
'new_message': "Нове повідомлення",
|
||||
'new_chat': "Новий чат",
|
||||
},
|
||||
'zh': {
|
||||
'new_message': "新讯息",
|
||||
'new_chat': "新聊天",
|
||||
@@ -41,9 +57,17 @@ const i18n = {
|
||||
'zh-TW': {
|
||||
'new_message': "新訊息",
|
||||
'new_chat': "新聊天",
|
||||
},
|
||||
'ar': {
|
||||
'new_message': "رسالة جديدة",
|
||||
'new_chat': "محادثة جديدة",
|
||||
}
|
||||
};
|
||||
self.i18nMessage = function(id) {
|
||||
|
||||
self.i18nMessage = function (id) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
// 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
|
||||
@@ -55,76 +79,84 @@ 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') {
|
||||
// TODO: if this is an 'msg', fetch the new message in the background.
|
||||
return;
|
||||
}
|
||||
|
||||
fbMessaging.onBackgroundMessage(payload => {
|
||||
// Notify webapp that a message was received.
|
||||
if (webAppChannel) {
|
||||
webAppChannel.postMessage(payload.data);
|
||||
}
|
||||
|
||||
if (payload.data.silent == 'true') {
|
||||
// No need to show anything.
|
||||
return;
|
||||
}
|
||||
|
||||
const titles = { 'msg': 'new_message', 'sub': 'new_chat' };
|
||||
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 || '', // TODO: content for 'sub' should be topic's or user's title.
|
||||
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
|
||||
badge: '/img/badge96.png',
|
||||
tag: payload.data.topic || undefined,
|
||||
data: {
|
||||
topic: payload.data.topic
|
||||
}
|
||||
};
|
||||
return self.registration.showNotification(title, options);
|
||||
const title = payload.data.title || self.i18nMessage(titles[pushType]);
|
||||
if (title) {
|
||||
const options = {
|
||||
body: payload.data.content || '', // TODO: content for 'sub' should be topic's or user's title.
|
||||
icon: '/img/logo96.png', // TODO: use topic's or user's avatar (would have to fetch for 'sub', read from db for 'msg').
|
||||
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.addEventListener('install', _ => {
|
||||
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 data = event.notification.data;
|
||||
event.notification.close();
|
||||
if (!data) {
|
||||
console.info("Missing 'data' in notification", event.notification);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlHash = '#/' + notification.data.topic;
|
||||
const urlHash = '#/' + 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];
|
||||
})
|
||||
.then(windowClients => {
|
||||
let anyClient = null;
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const url = new URL(windowClients[i].url);
|
||||
if (url.hash.includes(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);
|
||||
// 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 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);
|
||||
}));
|
||||
return clients.openWindow(url);
|
||||
}));
|
||||
});
|
||||
|
||||
// This is needed for 'Add to Home Screen'.
|
||||
@@ -133,13 +165,13 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith((async () => {
|
||||
event.respondWith((async _ => {
|
||||
// Try to find the response in the cache.
|
||||
const cache = await caches.open(PACKAGE_VERSION);
|
||||
|
||||
const reqUrl = new URL(event.request.url);
|
||||
// Using ignoreSearch=true to read cached images and docs despite different auth signatures.
|
||||
const cachedResponse = await cache.match(event.request, {ignoreSearch: (self.location.origin == reqUrl.origin)});
|
||||
const cachedResponse = await cache.match(event.request, { ignoreSearch: (self.location.origin == reqUrl.origin) });
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PACKAGE_VERSION } from './version.js';
|
||||
|
||||
// Name of this application, used in the User-Agent.
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.17');
|
||||
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.24');
|
||||
|
||||
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
||||
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||
@@ -16,11 +16,9 @@ export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
||||
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;
|
||||
export const KEYPRESS_DELAY = 3_000;
|
||||
// Delay before sending a read notification, milliseconds.
|
||||
export const READ_DELAY = 1000;
|
||||
export const READ_DELAY = 1_500;
|
||||
|
||||
// The default shortest allowed tag length. Matches the value on the server.
|
||||
export const MIN_TAG_LENGTH = 2;
|
||||
@@ -55,6 +53,9 @@ export const MAX_AVATAR_BYTES = 4096;
|
||||
// Size of the broken_image shown in MessagesView
|
||||
export const BROKEN_IMAGE_SIZE = 32;
|
||||
|
||||
// Size of video preview when dimensions are missing.
|
||||
export const NO_DIMENSIONS_VIDEO = 128;
|
||||
|
||||
// Number of chat messages to fetch in one call.
|
||||
export const MESSAGES_PAGE = 24;
|
||||
|
||||
@@ -64,7 +65,7 @@ export const MESSAGES_PAGE = 24;
|
||||
// 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;
|
||||
export const MAX_INBAND_ATTACHMENT_SIZE = 262_144;
|
||||
|
||||
// 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.
|
||||
@@ -78,8 +79,13 @@ export const MAX_IMAGE_DIM = 1024;
|
||||
// Linear dimensions of image preview: shrink image under this size for previews.
|
||||
export const IMAGE_PREVIEW_DIM = 64;
|
||||
|
||||
// Linear dimensions of video screencapture: shrink image under this size for previews.
|
||||
export const VIDEO_PREVIEW_DIM = 96;
|
||||
|
||||
// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.
|
||||
export const IMAGE_THUMBNAIL_DIM = 36;
|
||||
// Width of video thumbnail: allow it to be wider, but not taller. Use IMAGE_THUMBNAIL_DIM for height.
|
||||
export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
// Maximum number of online users to be shown in a topic title bar. Others will be
|
||||
// hidden under "+X more"
|
||||
@@ -89,6 +95,8 @@ export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
// Maximum length of topic description.
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
|
||||
// Maximum length of user name when displaying a video call.
|
||||
export const MAX_PEER_TITLE_LENGTH = 24;
|
||||
|
||||
// Length of message previews in chat list, in characters.
|
||||
export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
@@ -96,13 +104,56 @@ export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
// Length of a quote in quoted reply (for outgoing messages).
|
||||
export const QUOTED_REPLY_LENGTH = 30;
|
||||
// Length of a preview of a forwarded message.
|
||||
export const FORWARDED_PREVIEW_LENGTH = 96;
|
||||
export const FORWARDED_PREVIEW_LENGTH = 84;
|
||||
// Length of preview for a message being edited.
|
||||
export const EDIT_PREVIEW_LENGTH = 48;
|
||||
|
||||
// Minimum duration of an audio recording in milliseconds (2 sec).
|
||||
export const MIN_DURATION = 2_000;
|
||||
// Maximum duration of an audio recording in milliseconds (10 min).
|
||||
export const MAX_DURATION = 600_000;
|
||||
|
||||
// Link for "Contact Us".
|
||||
export const LINK_CONTACT_US = 'email:support@tinode.co';
|
||||
export const LINK_CONTACT_US = 'mailto: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';
|
||||
|
||||
// When user starts a P2P topic, automatic subscription does not
|
||||
// happen until the first message. Setting this option to true enables
|
||||
// immediate subscription.
|
||||
export const IMMEDIATE_P2P_SUBSCRIPTION = false;
|
||||
|
||||
// URL schemes permitted to appear in links (i.e. no javascript: scheme).
|
||||
export const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];
|
||||
|
||||
// Size of the QR code in the app.
|
||||
export const QRCODE_SIZE = 128;
|
||||
|
||||
// How much time has to pass to consider computer awoken from sleep (80 sec).
|
||||
// Chrome throttles ticker events in inactive tabs to once per minute. This
|
||||
// value ensures that these once-a-minute events do not trigger a reconnect.
|
||||
export const WAKE_UP_TIMEOUT = 80000;
|
||||
// Timer interval between checks if computer woke up from sleep (1 sec).
|
||||
export const WAKE_UP_TICK = 1000;
|
||||
|
||||
// Swipe distance to register as a gesture, in pixels.
|
||||
export const MIN_SWIPE_DISTANCE = REM_SIZE * 3;
|
||||
|
||||
// Self-topic avatar as an SVG icon encoded as data URI
|
||||
export const SELF_AVATAR_URI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHZpZXdCb3g9IjAgMCAxNyAxNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMy41NjIgMXYxNS40NTlsNC42ODYtMy4yNyA0Ljc1MiAzLjI2di0xNS40NDloLTkuNDM4ek0xMiAxNC41NTFsLTMuNzU2LTIuNTc4LTMuNjgxIDIuNTY4di0xMi41NDFoNy40Mzd2MTIuNTUxeiIgZmlsbD0iIzU1NTU1NSIgLz48L3N2Zz4=' // eslint-disable-line max-len
|
||||
|
||||
// Toast display duration, milliseconds.
|
||||
export const TOAST_DURATION = 3_000;
|
||||
|
||||
// Initial/default color scheme.
|
||||
export const DEFAULT_COLOR_SCHEME = 'light dark';
|
||||
|
||||
// Default text size in points.
|
||||
export const DEFAULT_TEXT_SIZE = 10;
|
||||
|
||||
// MIME type for the legacy Drafty form-response data.
|
||||
export const DRAFTY_FR_MIME_TYPE_LEGACY = 'application/json'; // Remove in 2026.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Video call states.
|
||||
export const CALL_STATE_NONE = 0;
|
||||
export const CALL_STATE_OUTGOING_INITATED = 1;
|
||||
export const CALL_STATE_INCOMING_RECEIVED = 2;
|
||||
export const CALL_STATE_IN_PROGRESS = 3;
|
||||
|
||||
// Video call message headers representing call state.
|
||||
export const CALL_HEAD_STARTED = 'started';
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "Hinzufügen",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "Mitglieder hinzufügen",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "Inhalt gelöscht",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "herunterladen",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Dateianhang",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formular:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Bild",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "Geben Sie den Bestätigungscode ein, der per {method} geschickt wurde:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "Passwort per E-Mail wiederherstellen:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "Kontakte:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "Kontakte",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "Zuletzt gesehen",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "Liste: email:lisa@beispiel.de, tel:17025550003...",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1229,13 +1215,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "bestätigen",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "Beschreibung (optional)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "Eine E-Mail wurde an {email} gesendet. Folgen Sie den Anweisungen in der E-Mail, um Ihr Passwort zurückzusetzen.",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "Eine E-Mail mit Sicherheitscode wurde gesendet.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "Neues Kennwort",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Nicht unterstützt",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "Eingehender Anruf",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "Ausgehender Anruf",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "Du",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "Videoanruf",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "Du redest schon!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "abgebrochen",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "verpasster",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "abgelehnt",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "getrennt",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "Verwenden Sie eine sichere Verbindung",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "Erfolgreich bestätigt",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Bearbeiten",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Bearbeitung",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", bearbeitet",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Bild hinzufügen",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Datei anhängen",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Aufnahme löschen",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Wiedergabe pausieren",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Aufnahme abspielen",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Sprachnachricht aufnehmen",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Wiedergabe fortsetzen",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Nachricht senden",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Datei hierher ziehen",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Videoaufnahme",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "in Arbeit",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "Anruf",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Das Format dieses Videos wird nicht erkannt",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "Laden...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "Ich habe Code",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "SMS zum Zurücksetzen des Passworts senden",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "Mobiltelefonnummer",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "Eine SMS mit einem Sicherheitscode wurde gesendet.",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "Handynummer erforderlich",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "Aktuelle E-Mail",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "Aktuelle Telefonnummer",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "Neue E-Mail",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "Neue Telefonnummer",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "E-Mail ändern",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "Telefonnummer ändern",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "besetzt",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "Audioaufnahme konnte nicht initialisiert werden",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "Anpinnen",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "Lösen",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Passwort erfolgreich zurückgesetzt",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "QR-Code scannen",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notizen, Nachrichten, Links, Dateien für die Nachwelt gespeichert",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Gespeicherte Nachrichten",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "Nachricht nicht gefunden",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "Nachricht gelöscht",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (optional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(bereits vergeben)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(ungültig)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Land auswählen",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "In die Zwischenablage kopiert",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "Dunkel",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "Hell",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "Systemstandard",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "Thema:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "Textgröße:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "Online-Status und Tipp-Indikatoren verbergen.",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "Senden mit {key}",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "Drücken Sie Enter für eine neue Zeile",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "Senden mit Enter",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "Drücken Sie Shift + Enter für eine neue Zeile",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "Allgemein",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "Tastatur:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "Anheften",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "Loslösen",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, one {{count, number} Abonnent} other {{count, number} Abonnenten}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "Mitglieder:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "Abonnenten:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, one {{count, number} Mitglied} other {{count, number} Mitglieder}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"action_more": {
|
||||
"translation": "More",
|
||||
"defaultMessage": "More",
|
||||
"description": "Action for showing more content",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"action_report_chat": {
|
||||
"translation": "Report Conversation",
|
||||
"defaultMessage": "Report Conversation",
|
||||
@@ -104,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "Add another",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "Add members",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -314,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "content deleted",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "download",
|
||||
"defaultMessage": "download",
|
||||
@@ -331,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Attachment",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Form:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Picture",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -371,8 +350,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"translation": "Confirmation code",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -461,13 +440,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_default_access": {
|
||||
"translation": "Default access mode:",
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Section title",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"label_default_access_mode": {
|
||||
"translation": "Default access mode:",
|
||||
"defaultMessage": "Default access mode:",
|
||||
@@ -554,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -600,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "Contacts:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "Contacts",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -643,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "Last seen",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1028,18 +1000,11 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "General",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for TopicCommonView.",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"sidepanel_title_acc_notifications": {
|
||||
"translation": "Notifications",
|
||||
"defaultMessage": "Notifications",
|
||||
@@ -1047,13 +1012,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_security": {
|
||||
"translation": "Security",
|
||||
"defaultMessage": "Security",
|
||||
"description": "Sidepanel title for AccSecurityView.",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"sidepanel_title_acc_support": {
|
||||
"translation": "Support",
|
||||
"defaultMessage": "Support",
|
||||
@@ -1089,13 +1047,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_crop": {
|
||||
"translation": "Drag to Adjust",
|
||||
"defaultMessage": "Drag to Adjust",
|
||||
"description": "Sidepanel title for AvatarCropView.",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"sidepanel_title_login": {
|
||||
"translation": "Sign In",
|
||||
"defaultMessage": "Sign In",
|
||||
@@ -1187,13 +1138,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"title_info": {
|
||||
"translation": "Info",
|
||||
"defaultMessage": "Info",
|
||||
"description": "Title for InfoView",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"title_manage_tags": {
|
||||
"translation": "Manage",
|
||||
"defaultMessage": "Manage",
|
||||
@@ -1271,13 +1215,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "confirm",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "Optional description",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1299,20 +1236,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"panel_title_acc_general": {
|
||||
"translation": "General",
|
||||
"defaultMessage": "General",
|
||||
"description": "Title for TopicCommonView.",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"panel_title_acc_security": {
|
||||
"translation": "Security",
|
||||
"defaultMessage": "Security",
|
||||
"description": "Title for TopicSecirityView and AccSecurityView.",
|
||||
"missing": false,
|
||||
"obsolete": true
|
||||
},
|
||||
"panel_title_crop": {
|
||||
"translation": "Drag to Adjust",
|
||||
"defaultMessage": "Drag to Adjust",
|
||||
@@ -1377,8 +1300,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "An email with security code has been sent.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1409,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "New password",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Unsupported",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "Incoming call",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "Outgoing call",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "You",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "Video call",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "You already in an ongoing call!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "cancelled",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "missed",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "declined",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "disconnected",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "Use secure connection",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "Confirmed successfully",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Edit",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Editing",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", edited",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Add image",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Attach file",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Delete",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Pause",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Play",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Record voice message",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Resume",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Send message",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Drag file here",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Video recording",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "in progress",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "Call",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Format of this video is not recognized",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "Loading...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "I have code",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "Send a password reset SMS:",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "Mobile phone number",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "A text message with security code has been sent.",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "Mobile phone number required",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "Current email",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "Current phone number",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "New email",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "New phone number",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "Change email",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "Change phone number",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "busy",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "Failed to initialize audio recording",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "Pin",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "Unpin",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Password reset successfully",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Scan QR Code",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notes, messages, links, files saved for posterity",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Saved messages",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "message not found",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "message deleted",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (optional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(already taken)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(invalid)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Select country",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copied to clipboard",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "Dark",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "Light",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "System default",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "Theme:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "Text size:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "Hide your online status and typing indicators.",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "Send on {key}",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "Press Enter for new line",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "Send on Enter",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "Press Shift + Enter for new line",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "General",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "Keyboard:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "Pin",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "Unpin",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, one {{count, number} subscriber} other {{count, number} subscribers}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "Members:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "Subscribers:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, one {{count, number} member} other {{count, number} members}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "Añadir contacto",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "Añadir miembros",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "este mensaje fue eliminado",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "descargar",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Archivo",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formulario:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Imagen",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "Introduzca el código de confirmación enviado a tu {method}:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "Enviar un correo electrónico de restablecimiento de contraseña:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "Contactos:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "Contactos",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "Últ. vez",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "Ej. email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1222,13 +1208,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "confirmar",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "Descripción (opcional)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "Se ha enviado un correo electrónico a {email}. Siga las instrucciones del correo electrónico para restablecer su contraseña.",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "Se ha enviado un correo electrónico con el código de seguridad.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "Nueva contraseña",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "No soportado",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "Llamada entrante",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "Llamada saliente",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "Tú",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "Videollamada",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "¡Ya estás hablando!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "cancelada",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "perdida",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "rechazada",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "desconectada",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "Usar conexión segura",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "Confirmado con éxito",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Editar",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Edición",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", editado",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Añadir imagen",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Adjuntar archivo",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Eliminar grabación",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Pausar reproducción",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Reproducir grabación",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Grabar mensaje de voz",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Reanudar la reproducción",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Enviar mensaje",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Arrastra el archivo aquí",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Grabación de vídeo",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "en progreso",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "Llamada",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "No se reconoce el formato de este video",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "Cargando...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "Tengo código",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "Enviar SMS para restablecer la contraseña",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "Número de teléfono móvil",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "Se ha enviado un mensaje de texto con un código de seguridad.",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "Número de teléfono móvil requerido",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "Correo electrónico actual",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "Número de teléfono actual",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "Nuevo correo electrónico",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "Nuevo número de teléfono",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "Cambiar correo electrónico",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "Cambiar número de teléfono",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "ocupado",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "Fijar",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "Desanclar",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "Error al inicializar la grabación de audio",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Restablecimiento de contraseña con éxito",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Escanear código QR",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Notas, mensajes, enlaces, archivos guardados para la posteridad",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Mensajes guardados",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "Mensaje no encontrado",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "Mensaje eliminado",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (opcional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(ya tomado)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(inválido)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Seleccionar país",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copiado al portapapeles",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "Oscuro",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "Claro",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "Predeterminado del sistema",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "Tema:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "Tamaño del texto:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "Ocultar tu estado en línea e indicadores de escritura.",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "Enviar con {key}",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "Presiona Enter para nueva línea",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "Enviar con Enter",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "Presiona Shift + Enter para nueva línea",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "General",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "Teclado:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "Fijar",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "Desfijar",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, one {{count, number} suscriptor} other {{count, number} suscriptores}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "Miembros:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "Suscriptores:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, one {{count, number} miembro} other {{count, number} miembros}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,22 @@
|
||||
"defaultMessage": "Report Conversation",
|
||||
"description": "Flat button [Report Group]"
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken"
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias"
|
||||
},
|
||||
"alias_invalid": {
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias"
|
||||
},
|
||||
"already_in_call": {
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one"
|
||||
},
|
||||
"archived_contacts": {
|
||||
"defaultMessage": "Archived contacts ({count})",
|
||||
"description": "Label for archived chats"
|
||||
@@ -55,10 +71,6 @@
|
||||
"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)"
|
||||
@@ -119,10 +131,54 @@
|
||||
"defaultMessage": "Update",
|
||||
"description": "Button [Update]"
|
||||
},
|
||||
"call_busy": {
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line"
|
||||
},
|
||||
"call_cancelled": {
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call"
|
||||
},
|
||||
"call_declined": {
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call"
|
||||
},
|
||||
"call_disconnected": {
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call"
|
||||
},
|
||||
"call_in_progress": {
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress"
|
||||
},
|
||||
"call_missed": {
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call"
|
||||
},
|
||||
"calls_incoming": {
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label"
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label"
|
||||
},
|
||||
"calls_you_label": {
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen"
|
||||
},
|
||||
"cannot_initiate_file_upload": {
|
||||
"defaultMessage": "Cannot initiate file upload.",
|
||||
"description": "Generic error messagewhen attachment fails"
|
||||
},
|
||||
"change_email": {
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email"
|
||||
},
|
||||
"change_phone": {
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number"
|
||||
},
|
||||
"channel": {
|
||||
"defaultMessage": "channel",
|
||||
"description": "Subtitle shown for channels in MessagesView instead of last seen"
|
||||
@@ -155,6 +211,18 @@
|
||||
"defaultMessage": "Code does not match",
|
||||
"description": "Error message when the credential validation code is incorrect."
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema"
|
||||
},
|
||||
"color_schema_light": {
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema"
|
||||
},
|
||||
"color_schema_system": {
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema"
|
||||
},
|
||||
"contacts_not_found": {
|
||||
"defaultMessage": "You have no chats<br />¯∖_(ツ)_/¯",
|
||||
"description": "HTML message shown in ContactList when no contacts are found"
|
||||
@@ -163,10 +231,22 @@
|
||||
"defaultMessage": "No contacts match ''{query}''",
|
||||
"description": "Shown in ContactsView when search returned no results"
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated."
|
||||
},
|
||||
"credential_email_prompt": {
|
||||
"defaultMessage": "Your registration email",
|
||||
"description": "Placeholder for entering email"
|
||||
},
|
||||
"current_email": {
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email"
|
||||
},
|
||||
"current_phone": {
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone"
|
||||
},
|
||||
"delete_account": {
|
||||
"defaultMessage": "Delete account",
|
||||
"description": "Title for delete account warning"
|
||||
@@ -179,10 +259,6 @@
|
||||
"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"
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"defaultMessage": "Description (optional)",
|
||||
"description": "Placeholder for editing topic description"
|
||||
@@ -193,15 +269,31 @@
|
||||
},
|
||||
"drafty_attachment": {
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview"
|
||||
"description": "Comment for attachment in Drafty"
|
||||
},
|
||||
"drafty_form": {
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview"
|
||||
"description": "Comment for form in Drafty"
|
||||
},
|
||||
"drafty_image": {
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview"
|
||||
"description": "Comment for embedded images in Drafty"
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty"
|
||||
},
|
||||
"drafty_video": {
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty"
|
||||
},
|
||||
"drag_file": {
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner"
|
||||
},
|
||||
"editing_message": {
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview"
|
||||
},
|
||||
"email_dative": {
|
||||
"defaultMessage": "email",
|
||||
@@ -216,13 +308,17 @@
|
||||
"description": "Call to action to enable peer's messaging"
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code"
|
||||
},
|
||||
"error_invalid_id": {
|
||||
"defaultMessage": "Invalid ID",
|
||||
"description": "Error message"
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available"
|
||||
},
|
||||
"file_attachment_too_large": {
|
||||
"defaultMessage": "The file size {size} exceeds the {limit} limit.",
|
||||
"description": "Error message when attachment is too large"
|
||||
@@ -254,10 +350,46 @@
|
||||
"defaultMessage": "Group or User ID",
|
||||
"description": "Prompt for entering user or group ID"
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image"
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file"
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio"
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback"
|
||||
},
|
||||
"icon_title_play": {
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback"
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message"
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback"
|
||||
},
|
||||
"icon_title_send": {
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message"
|
||||
},
|
||||
"image_caption_prompt": {
|
||||
"defaultMessage": "Image caption",
|
||||
"description": "Prompt in SendMessage for attached image"
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle"
|
||||
},
|
||||
"invalid_content": {
|
||||
"defaultMessage": "invalid content",
|
||||
"description": "Shown when the message is unreadable"
|
||||
@@ -266,10 +398,22 @@
|
||||
"defaultMessage": "Invalid security token",
|
||||
"description": "Error message when resetting password."
|
||||
},
|
||||
"label_alias": {
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias"
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias"
|
||||
},
|
||||
"label_client": {
|
||||
"defaultMessage": "Client:",
|
||||
"description": "Label for a client version"
|
||||
},
|
||||
"label_color_schema": {
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings"
|
||||
},
|
||||
"label_content_type": {
|
||||
"defaultMessage": "Content type:",
|
||||
"description": "Label for file content type (mime)"
|
||||
@@ -294,6 +438,14 @@
|
||||
"defaultMessage": "Incognito mode:",
|
||||
"description": "Label for incognito mode toggle"
|
||||
},
|
||||
"label_keyboard": {
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings"
|
||||
},
|
||||
"label_member_count": {
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members"
|
||||
},
|
||||
"label_message_sound": {
|
||||
"defaultMessage": "Message sound:",
|
||||
"description": "Label for message sounds toggle"
|
||||
@@ -302,6 +454,10 @@
|
||||
"defaultMessage": "Muted:",
|
||||
"description": "Label for Muting/unmuting the topic"
|
||||
},
|
||||
"label_new_password": {
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password"
|
||||
},
|
||||
"label_other_user": {
|
||||
"defaultMessage": "Other",
|
||||
"description": "Label for the other user when the user is unnamed"
|
||||
@@ -327,9 +483,13 @@
|
||||
"description": "Label for push notifications switch"
|
||||
},
|
||||
"label_reset_password": {
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field"
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone"
|
||||
},
|
||||
"label_sdk": {
|
||||
"defaultMessage": "SDK:"
|
||||
},
|
||||
@@ -348,6 +508,14 @@
|
||||
"defaultMessage": "Size:",
|
||||
"description": "Label for file size"
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers"
|
||||
},
|
||||
"label_text_size": {
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size"
|
||||
},
|
||||
"label_topic_name": {
|
||||
"defaultMessage": "Name",
|
||||
"description": "Label for editing topic name"
|
||||
@@ -356,8 +524,12 @@
|
||||
"defaultMessage": "Archived:",
|
||||
"description": "Label for unarchiving the topic"
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView"
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"defaultMessage": "Contacts:",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts"
|
||||
},
|
||||
"label_user_id": {
|
||||
@@ -381,7 +553,7 @@
|
||||
"description": "Label for current user permissions"
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online"
|
||||
},
|
||||
"leave_chat_warning": {
|
||||
@@ -400,14 +572,26 @@
|
||||
"defaultMessage": "Terms of Service",
|
||||
"description": "Ancor text for terms of service link"
|
||||
},
|
||||
"loading_note": {
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading"
|
||||
},
|
||||
"login_prompt": {
|
||||
"defaultMessage": "Login",
|
||||
"description": "Placeholer for username/login"
|
||||
},
|
||||
"member_count": {
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members"
|
||||
},
|
||||
"menu_item_archive_topic": {
|
||||
"defaultMessage": "Archive",
|
||||
"description": "Move topic from the list of active chats to archive"
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call"
|
||||
},
|
||||
"menu_item_block": {
|
||||
"defaultMessage": "Block",
|
||||
"description": "Block topic or user"
|
||||
@@ -432,6 +616,10 @@
|
||||
"defaultMessage": "Delete",
|
||||
"description": "Delete entire topic"
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message"
|
||||
},
|
||||
"menu_item_edit_permissions": {
|
||||
"defaultMessage": "Edit permissions",
|
||||
"description": "Menu item [Edit permissions]"
|
||||
@@ -472,6 +660,22 @@
|
||||
"defaultMessage": "Unmute",
|
||||
"description": "Turn notifications on"
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call"
|
||||
},
|
||||
"message_deleted": {
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted"
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited"
|
||||
},
|
||||
"message_not_found": {
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found"
|
||||
},
|
||||
"message_sending": {
|
||||
"defaultMessage": "sending...",
|
||||
"description": "Message being sent, in place of time stamp"
|
||||
@@ -488,10 +692,22 @@
|
||||
"defaultMessage": "Messaging disabled",
|
||||
"description": "Prompt in SendMessage in read-only topic"
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message"
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number"
|
||||
},
|
||||
"more_online_members": {
|
||||
"defaultMessage": "+{overflow} more",
|
||||
"description": "Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
|
||||
},
|
||||
"new_email": {
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email"
|
||||
},
|
||||
"new_message_prompt": {
|
||||
"defaultMessage": "New message",
|
||||
"description": "Prompt in send message field"
|
||||
@@ -500,6 +716,10 @@
|
||||
"defaultMessage": "Enter new password",
|
||||
"description": "Placeholder for entering new password"
|
||||
},
|
||||
"new_phone_number": {
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number"
|
||||
},
|
||||
"no_connection": {
|
||||
"defaultMessage": "No connection",
|
||||
"description": "Warning that the user is offline."
|
||||
@@ -536,14 +756,26 @@
|
||||
"defaultMessage": "Security",
|
||||
"description": "Title for TopicSecirity and AccSecurity."
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code"
|
||||
},
|
||||
"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.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent"
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent"
|
||||
},
|
||||
"password_reset_success": {
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset."
|
||||
},
|
||||
"password_unchanged_prompt": {
|
||||
"defaultMessage": "Unchanged",
|
||||
"description": "Message in editor while password is unchanged"
|
||||
@@ -600,6 +832,14 @@
|
||||
"defaultMessage": "phone",
|
||||
"description": "Dative case of 'phone', i.e. 'phone' in 'by phone'"
|
||||
},
|
||||
"pin_chat": {
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top."
|
||||
},
|
||||
"pin_message": {
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top."
|
||||
},
|
||||
"private_editing_placeholder": {
|
||||
"defaultMessage": "Visible to you only",
|
||||
"description": "Placeholder for editing 'private'"
|
||||
@@ -632,6 +872,10 @@
|
||||
"defaultMessage": "save",
|
||||
"description": "Call to save an attachment"
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code"
|
||||
},
|
||||
"search_for_contacts": {
|
||||
"defaultMessage": "Use search to find contacts",
|
||||
"description": "Text shown in contacts view when user entered no search query."
|
||||
@@ -641,9 +885,41 @@
|
||||
"description": "Text shown in contacts view when query returned no results."
|
||||
},
|
||||
"search_placeholder": {
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field"
|
||||
},
|
||||
"select_country": {
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector"
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI"
|
||||
},
|
||||
"self_topic_name": {
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI"
|
||||
},
|
||||
"send_command": {
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter"
|
||||
},
|
||||
"send_command_explained": {
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled"
|
||||
},
|
||||
"send_plain": {
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter"
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled"
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView."
|
||||
},
|
||||
"sidepanel_title_acc_notifications": {
|
||||
"defaultMessage": "Notifications",
|
||||
"description": "Sidepanel title for AccNotificationsView."
|
||||
@@ -692,6 +968,10 @@
|
||||
"defaultMessage": "Stay logged in",
|
||||
"description": "Label for a checkbox"
|
||||
},
|
||||
"subscriber_count": {
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers"
|
||||
},
|
||||
"tabtitle_find_user": {
|
||||
"defaultMessage": "find",
|
||||
"description": "Tab title Find"
|
||||
@@ -712,6 +992,10 @@
|
||||
"defaultMessage": "No tags defined. Add some.",
|
||||
"description": ""
|
||||
},
|
||||
"text_copied": {
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard"
|
||||
},
|
||||
"title_all_contacts": {
|
||||
"defaultMessage": "All Contacts",
|
||||
"description": "Section title [All Contacts]"
|
||||
@@ -756,6 +1040,18 @@
|
||||
"defaultMessage": "Unnamed",
|
||||
"description": "Title shown when the topic has no name"
|
||||
},
|
||||
"unpin_chat": {
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat."
|
||||
},
|
||||
"unpin_message": {
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message."
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid"
|
||||
},
|
||||
"update_available": {
|
||||
"defaultMessage": "Update available.",
|
||||
"description": "Message shown when an app update is available."
|
||||
@@ -767,9 +1063,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "다른항목추가",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "회원추가",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "내용이 삭제되었습니다",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "다운로드",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "부착",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "형태:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "이미지",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "{방법}으로보낸확인코드를입력하십시오:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "비밀번호재설정이메일보내기: ",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "연락처: ",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "연락처",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "마지막으로본",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "email:alice@example.com,tel:17025550003...와같은목록",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1222,13 +1208,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "확인",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "설명(선택 사항)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "이메일이 {email}(으)로 전송되었습니다. 이메일의 지침에 따라 비밀번호를 재설정하세요.",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "보안 코드가 포함된 이메일이 전송되었습니다.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "새 비밀번호",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "지원되지 않음",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "수신 전화",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "발신 전화",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "너",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "영상 통화",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "당신은 이미 말하고 있습니다!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "취소 된",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "부재중",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "통화 거부",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "연결 끊김",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "보안 연결 사용",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "성공적으로 확인됨",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "편집",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "교정하다",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": " 수정됨",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "이미지 추가",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "파일을 첨부",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "녹음 삭제",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "재생 일시 중지",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "녹음 재생",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "음성 메시지 녹음",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "재생 재개",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "문자 보내",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "여기로 파일 드래그",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "녹화",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "통화 중",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "전화",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "이 동영상의 형식을 인식할 수 없습니다.",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "로드 중...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "코드가 있습니다",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "비밀번호 재설정을 위해 SMS 보내기",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "휴대전화 번호",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "보안 코드가 포함된 문자 메시지가 전송되었습니다.",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "휴대전화 번호 필요",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "현재 이메일",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "현재 전화번호",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "새 이메일 주소",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "새 전화번호",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "이메일 변경",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "전화번호 변경",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "바쁘다",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "고정",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "고정 해제",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "오디오 녹음 초기화 실패",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "비밀번호 재설정 성공",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "QR 코드 스캔",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "후세를 위해 저장된 메모, 메시지, 링크, 파일",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "저장된 메시지",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "메시지를 찾을 수 없음",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "메시지 삭제됨",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "별칭(선택 사항)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "별칭:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(이미 취함)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(잘못된)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "별칭",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "국가 선택",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "클립보드에 복사됨",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "어둡게",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "밝게",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "시스템 기본값",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "테마:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "텍스트 크기:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "온라인 상태와 입력 표시기를 숨깁니다.",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "{key}로 전송",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "새 줄로 이동하려면 Enter를 누르세요",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "Enter로 전송",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "새 줄로 이동하려면 Shift + Enter를 누르세요",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "일반",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "키보드:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "고정",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "고정 해제",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, other {구독자 {count, number}명}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "멤버:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "구독자:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, other {멤버 {count, number}명}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "Adaugă",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "Adaugă persoane",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "conținut șters",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "download",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Atasament",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Formă:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Imagine",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "Introu codul de confirmare trimis pe {method}:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "Resetează parola:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "Contacte:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "Contacte",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "Văzut ultima dată",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "Caută după e-mail sau număr telefon...",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1222,13 +1208,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "confirmă",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "Descriere (opțional)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "Un e-mail a fost trimis către {email}. Urmați instrucțiunile din e-mail pentru a vă reseta parola.",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "A fost trimis un e-mail cu cod de securitate.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "Parolă Nouă",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Neacceptat",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "Apel primit",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "Apel efectuat",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "Tu",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "Apel video",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "Deja vorbesti!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "anulat",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "pierdut",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "refuzat",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "deconectat",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "Utilizați conexiune securizată",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "Confirmat cu succes",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Editează",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Editare",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", editat",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Adauga imagine",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Ataseaza fisier",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Ștergeți înregistrarea",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Întrerupeți redarea",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Redați înregistrarea",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Înregistrați mesajul vocal",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Reluați redarea",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Trimite mesaj",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Trageți fișierul aici",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Înregistrare video",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "în curs",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "Apel",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Formatul acestui videoclip nu este recunoscut",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "Se încarcă...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "Am cod",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "Trimite SMS pentru a reseta parola",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "Număr de telefon mobil",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "A fost trimis un mesaj text cu cod de securitate.",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "Este necesar un număr de telefon mobil",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "E-mail curent",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "Număr de telefon actual",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "E-mail nou",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "Număr de telefon nou",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "Schimbați adresa de e-mail",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "Schimbați numărul de telefon",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "ocupat",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "Fixați",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "Anulați fixarea",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "Inițializarea înregistrării audio a eșuat",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Resetarea parolei cu succes",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Scanați codul QR",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Note, mesaje, linkuri, fișiere salvate pentru posteritate",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Mesaje salvate",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "mesajul nu a fost găsit",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "mesaj șters",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Alias (opțional)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Alias:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(deja luat)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(nevalid)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Alias",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Selectați țara",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Copiat în clipboard",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "Întunecat",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "Luminos",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "Implicit sistem",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "Temă:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "Dimensiune text:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "Ascunde statusul tău online și indicatorii de tastare.",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "Trimite cu {key}",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "Apasă Enter pentru linie nouă",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "Trimite cu Enter",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "Apasă Shift + Enter pentru linie nouă",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "General",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "Tastatură:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "Fixează",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "Anulează fixarea",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, one {{count, number} abonat} few {{count, number} abonați} other {{count, number} de abonați}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "Membri:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "Abonați:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, one {{count, number} membru} few {{count, number} membri} other {{count, number} de membri}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "Добавить",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "Добавить",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -140,7 +133,7 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"button_edit": {
|
||||
"translation": "Редактировать",
|
||||
"translation": "Изменить",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Call to action [Edit]",
|
||||
"missing": false,
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "удалено",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "скачать",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "Аттачмент",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "Форма:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "Картинка",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "Код подтверждения, полученный по {method}:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "Отправить емейл для смены пароля:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "Конакты:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "Конакты",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "Был активен",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "Список, напр. email:alice@example.com, tel:+17025550003...",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1125,7 +1111,7 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"tags_not_found": {
|
||||
"translation": "Тегов нет. Добавьте",
|
||||
"translation": "Тегов нет. Добавьте.",
|
||||
"defaultMessage": "No tags defined. Add some.",
|
||||
"description": "",
|
||||
"missing": false,
|
||||
@@ -1222,13 +1208,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "подтвердить",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "Описание (не обязательно)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "Сообщение было отправлено на адрес {email}. Следуйте инструкциям в сообщении, чтобы изменить пароль.",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "Сообщение было отправлено на указанный email.",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "Новый пароль",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "Не поддерживается",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "Входящий звонок",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "Исходящий звонок",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "Вы",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "Видеозвонок",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "Вы уже звоните кому-то!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "отменен",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "пропущен",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "отклонен",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "разъединен",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "Безопасное соединение",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "Подтверждено успешно",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "Изменить",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "Редактирование",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ", изменено",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "Добавить изображение",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "Прикрепить файл",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "Удалить запись",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "Пауза воспроизведения",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "Воспроизвести запись",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "Записать голосовое сообщение",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "Продолжить",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "Отправить сообщение",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "Перетащите файл сюда",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "Видеозапись",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "в процессе",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "Позвонить",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "Неизвестный формат видео файла",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "Загружается...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "У меня есть код",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "Отправить SMS для сброса пароля",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "Номер мобильного телефона",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "SMS с защитным кодом отправлен.",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "Номер мобильного телефона",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "Текущий e-mail",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "Текущий номер телефона",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "Новый e-mail",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "Новый номер телефона",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "Изменить e-mail",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "Изменить номер телефона",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "занято",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "Закрепить",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "Открепить",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "Не удалось инициализировать аудиозапись",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "Пароль успешно изменен",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "Отсканируйте QR код",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "Заметки, сообщения, ссылки, файлы, сохраненные для потомков",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "Сохраненные сообщения",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "сообщение не найдено",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "сообщение удалено",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "Псевдоним (необязательно)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "Псевдоним:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(уже занято)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(некорректно)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "Псевдоним",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "Выберите страну",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "Скопировано в буфер обмена",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "Тёмная",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "Светлая",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "Системная",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "Тема:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "Размер текста:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "Скрывать онлайн статус и индикаторы набора текста.",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "Отправлять на {key}",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "Нажмать Enter для новой строки",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "Отправлять на Enter",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "Нажимать Shift + Enter для новой строки",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "Основные",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "Клавиатура:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "Закрепить",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "Открепить",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, one {{count, number} подписчик} few {{count, number} подписчика} many {{count, number} подписчиков} other {{count, number} подписчиков}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "Участники:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "Подписчики:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, one {{count, number} участник} few {{count, number} участника} many {{count, number} участников} other {{count, number} участников}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "新增其他人",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "新增成員",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "內容已被刪除",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "下載",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "附加檔案",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "表單:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "圖片",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "輸入透過 {method} 傳送給您的確認碼:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "傳送重設密碼郵件:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "聯絡人:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "聯絡人",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -600,7 +586,7 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_wire_transport": {
|
||||
"translation": "Wire transport:",
|
||||
"translation": "線路傳輸:",
|
||||
"defaultMessage": "Wire transport:",
|
||||
"description": "Label for wire transport selection in SettingsView",
|
||||
"missing": false,
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "最後上線",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "例如 email:alice@example.com, tel:17025550003... 的清單",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1222,13 +1208,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "確認",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "說明(可選)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "一封電子郵件已發送至 {email}。按照電子郵件中的說明重置密碼。",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "已發送一封帶有安全代碼的電子郵件。",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "新密碼",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "不支持",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "來電",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "撥出電話",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "你",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "視頻電話",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "你已經在說話了!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "取消",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "未接",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "被拒",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "斷開連接",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "使用安全連接",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "確認成功",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "編輯",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "編輯",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ",編輯",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "添加圖片",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "附加文件",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "刪除錄音",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "暫停播放",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "播放錄音",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "錄製語音消息",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "恢復播放",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "發信息",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "將文件拖到這裡",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "視頻錄製",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "通話中",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "稱呼",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "無法識別此視頻的格式",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "加載中...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "我有代碼",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "發送短信重置密碼",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "手機號碼",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "已發送帶有安全代碼的短信。",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "需要手機號",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "當前郵箱",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "當前電話號碼",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "新電子郵件地址",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "新電話號碼",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "更改郵箱",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "更改電話號碼",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "忙碌的",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "置頂消息",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "取消置頂消息",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "初始化錄音失敗",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "密碼重置成功",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "掃描二維碼",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "為後代所儲存的註解、訊息、連結、檔案",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "已儲存的訊息",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "未找到訊息",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "訊息已刪除",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "別名(可選)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "別名:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(已採取)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(無效)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "別名",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "選擇國家",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "已複製到剪貼簿",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "深色",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "淺色",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "系統預設",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "主題:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "文字大小:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "隱藏您的線上狀態和正在輸入指示器。",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "按 {key} 傳送",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "按 Enter 換行",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "按 Enter 傳送",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "按 Shift + Enter 換行",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "一般",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "鍵盤:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "置頂",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "取消置頂",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, other {{count, number} 位訂閱者}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "成員:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "訂閱者:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, other {{count, number} 位成員}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_another": {
|
||||
"translation": "加上另一个",
|
||||
"defaultMessage": "Add another",
|
||||
"description": "Call to action [+ add another]",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"button_add_members": {
|
||||
"translation": "添加成员",
|
||||
"defaultMessage": "Add members",
|
||||
@@ -307,13 +300,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"deleted_content": {
|
||||
"translation": "内容已删除",
|
||||
"defaultMessage": "content deleted",
|
||||
"description": "Shown when messages are deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"download_action": {
|
||||
"translation": "下载",
|
||||
"defaultMessage": "download",
|
||||
@@ -324,21 +310,21 @@
|
||||
"drafty_attachment": {
|
||||
"translation": "附件",
|
||||
"defaultMessage": "Attachment",
|
||||
"description": "Comment for attachment in drafty preview",
|
||||
"description": "Comment for attachment in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_form": {
|
||||
"translation": "形式:",
|
||||
"defaultMessage": "Form:",
|
||||
"description": "Comment for form in drafty preview",
|
||||
"description": "Comment for form in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_image": {
|
||||
"translation": "图像",
|
||||
"defaultMessage": "Picture",
|
||||
"description": "Comment for embedded images in drafty preview",
|
||||
"description": "Comment for embedded images in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
@@ -365,7 +351,7 @@
|
||||
},
|
||||
"enter_confirmation_code_prompt": {
|
||||
"translation": "输入通过{method}发送的验证码:",
|
||||
"defaultMessage": "Enter confirmation code sent to you by {method}:",
|
||||
"defaultMessage": "Confirmation code",
|
||||
"description": "Request to enter confirmation code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -455,7 +441,7 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_default_access_mode": {
|
||||
"translation": "蓦然访问模式:",
|
||||
"translation": "默认访问模式:",
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Label for default access mode",
|
||||
"missing": false,
|
||||
@@ -540,7 +526,7 @@
|
||||
},
|
||||
"label_reset_password": {
|
||||
"translation": "发送密码重置邮件:",
|
||||
"defaultMessage": "Send a password reset email:",
|
||||
"defaultMessage": "Send a password reset email",
|
||||
"description": "Label for password reset field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -586,8 +572,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"label_user_contacts": {
|
||||
"translation": "往来:",
|
||||
"defaultMessage": "Contacts:",
|
||||
"translation": "往来",
|
||||
"defaultMessage": "Contacts",
|
||||
"description": "Label for user contacts",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -629,7 +615,7 @@
|
||||
},
|
||||
"last_seen_timestamp": {
|
||||
"translation": "最后可见",
|
||||
"defaultMessage": "Last seen",
|
||||
"defaultMessage": "Last seen: {timestamp}",
|
||||
"description": "Label for the timestamp of when the user or topic was last online",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1007,7 +993,7 @@
|
||||
},
|
||||
"search_placeholder": {
|
||||
"translation": "列表如 email:alice@example.com, tel:+17025550003...",
|
||||
"defaultMessage": "List like email:alice@example.com, tel:17025550003...",
|
||||
"defaultMessage": "List like alice@example.com, +17025550003...",
|
||||
"description": "Placeholder in contacts search field",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1222,13 +1208,6 @@
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"validate_credential_action": {
|
||||
"translation": "确认",
|
||||
"defaultMessage": "confirm",
|
||||
"description": "Validate credentail call to action",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"description_editing_placeholder": {
|
||||
"translation": "说明(可选)",
|
||||
"defaultMessage": "Description (optional)",
|
||||
@@ -1314,8 +1293,8 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_email_sent": {
|
||||
"translation": "一封电子邮件已发送至 {email}。按照电子邮件中的说明重置密码。",
|
||||
"defaultMessage": "An email has been sent to {email}. Follow the directions in the email to reset your password.",
|
||||
"translation": "已发送一封带有安全代码的电子邮件。",
|
||||
"defaultMessage": "An email with security code has been sent.",
|
||||
"description": "Notification that the email with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
@@ -1353,5 +1332,537 @@
|
||||
"description": "Contact search prompt when forwarding a message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_new_password": {
|
||||
"translation": "新密码",
|
||||
"defaultMessage": "New password",
|
||||
"description": "Label for entering a new password",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_unknown": {
|
||||
"translation": "不支持",
|
||||
"defaultMessage": "Unsupported",
|
||||
"description": "Unsupported entity in drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_incoming": {
|
||||
"translation": "来电",
|
||||
"defaultMessage": "Incoming call",
|
||||
"description": "Incoming call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_outgoing": {
|
||||
"translation": "拨出电话",
|
||||
"defaultMessage": "Outgoing call",
|
||||
"description": "Outgoing call label",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"calls_you_label": {
|
||||
"translation": "你",
|
||||
"defaultMessage": "You",
|
||||
"description": "Shown over the local video screen",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_video_call": {
|
||||
"translation": "视频电话",
|
||||
"defaultMessage": "Video call",
|
||||
"description": "Start video call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"already_in_call": {
|
||||
"translation": "你已经在说话了!",
|
||||
"defaultMessage": "You already in an ongoing call!",
|
||||
"description": "Error message when the user tried to accept a new call without finishing pervious one",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_cancelled": {
|
||||
"translation": "取消",
|
||||
"defaultMessage": "cancelled",
|
||||
"description": "Label for cancelled outgoing call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_missed": {
|
||||
"translation": "未接",
|
||||
"defaultMessage": "missed",
|
||||
"description": "Label for missed incoming call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_declined": {
|
||||
"translation": "被拒",
|
||||
"defaultMessage": "declined",
|
||||
"description": "Label for declined call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_disconnected": {
|
||||
"translation": "断开连接",
|
||||
"defaultMessage": "disconnected",
|
||||
"description": "Label for disconnected call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_use_secure_connection": {
|
||||
"translation": "使用安全连接",
|
||||
"defaultMessage": "Use secure connection",
|
||||
"description": "Label for WS/WSS connection type in SettingsView",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"cred_confirmed_successfully": {
|
||||
"translation": "确认成功",
|
||||
"defaultMessage": "Confirmed successfully",
|
||||
"description": "Notification message that the credential was successfully validated.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_edit": {
|
||||
"translation": "编辑",
|
||||
"defaultMessage": "Edit",
|
||||
"description": "Edit message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"editing_message": {
|
||||
"translation": "编辑",
|
||||
"defaultMessage": "Editing",
|
||||
"description": "Title over message editing preview",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_edited_marker": {
|
||||
"translation": ",编辑",
|
||||
"defaultMessage": ", edited",
|
||||
"description": "Marker indicating that the message was edited",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_add_image": {
|
||||
"translation": "添加图片",
|
||||
"defaultMessage": "Add image",
|
||||
"description": "Icon tool tip for attaching an image",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_attach_file": {
|
||||
"translation": "附加文件",
|
||||
"defaultMessage": "Attach file",
|
||||
"description": "Icon tool tip for attaching a file",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_delete": {
|
||||
"translation": "删除录音",
|
||||
"defaultMessage": "Delete recording",
|
||||
"description": "Icon tool tip for deleting recorded audio",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_pause": {
|
||||
"translation": "暂停播放",
|
||||
"defaultMessage": "Pause playback",
|
||||
"description": "Icon tool tip for pausing audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_play": {
|
||||
"translation": "播放录音",
|
||||
"defaultMessage": "Play recording",
|
||||
"description": "Icon tool tip for starting audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_record_voice": {
|
||||
"translation": "录制语音信息",
|
||||
"defaultMessage": "Record voice message",
|
||||
"description": "Icon tool tip for recording a voice message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_resume": {
|
||||
"translation": "恢复播放",
|
||||
"defaultMessage": "Resume playback",
|
||||
"description": "Icon tool tip for resuming audio playback",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"icon_title_send": {
|
||||
"translation": "发信息",
|
||||
"defaultMessage": "Send message",
|
||||
"description": "Icon tool tip for sending a message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drag_file": {
|
||||
"translation": "将文件拖到这里",
|
||||
"defaultMessage": "Drag file here",
|
||||
"description": "Prompt on the file drag-n-drop overlay banner",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"drafty_video": {
|
||||
"translation": "视频录制",
|
||||
"defaultMessage": "Video recording",
|
||||
"description": "Comment for videos embedded in Drafty",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_in_progress": {
|
||||
"translation": "通话中",
|
||||
"defaultMessage": "in progress",
|
||||
"description": "Label for call in progress",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "称呼",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unrecognized_video_format": {
|
||||
"translation": "无法识别此视频的格式",
|
||||
"defaultMessage": "Format of this video is not recognized",
|
||||
"description": "Error message when uploaded video is invalid",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"loading_note": {
|
||||
"translation": "加载中...",
|
||||
"defaultMessage": "Loading...",
|
||||
"description": "Message shown when component is loading",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_i_have_code": {
|
||||
"translation": "我有代码",
|
||||
"defaultMessage": "I have code",
|
||||
"description": "Call to open field to enter password reset code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_reset_password_tel": {
|
||||
"translation": "发送短信重置密码",
|
||||
"defaultMessage": "Send SMS to reset password",
|
||||
"description": "Label for password reset field by phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_phone_number": {
|
||||
"translation": "手机号",
|
||||
"defaultMessage": "Mobile phone number",
|
||||
"description": "Prompt for entering a mobile phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_sms_sent": {
|
||||
"translation": "已发送带有安全代码的短信。",
|
||||
"defaultMessage": "A text message with security code has been sent.",
|
||||
"description": "Notification that the SMS with password reset instructions has been sent",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"mobile_number_required": {
|
||||
"translation": "需要手机号",
|
||||
"defaultMessage": "Mobile phone number required",
|
||||
"description": "Error message",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_email": {
|
||||
"translation": "当前邮箱",
|
||||
"defaultMessage": "Current email",
|
||||
"description": "Label for old email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"current_phone": {
|
||||
"translation": "当前电话号码",
|
||||
"defaultMessage": "Current phone number",
|
||||
"description": "Label for old phone",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_email": {
|
||||
"translation": "新电子邮件地址",
|
||||
"defaultMessage": "New email",
|
||||
"description": "Prompt for entering a new email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"new_phone_number": {
|
||||
"translation": "新电话号码",
|
||||
"defaultMessage": "New phone number",
|
||||
"description": "Prompt for entering a new telephone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_email": {
|
||||
"translation": "更改邮箱",
|
||||
"defaultMessage": "Change email",
|
||||
"description": "Prompt to change current email",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"change_phone": {
|
||||
"translation": "更改电话号码",
|
||||
"defaultMessage": "Change phone number",
|
||||
"description": "Prompt to change current phone number",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"call_busy": {
|
||||
"translation": "忙碌的",
|
||||
"defaultMessage": "busy",
|
||||
"description": "Label for busy call line",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_message": {
|
||||
"translation": "置顶消息",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning message to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_message": {
|
||||
"translation": "取消置顶消息",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the message.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"failed_to_init_audio": {
|
||||
"translation": "初始化录音失败",
|
||||
"defaultMessage": "Failed to initialize audio recording",
|
||||
"description": "Error message when audio is not available",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"password_reset_success": {
|
||||
"translation": "密码重置成功",
|
||||
"defaultMessage": "Password reset successfully",
|
||||
"description": "Notification message that the password was successfully reset.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"scan_qr_code": {
|
||||
"translation": "扫描二维码",
|
||||
"defaultMessage": "Scan QR Code",
|
||||
"description": "Title for scanning QR code",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_comment": {
|
||||
"translation": "为后人保存的注释、消息、链接、文件",
|
||||
"defaultMessage": "Notes, messages, links, files saved for posterity",
|
||||
"description": "Comment for self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"self_topic_name": {
|
||||
"translation": "已保存的消息",
|
||||
"defaultMessage": "Saved messages",
|
||||
"description": "Name of self topic for UI",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_not_found": {
|
||||
"translation": "未找到消息",
|
||||
"defaultMessage": "message not found",
|
||||
"description": "Pinned message is not found",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"message_deleted": {
|
||||
"translation": "消息已删除",
|
||||
"defaultMessage": "message deleted",
|
||||
"description": "Pinned message is deleted",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_editing_placeholder": {
|
||||
"translation": "别名(可选)",
|
||||
"defaultMessage": "Alias (optional)",
|
||||
"description": "Placeholder for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias": {
|
||||
"translation": "别名:",
|
||||
"defaultMessage": "Alias:",
|
||||
"description": "Label for user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_already_taken": {
|
||||
"translation": "(已占用)",
|
||||
"defaultMessage": "(already taken)",
|
||||
"description": "Error message for alias already taken",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"alias_invalid": {
|
||||
"translation": "(无效)",
|
||||
"defaultMessage": "(invalid)",
|
||||
"description": "Error message for invalid alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_alias_edit": {
|
||||
"translation": "别名",
|
||||
"defaultMessage": "Alias",
|
||||
"description": "Label for editing user or topic alias",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"select_country": {
|
||||
"translation": "选择国家",
|
||||
"defaultMessage": "Select country",
|
||||
"description": "Placeholder for the country selector",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"text_copied": {
|
||||
"translation": "已复制到剪贴板",
|
||||
"defaultMessage": "Copied to clipboard",
|
||||
"description": "Notification that text has been copied to clipboard",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_dark": {
|
||||
"translation": "深色",
|
||||
"defaultMessage": "Dark",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_light": {
|
||||
"translation": "浅色",
|
||||
"defaultMessage": "Light",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"color_schema_system": {
|
||||
"translation": "系统默认",
|
||||
"defaultMessage": "System default",
|
||||
"description": "Name of the color schema",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_color_schema": {
|
||||
"translation": "主题:",
|
||||
"defaultMessage": "Theme:",
|
||||
"description": "Label for selecting color scheme (dark, light) in Settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_text_size": {
|
||||
"translation": "文字大小:",
|
||||
"defaultMessage": "Text size:",
|
||||
"description": "Label adjusting text size",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"incognito_mode_description": {
|
||||
"translation": "隐藏您的在线状态和正在输入指示器。",
|
||||
"defaultMessage": "Hide your online status and typing indicators.",
|
||||
"description": "Description for incognito mode toggle",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command": {
|
||||
"translation": "按 {key} 发送",
|
||||
"defaultMessage": "Send on {key}",
|
||||
"description": "Config option to send message on hitting CTRL(or Cmd)-Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_command_explained": {
|
||||
"translation": "按 Enter 换行",
|
||||
"defaultMessage": "Press Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on CTRL-Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain": {
|
||||
"translation": "按 Enter 发送",
|
||||
"defaultMessage": "Send on Enter",
|
||||
"description": "Config option to send message on hitting Enter",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"send_plain_explained": {
|
||||
"translation": "按 Shift + Enter 换行",
|
||||
"defaultMessage": "Press Shift + Enter for new line",
|
||||
"description": "Explanation how to enter newline when [Send on Enter] is enabled",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"sidepanel_title_acc_general": {
|
||||
"translation": "常规",
|
||||
"defaultMessage": "General",
|
||||
"description": "Sidepanel title for AccGeneralView.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_keyboard": {
|
||||
"translation": "键盘:",
|
||||
"defaultMessage": "Keyboard:",
|
||||
"description": "Label for send on enter settings",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"pin_chat": {
|
||||
"translation": "置顶",
|
||||
"defaultMessage": "Pin",
|
||||
"description": "Menu item [Pin] for pinning chat to the top.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"unpin_chat": {
|
||||
"translation": "取消置顶",
|
||||
"defaultMessage": "Unpin",
|
||||
"description": "Menu item [Unpin] for un-pinning the chat.",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"subscriber_count": {
|
||||
"translation": "{count, plural, other {{count, number} 位订阅者}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} subscriber} two {{count, number} subscribers} three {{count, number} subscribers} few {{count, number} subscribers} many {{count, number} subscribers} other {{count, number} subscribers}}",
|
||||
"description": "Count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_member_count": {
|
||||
"translation": "成员:",
|
||||
"defaultMessage": "Members:",
|
||||
"description": "Label for the count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"label_subscriber_count": {
|
||||
"translation": "订阅者:",
|
||||
"defaultMessage": "Subscribers:",
|
||||
"description": "Label for the count of channel subscribers",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
},
|
||||
"member_count": {
|
||||
"translation": "{count, plural, other {{count, number} 位成员}}",
|
||||
"defaultMessage": "{count, plural, one {{count, number} member} two {{count, number} members} three {{count, number} members} few {{count, number} members} many {{count, number} members} other {{count, number} members}}",
|
||||
"description": "Count of group topic members",
|
||||
"missing": false,
|
||||
"obsolete": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,45 @@
|
||||
// Used to generate umd/index.prod.js
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import allMessages from './messages.json';
|
||||
import TinodeWeb from './views/tinode-web.jsx';
|
||||
import HashNavigation from './lib/navigation.js';
|
||||
|
||||
// Insert google analytics script and tag if configured.
|
||||
if (typeof FIREBASE_INIT != 'undefined' && FIREBASE_INIT && FIREBASE_INIT.measurementId) {
|
||||
const head = document.getElementsByTagName('head')[0];
|
||||
let script = document.createElement('script');
|
||||
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + FIREBASE_INIT.measurementId;
|
||||
script.async = true;
|
||||
head.prepend(script);
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', FIREBASE_INIT.measurementId);
|
||||
}
|
||||
|
||||
// Allow loading translation strings for just one language.
|
||||
const messageLoader = {
|
||||
'ar': _ => import('./i18n.min/ar.json'),
|
||||
'de': _ => import('./i18n.min/de.json'),
|
||||
'en': _ => import('./i18n.min/en.json'),
|
||||
'es': _ => import('./i18n.min/es.json'),
|
||||
'fr': _ => import('./i18n.min/fr.json'),
|
||||
'ko': _ => import('./i18n.min/ko.json'),
|
||||
'ro': _ => import('./i18n.min/ro.json'),
|
||||
'ru': _ => import('./i18n.min/ru.json'),
|
||||
'th': _ => import('./i18n.min/th.json'),
|
||||
'uk': _ => import('./i18n.min/uk.json'),
|
||||
'zh': _ => import('./i18n.min/zh.json'),
|
||||
'zh-TW': _ => import('./i18n.min/zh-TW.json')
|
||||
};
|
||||
|
||||
// List of languages that are written right-to-left.
|
||||
const rtl = ['ar', 'fa', 'he', 'ur'];
|
||||
|
||||
// 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);
|
||||
@@ -19,21 +50,26 @@ const language = (params && params.hl) ||
|
||||
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];
|
||||
// Make sure names like 'en_GB' and 'en-GB' consistently use '-'.
|
||||
const normalized = language.replace('_', '-');
|
||||
// Get the base language 'en' from a more specific 'en-US' as a partial fallback.
|
||||
const baseLanguage = normalized.split('-')[0].toLowerCase();
|
||||
|
||||
// Try the full locale first, then the locale without the region code, fallback to 'en'.
|
||||
const htmlLang = allMessages[language] ? langauge : allMessages[baseLanguage] ? baseLanguage : 'en';
|
||||
const messages = allMessages[htmlLang];
|
||||
const htmlLang = messageLoader[normalized] ? language : messageLoader[baseLanguage] ? baseLanguage : 'en';
|
||||
|
||||
const html = document.getElementsByTagName('html')[0];
|
||||
// Set lang attribute of the HTML element: <html lang="XX">
|
||||
document.getElementsByTagName('html')[0].setAttribute('lang', htmlLang);
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
html.setAttribute('lang', htmlLang);
|
||||
if (rtl.includes(baseLanguage)) {
|
||||
// Set right-to-left dir attribute of the HTML element: <html dir="rtl">
|
||||
html.setAttribute('dir', 'rtl');
|
||||
}
|
||||
// Render the app.
|
||||
const root = createRoot(document.getElementById('mountPoint'));
|
||||
messageLoader[htmlLang]().then(messages =>
|
||||
root.render(
|
||||
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
|
||||
<TinodeWeb />
|
||||
</IntlProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('mountPoint')
|
||||
);
|
||||
));
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// 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'];
|
||||
@@ -9,11 +7,13 @@ export const MIME_EXTENSIONS = ['jpg', 'gif', 'png',
|
||||
// Get an URL from a theCard photo: either create a data: URL or return reference URL.
|
||||
export function makeImageUrl(photo) {
|
||||
if (photo && typeof photo == 'object') {
|
||||
if (photo.ref) {
|
||||
return photo.ref
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -138,10 +138,10 @@ export function imageCrop(mime, objURL, left, top, width, height, scale) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onerror = (err) => {
|
||||
img.onerror = _ => {
|
||||
reject(new Error("Image format unrecognized"));
|
||||
};
|
||||
img.onload = () => {
|
||||
img.onload = _ => {
|
||||
// Once the image is loaded, the URL is no longer needed.
|
||||
URL.revokeObjectURL(img.src);
|
||||
|
||||
@@ -172,10 +172,10 @@ export function imageCrop(mime, objURL, left, top, width, height, scale) {
|
||||
export function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = (evt) => {
|
||||
reader.onerror = _ => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = () => {
|
||||
reader.onload = _ => {
|
||||
resolve({mime: file.type, bits: reader.result.split(',')[1], name: file.name});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
@@ -186,10 +186,10 @@ export function fileToBase64(file) {
|
||||
export function blobToBase64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = (evt) => {
|
||||
reader.onerror = _ => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = () => {
|
||||
reader.onload = _ => {
|
||||
resolve({mime: blob.type, bits: reader.result.split(',')[1]});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
@@ -199,7 +199,7 @@ export function blobToBase64(blob) {
|
||||
// File pasted from the clipboard. It's either an inline image or a file attachment.
|
||||
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
|
||||
const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
||||
if (!Array.isArray(items)) {
|
||||
if (!items || !items.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError)
|
||||
|
||||
// Get mime type from data URL header.
|
||||
export function getMimeType(header) {
|
||||
var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
|
||||
const mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
|
||||
return (mime && mime.length > 1) ? mime[1] : null;
|
||||
}
|
||||
|
||||
@@ -281,3 +281,27 @@ export function base64ToBlob(str, mime) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function intArrayToBase64(arr) {
|
||||
if (!Array.isArray(arr)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let bin = '';
|
||||
new Uint8Array(arr).forEach(b => bin += String.fromCharCode(b));
|
||||
return window.btoa(bin);
|
||||
} catch (err) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function base64ToIntArray(b64) {
|
||||
const arr = [];
|
||||
try {
|
||||
const bin = window.atob(b64);
|
||||
[...bin].forEach(c => {
|
||||
arr.push(c.charCodeAt(0));
|
||||
});
|
||||
return arr;
|
||||
} catch (err) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,33 +3,175 @@ import { defineMessages } from 'react-intl';
|
||||
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import AudioPlayer from '../widgets/audio-player.jsx'
|
||||
import CallMessage from '../widgets/call-message.jsx'
|
||||
import CallStatus from '../widgets/call-status.jsx';
|
||||
import InlineVideo from '../widgets/inline-video.jsx';
|
||||
import LazyImage from '../widgets/lazy-image.jsx'
|
||||
import UploadingImage from '../widgets/uploading-image.jsx'
|
||||
|
||||
import { IMAGE_THUMBNAIL_DIM, BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
|
||||
import { BROKEN_IMAGE_SIZE, CLICKABLE_URL_SCHEMES, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO,
|
||||
REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js';
|
||||
import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';
|
||||
import { idToColorClass, shortenFileName } from './strformat.js';
|
||||
import { cancelablePromise, sanitizeImageUrl } from './utils.js';
|
||||
import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';
|
||||
import { cancelablePromise, sanitizeUrl, sanitizeUrlForMime } from './utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
drafty_form: {
|
||||
id: 'drafty_form',
|
||||
defaultMessage: 'Form: ',
|
||||
description: 'Comment for form in drafty preview'
|
||||
description: 'Comment for form in Drafty'
|
||||
},
|
||||
drafty_attachment: {
|
||||
id: 'drafty_attachment',
|
||||
defaultMessage: 'Attachment',
|
||||
description: 'Comment for attachment in drafty preview'
|
||||
description: 'Comment for attachment in Drafty'
|
||||
},
|
||||
drafty_image: {
|
||||
id: 'drafty_image',
|
||||
defaultMessage: 'Picture',
|
||||
description: 'Comment for embedded images in drafty preview'
|
||||
description: 'Comment for embedded images in Drafty'
|
||||
},
|
||||
drafty_video: {
|
||||
id: 'drafty_video',
|
||||
defaultMessage: 'Video recording',
|
||||
description: 'Comment for videos embedded in Drafty'
|
||||
},
|
||||
drafty_unknown: {
|
||||
id: 'drafty_unknown',
|
||||
defaultMessage: 'Unsupported',
|
||||
description: 'Unsupported entity in drafty'
|
||||
}
|
||||
});
|
||||
|
||||
// Size the already scaled image.
|
||||
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// viewportWidth:
|
||||
// authorizeURL:
|
||||
// onImagePreview:
|
||||
// onVideoPreview:
|
||||
// onFormButtonClick:
|
||||
// onQuoteClick:
|
||||
export function fullFormatter(style, data, values, key, stack) {
|
||||
if (stack.includes('QQ')) {
|
||||
return quoteFormatter.call(this, style, data, values, key);
|
||||
}
|
||||
|
||||
if (!style) {
|
||||
// Unformatted.
|
||||
return values;
|
||||
}
|
||||
|
||||
let el = Drafty.tagName(style);
|
||||
let attr = Drafty.attrValue(style, data) || {};
|
||||
attr.key = key;
|
||||
switch (style) {
|
||||
case 'AU':
|
||||
// Show audio player.
|
||||
if (attr.src) {
|
||||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio'));
|
||||
attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;
|
||||
attr.preview = data.preview;
|
||||
attr.loading = 'lazy';
|
||||
}
|
||||
el = AudioPlayer;
|
||||
// Audio element cannot have content.
|
||||
values = null;
|
||||
break;
|
||||
case 'BR':
|
||||
values = null;
|
||||
break;
|
||||
case 'EX':
|
||||
// Ignore.
|
||||
break;
|
||||
case 'HL':
|
||||
// Highlighted text. Assign class name.
|
||||
attr.className = 'highlight';
|
||||
break;
|
||||
case 'HD':
|
||||
el = null;
|
||||
values = null;
|
||||
break;
|
||||
case 'IM':
|
||||
// Additional processing for images
|
||||
el = handleImageData.call(this, el, data, attr);
|
||||
// Image element cannot have content.
|
||||
values = null;
|
||||
break;
|
||||
case 'BN':
|
||||
// Button
|
||||
attr.onClick = this.onFormButtonClick;
|
||||
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 'LN':
|
||||
if (attr) {
|
||||
// Ensure only safe URL schemes are clickable.
|
||||
attr.href = typeof attr.href == 'string' ? sanitizeUrl(attr.href, CLICKABLE_URL_SCHEMES) : '';
|
||||
}
|
||||
break;
|
||||
case 'MN':
|
||||
// Mention
|
||||
attr.className = 'mention'
|
||||
if (data) {
|
||||
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||||
}
|
||||
break;
|
||||
case 'FM':
|
||||
// Form
|
||||
attr.className = 'bot-form';
|
||||
break;
|
||||
case 'RW':
|
||||
// Form element formatting is dependent on element content.
|
||||
break;
|
||||
case 'QQ':
|
||||
// Quote/citation.
|
||||
attr.className = 'reply-quote'
|
||||
attr.onClick = this.onQuoteClick;
|
||||
break;
|
||||
case 'VC':
|
||||
el = CallMessage;
|
||||
// Video call messages do not have content.
|
||||
values = null;
|
||||
if (data) {
|
||||
attr.callState = data.state;
|
||||
attr.incoming = data.incoming;
|
||||
attr.duration = data.duration;
|
||||
}
|
||||
break;
|
||||
case 'VD':
|
||||
// Additional processing for videos.
|
||||
el = handleVideoData.call(this, el, data, attr);
|
||||
// Video element cannot have content.
|
||||
values = null;
|
||||
break;
|
||||
default:
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
attr = {key: key};
|
||||
// Generate comment for unknown element.
|
||||
let body = values;
|
||||
if (!Array.isArray(values) || !values.join('').trim()) {
|
||||
body = [<span key="x1" className="gray">{this.formatMessage(messages.drafty_unknown)}</span>];
|
||||
}
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' '].concat(body);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!el) {
|
||||
return values;
|
||||
}
|
||||
return React.createElement(el, attr, values);
|
||||
}
|
||||
|
||||
// Additional processing of image data.
|
||||
function handleImageData(el, data, attr) {
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_image.png';
|
||||
@@ -53,7 +195,7 @@ function handleImageData(el, data, attr) {
|
||||
minHeight: dim.dstHeight + 'px'
|
||||
};
|
||||
if (!Drafty.isProcessing(data)) {
|
||||
attr.src = this.authorizeURL(sanitizeImageUrl(attr.src));
|
||||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||||
attr.alt = data.name;
|
||||
if (attr.src) {
|
||||
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||||
@@ -63,137 +205,54 @@ function handleImageData(el, data, attr) {
|
||||
}
|
||||
attr.loading = 'lazy';
|
||||
} else {
|
||||
attr.src = 'img/broken_image.png';
|
||||
attr.src = null;
|
||||
}
|
||||
} else {
|
||||
// Use custom element instead of <img>.
|
||||
// Use custom element instead of <img> or <video>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function quotedImage(data) {
|
||||
let promise;
|
||||
// Get the blob from the image data.
|
||||
if (data.val) {
|
||||
const blob = base64ToBlob(data.val, data.mime);
|
||||
promise = blob ? Promise.resolve(blob) : Prmise.reject(new Error("Invalid image"));
|
||||
// Additional processing of attached video data.
|
||||
function handleVideoData(el, data, attr) {
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_video.png';
|
||||
attr.style = {
|
||||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
};
|
||||
return el;
|
||||
}
|
||||
attr.className = 'inline-image';
|
||||
const dim = fitImageSize(data.width, data.height,
|
||||
this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) :
|
||||
REM_SIZE * 34.5, REM_SIZE * 24, false) ||
|
||||
{dstWidth: NO_DIMENSIONS_VIDEO, dstHeight: NO_DIMENSIONS_VIDEO};
|
||||
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.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||||
attr.alt = data.name;
|
||||
if (data.ref || data.val) {
|
||||
attr.onClick = this.onVideoPreview;
|
||||
attr.loading = 'lazy';
|
||||
}
|
||||
el = InlineVideo;
|
||||
} else {
|
||||
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
|
||||
if (evt.ok) {
|
||||
return evt.blob();
|
||||
} else {
|
||||
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
|
||||
}
|
||||
});
|
||||
// Use custom element instead of <img> or <video>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
// Scale the blob.
|
||||
return promise
|
||||
.then(blob => {
|
||||
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
|
||||
}).then(scaled => {
|
||||
data.mime = scaled.mime;
|
||||
data.size = scaled.blob.size;
|
||||
data.width = scaled.width;
|
||||
data.height = scaled.height;
|
||||
delete data.ref;
|
||||
// Keeping the original file name, if provided: ex.data.name;
|
||||
|
||||
return blobToBase64(scaled.blob);
|
||||
}).then(b64 => {
|
||||
data.val = b64.bits;
|
||||
return data;
|
||||
}).catch(err => {
|
||||
delete data.val;
|
||||
delete data.name;
|
||||
data.width = IMAGE_THUMBNAIL_DIM;
|
||||
data.height = IMAGE_THUMBNAIL_DIM;
|
||||
data.maxWidth = IMAGE_THUMBNAIL_DIM;
|
||||
data.maxHeight = IMAGE_THUMBNAIL_DIM;
|
||||
// Rethrow.
|
||||
throw err;
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// viewportWidth: this.props.viewportWidth;
|
||||
// authorizeURL: this.props.tinode.authorizeURL
|
||||
// onImagePreview: this.handleImagePreview
|
||||
// onFormButtonClick: this.handleFormButtonClick
|
||||
// onQuoteClick: this.handleQuoteClick (optional)
|
||||
export function fullFormatter(style, data, values, key) {
|
||||
if (!style) {
|
||||
// Unformatted.
|
||||
return values;
|
||||
}
|
||||
|
||||
if (style == 'HD') {
|
||||
// Hidden.
|
||||
return null;
|
||||
}
|
||||
|
||||
let el = Drafty.tagName(style);
|
||||
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
|
||||
el = handleImageData.call(this, el, data, attr);
|
||||
// Image element cannot have content.
|
||||
values = null;
|
||||
break;
|
||||
case 'BN':
|
||||
// Button
|
||||
attr.onClick = this.onFormButtonClick;
|
||||
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 'MN':
|
||||
// Mention
|
||||
attr.className = 'mention'
|
||||
if (data) {
|
||||
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||||
}
|
||||
break;
|
||||
case 'FM':
|
||||
// Form
|
||||
attr.className = 'bot-form';
|
||||
break;
|
||||
case 'RW':
|
||||
// Form element formatting is dependent on element content.
|
||||
break;
|
||||
case 'QQ':
|
||||
// Quote/citation.
|
||||
attr.className = 'reply-quote'
|
||||
attr.onClick = this.onQuoteClick;
|
||||
break;
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i className="material-icons gray">extension</i>, ' '].concat(values || []);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!el) {
|
||||
return values;
|
||||
}
|
||||
return React.createElement(el, attr, values);
|
||||
};
|
||||
|
||||
// Converts Drafty object into a one-line preview. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// formatMessage: this.props.intl.formatMessage
|
||||
@@ -204,14 +263,14 @@ export function previewFormatter(style, data, values, key) {
|
||||
return values;
|
||||
}
|
||||
|
||||
if (style == 'HD') {
|
||||
// Hidden.
|
||||
return null;
|
||||
}
|
||||
|
||||
let el = Drafty.tagName(style);
|
||||
const attr = { key: key };
|
||||
switch (style) {
|
||||
case 'AU':
|
||||
// Voicemail as '[mic] 0:00'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="au" className="material-icons">mic</i>, ' ', secondsToTime(data.duration/1000)];
|
||||
break;
|
||||
case 'BR':
|
||||
// Replace new line with a space.
|
||||
el = React.Fragment;
|
||||
@@ -229,7 +288,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
case 'IM':
|
||||
// Replace image with '[icon] Image'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">photo</i>, this.formatMessage(messages.drafty_image)];
|
||||
values = [<i key="im" className="material-icons">photo</i>, ' ', this.formatMessage(messages.drafty_image)];
|
||||
break;
|
||||
case 'BN':
|
||||
el = 'span';
|
||||
@@ -245,7 +304,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
break;
|
||||
case 'EX':
|
||||
if (data) {
|
||||
if (data.mime == 'application/json') {
|
||||
if (Drafty.isFormResponseType(data.mime)) {
|
||||
// Ignore JSON attachments: they are form response payloads.
|
||||
return null;
|
||||
}
|
||||
@@ -254,12 +313,32 @@ export function previewFormatter(style, data, values, key) {
|
||||
delete data.ref;
|
||||
}
|
||||
el = React.Fragment;
|
||||
values = [<i key="ex" className="material-icons">attachment</i>, this.formatMessage(messages.drafty_attachment)];
|
||||
values = [<i key="ex" className="material-icons">attachment</i>, ' ', this.formatMessage(messages.drafty_attachment)];
|
||||
break;
|
||||
case 'VC':
|
||||
el = CallStatus;
|
||||
if (data) {
|
||||
attr.callState = data.state;
|
||||
attr.incoming = data.incoming;
|
||||
attr.duration = data.duration;
|
||||
}
|
||||
values = null;
|
||||
break;
|
||||
case 'QQ':
|
||||
case 'HD':
|
||||
el = null;
|
||||
values = null;
|
||||
break;
|
||||
case 'VD':
|
||||
// Replace image with '[icon] Video'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">play_circle_outline</i>, ' ', this.formatMessage(messages.drafty_video)];
|
||||
break;
|
||||
default:
|
||||
if (el == '_UNKN') {
|
||||
if (!el) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i key="unkn" className="material-icons">extension</i>, ' '].concat(values || []);
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -269,7 +348,7 @@ export function previewFormatter(style, data, values, key) {
|
||||
return React.createElement(el, attr, values);
|
||||
};
|
||||
|
||||
// Converts Drafty object into a quoted reply. 'this' is set by the caller.
|
||||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||||
function inlineImageAttr(attr, data) {
|
||||
attr.style = {
|
||||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
@@ -281,23 +360,48 @@ function inlineImageAttr(attr, data) {
|
||||
attr.alt = this.formatMessage(messages.drafty_image);
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_image.png';
|
||||
} else {
|
||||
attr.src = attr.src || 'img/broken_image.png';
|
||||
}
|
||||
attr.title = attr.alt;
|
||||
return attr;
|
||||
}
|
||||
|
||||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||||
function inlineVideoAttr(attr, data) {
|
||||
const dim = fitImageSize(data.width, data.height, VIDEO_THUMBNAIL_WIDTH, IMAGE_THUMBNAIL_DIM);
|
||||
attr.style = {
|
||||
width: dim.width + 'px',
|
||||
height: dim.height + 'px',
|
||||
maxWidth: VIDEO_THUMBNAIL_WIDTH + 'px',
|
||||
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
}
|
||||
attr.className = 'inline-image';
|
||||
attr.alt = this.formatMessage(messages.drafty_video);
|
||||
attr.title = attr.alt;
|
||||
if (!data) {
|
||||
attr.src = 'img/broken_video.png';
|
||||
} else {
|
||||
attr.src = attr.src || 'img/broken_video.png';
|
||||
}
|
||||
return attr;
|
||||
}
|
||||
|
||||
// Displays a portion of Drafty within 'QQ' quotes. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// formatMessage: this.props.intl.formatMessage
|
||||
// messages: formatjs messages defined with defineMessages.
|
||||
// authorizeURL: this.props.tinode.authorizeURL
|
||||
// onQuoteClick: this.handleQuoteClick (optional)
|
||||
export function quoteFormatter(style, data, values, key) {
|
||||
if (['BR', 'EX', 'IM', 'MN', 'QQ'].includes(style)) {
|
||||
function quoteFormatter(style, data, values, key) {
|
||||
if (['BR', 'EX', 'IM', 'MN', 'VD'].includes(style)) {
|
||||
let el = Drafty.tagName(style);
|
||||
let attr = Drafty.attrValue(style, data) || {};
|
||||
attr.key = key;
|
||||
switch(style) {
|
||||
case 'BR':
|
||||
values = null;
|
||||
break;
|
||||
case 'IM':
|
||||
attr = inlineImageAttr.call(this, attr, data);
|
||||
values = [React.createElement('img', attr, null), ' ', attr.alt];
|
||||
@@ -305,6 +409,13 @@ export function quoteFormatter(style, data, values, key) {
|
||||
// Fragment attributes.
|
||||
attr = {key: key};
|
||||
break;
|
||||
case 'VD':
|
||||
attr = inlineVideoAttr.call(this, attr, data);
|
||||
values = [React.createElement('img', attr, null), ' ', attr.alt];
|
||||
el = React.Fragment;
|
||||
// Fragment attributes.
|
||||
attr = {key: key};
|
||||
break;
|
||||
case 'MN':
|
||||
el = 'span';
|
||||
attr.className = 'mention'
|
||||
@@ -312,14 +423,10 @@ export function quoteFormatter(style, data, values, key) {
|
||||
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||||
}
|
||||
break;
|
||||
case 'QQ':
|
||||
attr.className = 'reply-quote';
|
||||
attr.onClick = this.onQuoteClick;
|
||||
break;
|
||||
case 'EX':
|
||||
let fname;
|
||||
if (data) {
|
||||
if (data.mime == 'application/json') {
|
||||
if (Drafty.isFormResponseType(data.mime)) {
|
||||
// Ignore JSON attachments: they are form response payloads.
|
||||
return null;
|
||||
}
|
||||
@@ -339,42 +446,69 @@ export function quoteFormatter(style, data, values, key) {
|
||||
}
|
||||
|
||||
// Create image thumbnail suitable for inclusion in a quote.
|
||||
function quoteImage(data) {
|
||||
function quoteImageOrVideo(data, isVideo) {
|
||||
let promise;
|
||||
// Get the blob from the image data.
|
||||
if (data.val) {
|
||||
const blob = base64ToBlob(data.val, data.mime);
|
||||
promise = blob ? Promise.resolve(blob) : Promise.reject(new Error("Invalid image"));
|
||||
let bits, ref, mime;
|
||||
if (isVideo) {
|
||||
bits = data.preview;
|
||||
mime = data.premime || 'image/jpeg';
|
||||
ref = data.preref;
|
||||
} else {
|
||||
promise = fetch(this.authorizeURL(sanitizeImageUrl(data.ref))).then(evt => {
|
||||
if (evt.ok) {
|
||||
return evt.blob();
|
||||
} else {
|
||||
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
|
||||
}
|
||||
});
|
||||
bits = data.val;
|
||||
mime = data.mime;
|
||||
ref = data.ref;
|
||||
}
|
||||
// Get the blob from the image data.
|
||||
if (bits) {
|
||||
const blob = base64ToBlob(bits, mime);
|
||||
if (!blob) {
|
||||
throw new Error("Invalid image");
|
||||
}
|
||||
promise = Promise.resolve(blob);
|
||||
} else if (ref) {
|
||||
promise = fetch(this.authorizeURL(sanitizeUrlForMime(ref, 'image')))
|
||||
.then(evt => {
|
||||
if (evt.ok) {
|
||||
return evt.blob();
|
||||
} else {
|
||||
throw new Error(`Image fetch unsuccessful: ${evt.status} ${evt.statusText}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error("Missing image data");
|
||||
}
|
||||
|
||||
// Scale the blob.
|
||||
return promise
|
||||
.then(blob => {
|
||||
// Cut the square from the center of the image and shrink it.
|
||||
return imageScaled(blob, IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, true)
|
||||
// If it's an image, cut the square from the center of the image and shrink it.
|
||||
// If it's a video, allow it to be rectantular.
|
||||
return imageScaled(blob, isVideo ? VIDEO_THUMBNAIL_WIDTH : IMAGE_THUMBNAIL_DIM, IMAGE_THUMBNAIL_DIM, -1, !isVideo)
|
||||
}).then(scaled => {
|
||||
data.mime = scaled.mime;
|
||||
if (isVideo) {
|
||||
data.premime = scaled.mime;
|
||||
} else {
|
||||
data.mime = scaled.mime;
|
||||
}
|
||||
data.size = scaled.blob.size;
|
||||
data.width = scaled.width;
|
||||
data.height = scaled.height;
|
||||
delete data.ref;
|
||||
delete data.preref;
|
||||
// Keeping the original file name, if provided: ex.data.name;
|
||||
|
||||
data.src = URL.createObjectURL(scaled.blob);
|
||||
return blobToBase64(scaled.blob);
|
||||
}).then(b64 => {
|
||||
data.val = b64.bits;
|
||||
if (isVideo) {
|
||||
data.preview = b64.bits;
|
||||
} else {
|
||||
data.val = b64.bits;
|
||||
}
|
||||
return data;
|
||||
}).catch(err => {
|
||||
delete data.val;
|
||||
delete data.preview;
|
||||
delete data.src;
|
||||
data.width = IMAGE_THUMBNAIL_DIM;
|
||||
data.height = IMAGE_THUMBNAIL_DIM;
|
||||
@@ -384,12 +518,33 @@ function quoteImage(data) {
|
||||
}
|
||||
|
||||
// Create a preview of a reply.
|
||||
export function replyFormatter(style, data, values, key) {
|
||||
if (style != 'IM') {
|
||||
return quoteFormatter.call(this, style, data, values, key);
|
||||
export function replyFormatter(style, data, values, key, stack) {
|
||||
if (style == 'IM' || style == 'VD') {
|
||||
const isImage = style == 'IM';
|
||||
const attr = isImage ? inlineImageAttr.call(this, {key: key}, data) :
|
||||
inlineVideoAttr.call(this, {key: key}, data);
|
||||
|
||||
let loadedPromise;
|
||||
try {
|
||||
loadedPromise = cancelablePromise(quoteImageOrVideo.call(this, data, style == 'VD'));
|
||||
} catch (error) {
|
||||
console.warn("Failed to quote image:", error.message);
|
||||
loadedPromise = cancelablePromise(error);
|
||||
}
|
||||
attr.whenDone = loadedPromise;
|
||||
values = [React.createElement(LazyImage, attr, null), ' ', attr.alt];
|
||||
return React.createElement(React.Fragment, {key: key}, values);
|
||||
} else if (style == 'QQ') {
|
||||
if (stack.includes('QQ')) {
|
||||
// Quote inside quote when forwarding a message.
|
||||
return React.createElement('span', {key: key},
|
||||
[<i key="qq" className="material-icons">format_quote</i>, ' ']);
|
||||
}
|
||||
|
||||
const attr = Drafty.attrValue('QQ', data) || {};
|
||||
attr.key = key;
|
||||
attr.className = 'reply-quote'
|
||||
return React.createElement(Drafty.tagName('QQ'), attr, values);
|
||||
}
|
||||
const attr = inlineImageAttr.call(this, {key: key}, data);
|
||||
attr.whenDone = cancelablePromise(quoteImage.call(this, data));
|
||||
values = [React.createElement(LazyImage, attr, null), ' ', attr.alt];
|
||||
return React.createElement(React.Fragment, {key: key}, values);
|
||||
return quoteFormatter.call(this, style, data, values, key);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// 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 {
|
||||
// Parse hash as in http://www.example.com/path#hash as if it were
|
||||
// path and arguments.
|
||||
static parseUrlHash(hash) {
|
||||
// Split path from args, path -> parts[0], args->path[1]
|
||||
// Split 'path/?args' into 'path/' and 'args', path -> parts[0], args->path[1].
|
||||
const parts = hash.split('?', 2);
|
||||
const params = {};
|
||||
let path = [];
|
||||
@@ -12,10 +12,11 @@ export default class HashNavigation {
|
||||
path = parts[0].replace('#', '').split('/');
|
||||
}
|
||||
if (parts[1]) {
|
||||
parts[1].split('&').forEach(function(part) {
|
||||
const item = part.split('=');
|
||||
if (item[0]) {
|
||||
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
|
||||
parts[1].split('&').forEach((arg) => {
|
||||
// Can't use split() because the value may contain '='.
|
||||
const eq = arg.indexOf('=');
|
||||
if (eq > 0) {
|
||||
params[arg.slice(0, eq)] = decodeURIComponent(arg.slice(eq + 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,7 +32,9 @@ export default class HashNavigation {
|
||||
const args = [];
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key)) {
|
||||
args.push(key + '=' + params[key]);
|
||||
if (params[key] !== undefined) {
|
||||
args.push(key + '=' + encodeURIComponent(params[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (args.length > 0) {
|
||||
@@ -41,38 +44,38 @@ export default class HashNavigation {
|
||||
}
|
||||
|
||||
static addUrlParam(hash, key, value) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
parsed.params[key] = value;
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static removeUrlParam(hash, key) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
delete parsed.params[key];
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlSidePanel(hash, sidepanel) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
parsed.path[0] = sidepanel;
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlInfoPanel(hash, infopanel) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
if (infopanel) {
|
||||
parsed.params.info = infopanel;
|
||||
} else {
|
||||
delete parsed.params.info;
|
||||
}
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlTopic(hash, topic) {
|
||||
const parsed = this.parseUrlHash(hash);
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
parsed.path[1] = topic;
|
||||
// Close InfoView on topic change.
|
||||
delete parsed.params.info;
|
||||
return this.composeUrlHash(parsed.path, parsed.params);
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,40 @@ export function shortDateFormat(then, locale) {
|
||||
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;
|
||||
// Representation a date relative to now.
|
||||
export function relativeDateFormat(then, locale) {
|
||||
locale = locale || window.navigator.userLanguage || window.navigator.language;
|
||||
const now = new Date();
|
||||
const thenDays = Math.floor((then.getTime() - then.getTimezoneOffset() * 60_000) / 86_400_000);
|
||||
const nowDays = Math.floor((now.getTime() - now.getTimezoneOffset() * 60_000) / 86_400_000);
|
||||
const diff = thenDays - nowDays;
|
||||
if (Math.abs(diff) < 2) {
|
||||
// Today or yesterday.
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diff, 'day')
|
||||
}
|
||||
// More than two days: just show the date.
|
||||
return new Intl.DateTimeFormat(locale).format(then);
|
||||
}
|
||||
|
||||
// Convert seconds to [hours:]minutes:seconds, i.e. 156 sec -> 2:36, 3756 sec -> 1:02:36.
|
||||
// If <code>fixedMins</code> is true, then minutes are represented by at least two digits.
|
||||
export function secondsToTime(seconds, fixedMin) {
|
||||
if (typeof seconds != 'number') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let min = (Math.floor(seconds / 60) | 0) % 60;
|
||||
let hours = Math.floor(seconds / 3600) | 0;
|
||||
|
||||
if (fixedMin || hours > 0) {
|
||||
min = min < 10 ? `0${min}` : min;
|
||||
}
|
||||
let sec = (seconds | 0) % 60;
|
||||
sec = sec < 10 ? `0${sec}` : sec;
|
||||
return `${min}:${sec}`;
|
||||
if (hours == 0) {
|
||||
return `${min}:${sec}`;
|
||||
}
|
||||
return `${hours}:${min}:${sec}`;
|
||||
}
|
||||
|
||||
// Convert a number of bytes to human-readable format.
|
||||
@@ -71,3 +99,27 @@ export function letterTileColorId(userId) {
|
||||
export function idToColorClass(id, light, fg) {
|
||||
return (light ? 'lt-' : 'dk-') + (fg ? 'fg-' : 'bg-') + letterTileColorId(id);
|
||||
}
|
||||
|
||||
// Takes ISO Alpha-2 country code, like 'US' and converts it to a flag emoji.
|
||||
export function flagEmoji(countryCode) {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 0x1F1A5 + char.charCodeAt());
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
// Truncate a string to a specific length and add ellipsis if needed.
|
||||
// Support both LTR and RTL languages.
|
||||
export function truncateString(str, maxLength) {
|
||||
if (typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// Detect RTL characters in the string and truncate accordingly.
|
||||
const isRTL = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(str);
|
||||
return isRTL ? '…' + str.slice(0, maxLength - 1) : str.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
shortDateFormat,
|
||||
relativeDateFormat,
|
||||
secondsToTime,
|
||||
bytesToHumanSize,
|
||||
shortenFileName,
|
||||
idToColorClass,
|
||||
flagEmoji,
|
||||
truncateString
|
||||
} from './strformat.js';
|
||||
|
||||
describe('shortDateFormat', () => {
|
||||
jest
|
||||
.useFakeTimers()
|
||||
.setSystemTime(new Date('2025-07-25T12:30:00Z'));
|
||||
|
||||
test('should format for same day', () => {
|
||||
const then = new Date('2025-07-25T10:15:00');
|
||||
// Note: output depends on the test runner's locale and timezone.
|
||||
// Assuming a locale where the format is HH:mm.
|
||||
expect(shortDateFormat(then, 'en-US')).toMatch(/10:15/);
|
||||
});
|
||||
|
||||
test('should format for same year, different day', () => {
|
||||
const then = new Date('2025-06-20T10:15:00');
|
||||
expect(shortDateFormat(then, 'en-US')).toMatch(/Jun 20, 10:15/);
|
||||
});
|
||||
|
||||
test('should format for different year', () => {
|
||||
const then = new Date('2024-07-25T10:15:00');
|
||||
expect(shortDateFormat(then, 'en-US')).toBe('Jul 25, 2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeDateFormat', () => {
|
||||
// Mock current date to a fixed point for consistent testing.
|
||||
jest
|
||||
.useFakeTimers()
|
||||
.setSystemTime(new Date('2025-07-25T12:30:00Z'));
|
||||
|
||||
test('should format for today', () => {
|
||||
const then = new Date('2025-07-25T10:00:00Z');
|
||||
expect(relativeDateFormat(then, 'en-US')).toBe('today');
|
||||
});
|
||||
|
||||
test('should format for yesterday', () => {
|
||||
const then = new Date('2025-07-24T10:00:00Z');
|
||||
expect(relativeDateFormat(then, 'en-US')).toBe('yesterday');
|
||||
});
|
||||
|
||||
test('should format for a past date', () => {
|
||||
const then = new Date('2025-07-20T10:00:00Z');
|
||||
expect(relativeDateFormat(then, 'en-US')).toBe('7/20/2025');
|
||||
});
|
||||
|
||||
test('should format for tomorrow', () => {
|
||||
const then = new Date('2025-07-26T10:00:00Z');
|
||||
expect(relativeDateFormat(then, 'en-US')).toBe('tomorrow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsToTime', () => {
|
||||
test('should format seconds only', () => {
|
||||
expect(secondsToTime(45)).toBe('0:45');
|
||||
});
|
||||
|
||||
test('should format minutes and seconds', () => {
|
||||
expect(secondsToTime(156)).toBe('2:36');
|
||||
});
|
||||
|
||||
test('should format hours, minutes, and seconds', () => {
|
||||
expect(secondsToTime(3756)).toBe('1:02:36');
|
||||
});
|
||||
|
||||
test('should format with fixed minutes', () => {
|
||||
expect(secondsToTime(3726, true)).toBe('1:02:06');
|
||||
expect(secondsToTime(126, true)).toBe('02:06');
|
||||
});
|
||||
|
||||
test('should handle non-number input', () => {
|
||||
expect(secondsToTime('abc')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bytesToHumanSize', () => {
|
||||
test('should format 0 bytes', () => {
|
||||
expect(bytesToHumanSize(0)).toBe('0 Bytes');
|
||||
});
|
||||
|
||||
test('should format bytes', () => {
|
||||
expect(bytesToHumanSize(500)).toBe('500 Bytes');
|
||||
});
|
||||
|
||||
test('should format kilobytes', () => {
|
||||
expect(bytesToHumanSize(1500)).toBe('1.46 KB');
|
||||
});
|
||||
|
||||
test('should format megabytes', () => {
|
||||
expect(bytesToHumanSize(1500000)).toBe('1.43 MB');
|
||||
});
|
||||
|
||||
test('should format gigabytes', () => {
|
||||
expect(bytesToHumanSize(1500000000)).toBe('1.40 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortenFileName', () => {
|
||||
test('should not shorten if not needed', () => {
|
||||
expect(shortenFileName('shortname.txt', 20)).toBe('shortname.txt');
|
||||
});
|
||||
|
||||
test('should shorten a long file name', () => {
|
||||
const longName = 'this_is_a_very_long_file_name_that_needs_shortening.txt';
|
||||
expect(shortenFileName(longName, 20)).toBe('this_is_a…ening.txt');
|
||||
expect(shortenFileName(longName, 20).length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('should handle non-string input', () => {
|
||||
expect(shortenFileName(null, 20)).toBeNull();
|
||||
expect(shortenFileName(undefined, 20)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('idToColorClass', () => {
|
||||
test('should generate light background color class', () => {
|
||||
expect(idToColorClass('usr123', true, false)).toMatch(/lt-bg-\d+/);
|
||||
});
|
||||
|
||||
test('should generate dark background color class', () => {
|
||||
expect(idToColorClass('usr123', false, false)).toMatch(/dk-bg-\d+/);
|
||||
});
|
||||
|
||||
test('should generate light foreground color class', () => {
|
||||
expect(idToColorClass('usr123', true, true)).toMatch(/lt-fg-\d+/);
|
||||
});
|
||||
|
||||
test('should generate dark foreground color class', () => {
|
||||
expect(idToColorClass('usr123', false, true)).toMatch(/dk-fg-\d+/);
|
||||
});
|
||||
|
||||
test('should be deterministic', () => {
|
||||
expect(idToColorClass('usr123', true, false)).toBe('lt-bg-2');
|
||||
expect(idToColorClass('grpABC', false, true)).toBe('dk-fg-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flagEmoji', () => {
|
||||
test('should convert country code to flag', () => {
|
||||
expect(flagEmoji('US')).toBe('🇺🇸');
|
||||
});
|
||||
|
||||
test('should handle lowercase country code', () => {
|
||||
expect(flagEmoji('jp')).toBe('🇯🇵');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateString', () => {
|
||||
test('should not truncate if not needed', () => {
|
||||
expect(truncateString('short', 10)).toBe('short');
|
||||
});
|
||||
|
||||
test('should truncate and add ellipsis for long strings', () => {
|
||||
expect(truncateString('this is a long string', 10)).toBe('this is a…');
|
||||
});
|
||||
|
||||
test('should handle RTL strings (Arabic)', () => {
|
||||
expect(truncateString('هذا مثال لنص عربي طويل يحتاج.', 10)).toBe('…هذا مثال ');
|
||||
});
|
||||
|
||||
test('should handle RTL strings (Hebrew)', () => {
|
||||
expect(truncateString('זה דוגמה לטקסט עברי ארוך', 10)).toBe('…זה דוגמה ');
|
||||
});
|
||||
|
||||
test('should handle RTL strings (Persian/Farsi)', () => {
|
||||
expect(truncateString('این یک نمونه متن فارسی طولانی است', 10)).toBe('…این یک نم');
|
||||
});
|
||||
|
||||
test('should handle RTL strings (Urdu)', () => {
|
||||
expect(truncateString('یہ اردو زبان کا ایک طویل جملہ ہے', 10)).toBe('…یہ اردو ز');
|
||||
});
|
||||
|
||||
test('should handle RTL strings (Arabic with mixed punctuation)', () => {
|
||||
expect(truncateString('النص العربي الطويل! متحف الفن', 10)).toBe('…النص العر');
|
||||
});
|
||||
|
||||
test('should handle RTL strings (Hebrew with English mixed)', () => {
|
||||
expect(truncateString('טקסט עברי עם English מעורב', 10)).toBe('…טקסט עברי');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// Odds and ends
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
// Make shortcut icon appear with a green dot + show unread count in title.
|
||||
export function updateFavicon(count) {
|
||||
@@ -89,7 +89,7 @@ export function arrayEqual(a, b) {
|
||||
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 val.replaceAll(/[- ().]*/g, '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function asPhone(val) {
|
||||
// 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)) {
|
||||
if (/^[a-z0-9_.+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(val)) {
|
||||
return val;
|
||||
}
|
||||
return null;
|
||||
@@ -108,9 +108,12 @@ export function asEmail(val) {
|
||||
// example.html - ok
|
||||
// https:example.com - not ok.
|
||||
// http:/example.com - not ok.
|
||||
// //example.com or \\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);
|
||||
// Replacing backslashes with forward slashes to mimic JS URL parser then testing for
|
||||
// 'scheme:' and '//' with optional space at the start.
|
||||
return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url.replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
|
||||
@@ -121,7 +124,10 @@ export function sanitizeUrl(url, allowedSchemes) {
|
||||
}
|
||||
|
||||
// Strip control characters and whitespace. They are not valid URL characters anyway.
|
||||
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
|
||||
url = url.replace(/[^\x21-\x7E]/gmi, '').trim();
|
||||
|
||||
// Replace backslashes with forward slashes. They will be replaced in the URL parser anyway.
|
||||
url = url.replace(/\\/g, '/');
|
||||
|
||||
// Relative URLs are safe.
|
||||
// Relative URL does not start with ':', abcd123: or '//'.
|
||||
@@ -144,10 +150,10 @@ export function sanitizeUrl(url, allowedSchemes) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
|
||||
// Ensure URL is suitable as a source like <img src="url"> field: the URL must be a relative URL or
|
||||
// have http:, https:, blob: or data: scheme.
|
||||
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
||||
export function sanitizeImageUrl(url) {
|
||||
// In case of data: scheme, the URL must must be of the right MIME type such as 'data:{mimeMajor}/XXXX;base64,'.
|
||||
export function sanitizeUrlForMime(url, mimeMajor) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
@@ -157,20 +163,57 @@ export function sanitizeImageUrl(url) {
|
||||
return sanitizedUrl;
|
||||
}
|
||||
|
||||
// Is this a data: URL of an image?
|
||||
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
|
||||
// Is this a data: URL of the appropriate mime type?
|
||||
const re = new RegExp(`data:${mimeMajor}\/[-+.a-z0-9]+;base64,`, 'i');
|
||||
if (re.test(url.trim())) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Append query parameter 'asatt=1' to the URL.
|
||||
// It will cause Tinode server to add 'Content-Disposition: attachment' header when serving it.
|
||||
// The URL is a string. The URL here is always absolute.
|
||||
export function urlAsAttachment(url) {
|
||||
// TODO: check if URL is local or remote, i.e. compare to window.location.origin.
|
||||
/*
|
||||
if (typeof window.location == 'object') {
|
||||
if (!url.startsWith(window.location.origin)) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
*/
|
||||
// Check if the URL is a data or blob. Do not alter such URLs.
|
||||
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Split URL into host+path, query, fragment.
|
||||
let query = '', fragment = '';
|
||||
const idxF = url.indexOf('#');
|
||||
if (idxF > 0) {
|
||||
fragment = url.substring(idxF+1);
|
||||
url = url.substring(0, idxF);
|
||||
}
|
||||
const idxQ = url.indexOf('?');
|
||||
if (idxQ > 0) {
|
||||
query = url.substring(idxQ+1);
|
||||
url = url.substring(0, idxQ);
|
||||
}
|
||||
// Add parameter and reassemble.
|
||||
const params = new URLSearchParams(query);
|
||||
params.append('asatt', '1');
|
||||
return `${url}?${params.toString()}` + (fragment ? `#${fragment}` : '');
|
||||
}
|
||||
|
||||
// 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:
|
||||
case Tinode.MESSAGE_STATUS_FATAL:
|
||||
return { name: 'warning', color: 'danger-color' }; // yellow icon /!\
|
||||
case Tinode.MESSAGE_STATUS_SENT:
|
||||
return { name: 'done' }; // checkmark
|
||||
@@ -183,20 +226,25 @@ export function deliveryMarker(received) {
|
||||
}
|
||||
|
||||
// Wraps a promise to make it cancelable.
|
||||
// The parameter can be either a promise or an error. If it's an error, the wrapped promise is
|
||||
// created in a rejected state.
|
||||
export function cancelablePromise(promise) {
|
||||
let hasCanceled = false;
|
||||
|
||||
const wrappedPromise = new Promise((resolve, reject) => {
|
||||
promise.then(
|
||||
result => hasCanceled ? reject({isCanceled: true}) : resolve(result),
|
||||
error => hasCanceled ? reject({isCanceled: true}) : reject(error)
|
||||
);
|
||||
});
|
||||
const wrappedPromise = promise instanceof Error ?
|
||||
Promise.reject(promise) :
|
||||
new Promise((resolve, reject) => {
|
||||
promise.then(
|
||||
result => hasCanceled ? reject({isCanceled: true}) : resolve(result),
|
||||
error => hasCanceled ? reject({isCanceled: true}) : reject(error)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
promise: wrappedPromise,
|
||||
cancel( ) {
|
||||
cancel() {
|
||||
hasCanceled = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
arrayEqual,
|
||||
asEmail,
|
||||
asPhone,
|
||||
isUrlRelative,
|
||||
sanitizeUrl,
|
||||
sanitizeUrlForMime,
|
||||
urlAsAttachment
|
||||
} from './utils';
|
||||
|
||||
test('asPhone', () => {
|
||||
expect(asPhone('123-456-7890')).toBe('1234567890');
|
||||
expect(asPhone('(123) 456-7890')).toBe('1234567890');
|
||||
expect(asPhone('+1 (123) 456-7890')).toBe('+11234567890');
|
||||
expect(asPhone('123.456.7890')).toBe('1234567890');
|
||||
expect(asPhone('123 456 7890')).toBe('1234567890');
|
||||
expect(asPhone('123-456-78901')).toBe('12345678901');
|
||||
expect(asPhone('')).toBeNull();
|
||||
expect(asPhone('abc-def-ghij')).toBeNull();
|
||||
expect(asPhone('123-456-789')).toBeNull();
|
||||
expect(asPhone('123-4i6-78901')).toBeNull();
|
||||
});
|
||||
|
||||
test('arrayEqual', () => {
|
||||
expect(arrayEqual([1, 2, 3], [3, 2, 1])).toBe(true);
|
||||
expect(arrayEqual([1, 2, 3], [4, 5, 6])).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], [1, 2])).toBe(false);
|
||||
expect(arrayEqual([], [])).toBe(true);
|
||||
expect(arrayEqual([1], [1])).toBe(true);
|
||||
expect(arrayEqual([1], [2])).toBe(false);
|
||||
expect(arrayEqual(null, [1, 2, 3])).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], null)).toBe(false);
|
||||
expect(arrayEqual(null, null)).toBe(true);
|
||||
expect(arrayEqual(undefined, undefined)).toBe(true);
|
||||
expect(arrayEqual([1, 2, 3], undefined)).toBe(false);
|
||||
expect(arrayEqual([1, 2, 3], [1, 2, '3'])).toBe(false);
|
||||
});
|
||||
|
||||
test('asEmail', () => {
|
||||
expect(asEmail('test@example.com')).toBe('test@example.com');
|
||||
expect(asEmail('test@mail.example.com')).toBe('test@mail.example.com');
|
||||
expect(asEmail('test@e-mail.example.com')).toBe('test@e-mail.example.com');
|
||||
expect(asEmail(' test@example.com ')).toBe('test@example.com');
|
||||
expect(asEmail('test.email+alex@leetcode.com')).toBe('test.email+alex@leetcode.com');
|
||||
expect(asEmail('test.email@sub.example.com')).toBe('test.email@sub.example.com');
|
||||
expect(asEmail('test.email@sub.example.co.uk')).toBe('test.email@sub.example.co.uk');
|
||||
expect(asEmail('invalid-email')).toBeNull();
|
||||
expect(asEmail('invalid-email@')).toBeNull();
|
||||
expect(asEmail('invalid-email@example')).toBeNull();
|
||||
expect(asEmail('invalid-email@example.')).toBeNull();
|
||||
expect(asEmail('invalid-email@example..com')).toBeNull();
|
||||
expect(asEmail('invalid-email@.example.com')).toBeNull();
|
||||
expect(asEmail('invalid-email@example.com.')).toBeNull();
|
||||
expect(asEmail('invalid-email@@example.com.')).toBeNull();
|
||||
expect(asEmail('invalid/email@example.com.')).toBeNull();
|
||||
});
|
||||
|
||||
test('isUrlRelative', () => {
|
||||
expect(isUrlRelative('example.html')).toBe(true);
|
||||
expect(isUrlRelative('https:example.com')).toBe(false);
|
||||
expect(isUrlRelative('http:/example.com')).toBe(false);
|
||||
expect(isUrlRelative(' \n https://example.com')).toBe(false);
|
||||
expect(isUrlRelative('//example.com')).toBe(false);
|
||||
expect(isUrlRelative('\\example.com')).toBe(true);
|
||||
expect(isUrlRelative('example.com')).toBe(true);
|
||||
expect(isUrlRelative('/example')).toBe(true);
|
||||
expect(isUrlRelative('ftp://example.com')).toBe(false);
|
||||
expect(isUrlRelative('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA')).toBe(false);
|
||||
});
|
||||
|
||||
test('sanitizeUrl', () => {
|
||||
// Test with valid URLs
|
||||
expect(sanitizeUrl('http://example.com')).toBe('http://example.com');
|
||||
expect(sanitizeUrl('https://example.com')).toBe('https://example.com');
|
||||
expect(sanitizeUrl('blob:http://example.com')).toBe('blob:http://example.com');
|
||||
expect(sanitizeUrl('example.html')).toBe('example.html');
|
||||
|
||||
// Test with invalid URLs
|
||||
expect(sanitizeUrl('ftp://example.com')).toBeNull();
|
||||
expect(sanitizeUrl('javascript:alert(1)')).toBeNull();
|
||||
expect(sanitizeUrl('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==')).toBeNull();
|
||||
|
||||
// Test with allowed schemes
|
||||
expect(sanitizeUrl('ftp://example.com', ['ftp'])).toBe('ftp://example.com');
|
||||
expect(sanitizeUrl('ftps://example.com', ['ftp', 'ftps'])).toBe('ftps://example.com');
|
||||
|
||||
// Test with non-string input
|
||||
expect(sanitizeUrl(null)).toBe(null);
|
||||
expect(sanitizeUrl(123)).toBe(123);
|
||||
|
||||
// Test with control characters and whitespace
|
||||
expect(sanitizeUrl(' \n https://example.com')).toBe('https://example.com');
|
||||
expect(sanitizeUrl('http:\\\\example.com')).toBe('http://example.com');
|
||||
});
|
||||
|
||||
test('sanitizeUrlForMime', () => {
|
||||
expect(sanitizeUrlForMime('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', 'image')).toBeNull();
|
||||
|
||||
// Test with empty and null URLs
|
||||
expect(sanitizeUrlForMime('', 'image')).toBeNull();
|
||||
expect(sanitizeUrlForMime(null, 'image')).toBeNull();
|
||||
|
||||
// Test with valid data URL but wrong mime type
|
||||
expect(sanitizeUrlForMime('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA', 'text')).toBeNull();
|
||||
});
|
||||
|
||||
test('urlAsAttachment', () => {
|
||||
// Test with a simple URL
|
||||
expect(urlAsAttachment('http://example.com')).toBe('http://example.com?asatt=1');
|
||||
|
||||
// Test with a URL that already has a query parameter
|
||||
expect(urlAsAttachment('http://example.com?param=value')).toBe('http://example.com?param=value&asatt=1');
|
||||
|
||||
// Test with a URL that has a fragment
|
||||
expect(urlAsAttachment('http://example.com#fragment')).toBe('http://example.com?asatt=1#fragment');
|
||||
|
||||
// Test with a URL that has both query parameters and a fragment
|
||||
expect(urlAsAttachment('http://example.com?param=value#fragment')).toBe('http://example.com?param=value&asatt=1#fragment');
|
||||
|
||||
// Test with a data URL
|
||||
expect(urlAsAttachment('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA')).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA');
|
||||
|
||||
// Test with a blob URL
|
||||
expect(urlAsAttachment('blob:http://example.com/550e8400-e29b-41d4-a716-446655440000')).toBe('blob:http://example.com/550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This is a generated file. Don't edit.
|
||||
|
||||
export const PACKAGE_VERSION = "0.18.0-beta1";
|
||||
export const PACKAGE_VERSION = "0.25.0-beta1";
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class AccGeneralView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
colorSchema: props.colorSchema || 'light dark',
|
||||
textSize: (props.textSize * 10) || '100',
|
||||
sendOnEnter: props.sendOnEnter || 'plain',
|
||||
};
|
||||
|
||||
this.isMac = ((navigator.userAgentData && navigator.userAgentData.platform)
|
||||
|| navigator.platform).toLowerCase().startsWith('mac');
|
||||
this.handleColorSchemaSelected = this.handleColorSchemaSelected.bind(this);
|
||||
this.handleTextSizeChanged = this.handleTextSizeChanged.bind(this);
|
||||
this.handleSendOnEnterSelected = this.handleSendOnEnterSelected.bind(this);
|
||||
}
|
||||
|
||||
handleColorSchemaSelected(e) {
|
||||
this.setState({colorSchema: e.currentTarget.value});
|
||||
this.props.onChangeColorSchema(e.currentTarget.value);
|
||||
}
|
||||
|
||||
handleTextSizeChanged(e) {
|
||||
// In %%.
|
||||
this.setState({textSize: e.currentTarget.value});
|
||||
// Convert %% to pt, 100% = 10pt.
|
||||
this.props.onTextSizeChanged((e.currentTarget.value / 10) | 0);
|
||||
}
|
||||
|
||||
handleSendOnEnterSelected(e) {
|
||||
this.setState({sendOnEnter: e.currentTarget.value});
|
||||
this.props.onSendOnEnterChanged(e.currentTarget.value);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="settings-form" className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_color_schema" defaultMessage="Theme:"
|
||||
description="Label for selecting color scheme (dark, light) in Settings" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<ul className="quoted">
|
||||
<li key="system">
|
||||
<input type="radio" id="system" name="color-scheme-select" value="light dark"
|
||||
checked={this.state.colorSchema == 'light dark'}
|
||||
onChange={this.handleColorSchemaSelected} />
|
||||
<label htmlFor="system">
|
||||
<FormattedMessage id="color_schema_system" defaultMessage="System default" description="Name of the color schema"/>
|
||||
<img src="../img/routine.svg" style={{verticalAlign: 'top', width: '1.6rem', height: '1.6rem'}}/>
|
||||
</label>
|
||||
</li>
|
||||
<li key="light">
|
||||
<input type="radio" id="light" name="color-scheme-select" value="light"
|
||||
checked={this.state.colorSchema == 'light'}
|
||||
onChange={this.handleColorSchemaSelected} />
|
||||
<label htmlFor="light">
|
||||
<FormattedMessage id="color_schema_light" defaultMessage="Light" description="Name of the color schema"/>
|
||||
<i className="material-icons orange large">light_mode</i>
|
||||
</label>
|
||||
</li>
|
||||
<li key="dark">
|
||||
<input type="radio" id="dark" name="color-scheme-select" value="dark"
|
||||
checked={this.state.colorSchema == 'dark'}
|
||||
onChange={this.handleColorSchemaSelected} />
|
||||
<label htmlFor="dark">
|
||||
<FormattedMessage id="color_schema_dark" defaultMessage="Dark" description="Name of the color schema"/>
|
||||
<i className="material-icons blue large">dark_mode</i>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_text_size" defaultMessage="Text size:"
|
||||
description="Label adjusting text size" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<input type="range" id="text_size" name="text_size" min="80" max="120" step="10"
|
||||
list="text_size_options" value={this.state.textSize} onChange={this.handleTextSizeChanged} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<datalist id="text_size_options">
|
||||
<option value="80" label="80%" />
|
||||
<option value="90" label="90%" />
|
||||
<option value="100" label="100%" />
|
||||
<option value="110" label="110%" />
|
||||
<option value="120" label="120%" />
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_keyboard" defaultMessage="Keyboard:"
|
||||
description="Label for send on enter settings" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<ul className="quoted">
|
||||
<li key="plain">
|
||||
<input type="radio" id="plain" name="send-select" value="plain"
|
||||
checked={this.state.sendOnEnter == 'plain'}
|
||||
onChange={this.handleSendOnEnterSelected} />
|
||||
<label htmlFor="send_plain">
|
||||
<FormattedMessage id="send_plain" defaultMessage="Send on Enter"
|
||||
description="Config option to send message on hitting Enter"/>
|
||||
</label>
|
||||
<div className="panel-form-row">
|
||||
<span className="small gray">
|
||||
<FormattedMessage id="send_plain_explained" defaultMessage="Press Shift + Enter for new line"
|
||||
description="Explanation how to enter newline when [Send on Enter] is enabled" />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li key="command">
|
||||
<input type="radio" id="command" name="send-select" value="command"
|
||||
checked={this.state.sendOnEnter == 'command'}
|
||||
onChange={this.handleSendOnEnterSelected} />
|
||||
<label htmlFor="send_command">
|
||||
<FormattedMessage id="send_command" defaultMessage="Send on {key}"
|
||||
values={{key: this.isMac ? '⌘ + Enter' : 'Ctrl + Enter'}}
|
||||
description="Config option to send message on hitting CTRL(or Cmd)-Enter"/>
|
||||
</label>
|
||||
<div className="panel-form-row">
|
||||
<span className="small gray">
|
||||
<FormattedMessage id="send_command_explained" defaultMessage="Press Enter for new line"
|
||||
description="Explanation how to enter newline when [Send on CTRL-Enter] is enabled" />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
|
||||
export default class EditAccountView extends React.PureComponent {
|
||||
export default class AccNotificationsView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -16,8 +16,6 @@ export default class EditAccountView extends React.PureComponent {
|
||||
this.props.onToggleMessageSounds(checked);
|
||||
} else if (what == 'alert') {
|
||||
this.props.onTogglePushNotifications(checked);
|
||||
} else if (what == 'incognito') {
|
||||
this.props.onToggleIncognitoMode(checked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,14 +45,6 @@ export default class EditAccountView extends React.PureComponent {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||
|
||||
@@ -42,6 +43,7 @@ class AccSecurityView extends React.Component {
|
||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
|
||||
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
|
||||
}
|
||||
|
||||
handlePasswordUpdate(pwd) {
|
||||
@@ -70,13 +72,17 @@ class AccSecurityView extends React.Component {
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
handleCheckboxClick(what, checked) {
|
||||
this.props.onToggleIncognitoMode(checked);
|
||||
}
|
||||
|
||||
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
|
||||
(_ => this.props.onDeleteAccount()), // onConfirm
|
||||
null, // use default text "OK"
|
||||
true, // Show Reject button
|
||||
null // use default text "Cancel"
|
||||
@@ -109,6 +115,21 @@ class AccSecurityView extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<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 className="panel-form-row">
|
||||
<span className="small gray">
|
||||
<FormattedMessage id="incognito_mode_description" defaultMessage="Hide your online status and typing indicators."
|
||||
description="Description for incognito mode toggle" />
|
||||
</span>
|
||||
</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"
|
||||
@@ -124,7 +145,7 @@ class AccSecurityView extends React.Component {
|
||||
<div>
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
|
||||
description="Label for default access mode" />
|
||||
description="Label for default access mode" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="quoted">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { parsePhoneNumber } from 'libphonenumber-js/mobile';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import BadgeList from '../widgets/badge-list.jsx';
|
||||
import CredentialEdit from '../widgets/credential-edit.jsx';
|
||||
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH } from '../config.js';
|
||||
|
||||
function _clip(str, length) {
|
||||
return str && str.substring(0, length);
|
||||
return truncateString(str, length);
|
||||
}
|
||||
|
||||
export default class AccountSettingsView extends React.Component {
|
||||
@@ -20,15 +23,85 @@ export default class AccountSettingsView extends React.Component {
|
||||
this.state = {
|
||||
fullName: _clip(me.public ? me.public.fn : undefined, MAX_TITLE_LENGTH),
|
||||
description: _clip(me.public ? me.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||
avatar: makeImageUrl(me.public ? me.public.photo : null)
|
||||
avatar: makeImageUrl(me.public ? me.public.photo : null),
|
||||
credentials: me.getCredentials() || [],
|
||||
credEdit: undefined,
|
||||
alias: ((me.tags() || []).find(tag => tag.startsWith('alias:')) || '').substring(6),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
me.onCredsUpdated = _ => this.setState({credentials: me.getCredentials()});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
me.onCredsUpdated = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.credEdit) {
|
||||
return (
|
||||
<CredentialEdit
|
||||
method={this.state.credEdit.meth}
|
||||
val={this.state.credEdit.val}
|
||||
done={this.state.credEdit.done}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onCredAdd={this.props.onCredAdd}
|
||||
onCredConfirm={this.props.onCredConfirm}
|
||||
onCancel={_ => this.setState({credEdit: undefined})}
|
||||
onError={this.props.onError} />
|
||||
);
|
||||
}
|
||||
|
||||
// Count how many times each method is validated.
|
||||
const validated = {};
|
||||
this.state.credentials.forEach(cred => {
|
||||
if (cred.done) {
|
||||
validated[cred.meth] = (validated[cred.meth] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const credentials = [];
|
||||
this.state.credentials.forEach((cred, idx) => {
|
||||
if (!['email', 'tel'].includes(cred.meth)) {
|
||||
// Skip unknown methods.
|
||||
return ;
|
||||
}
|
||||
|
||||
let val = cred.val
|
||||
if (cred.meth == 'tel') {
|
||||
const number = parsePhoneNumber(cred.val);
|
||||
val = number ? number.formatInternational() : cred.val;
|
||||
}
|
||||
|
||||
credentials.push(
|
||||
<div className="group quoted" key={idx}>
|
||||
<tt className="clickable" onClick={e => {e.preventDefault(); this.setState({credEdit: cred});}}>{val}</tt>
|
||||
<span> {cred.done ? null : <i className="material-icons">pending</i>}
|
||||
{!cred.done || validated[cred.meth] > 1 || this.props.reqCredMethod != cred.meth ?
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}>
|
||||
<i className="material-icons">delete</i>
|
||||
</a>
|
||||
:
|
||||
null}
|
||||
</span>
|
||||
</div>);
|
||||
});
|
||||
if (credentials.length > 0) {
|
||||
// Add title as the first element.
|
||||
credentials.unshift(
|
||||
<label className="small" key={'title'}>
|
||||
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts"
|
||||
description="Label for user contacts" />
|
||||
</label>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||
<a href="#" className="flat-button float-right" onClick={e => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
@@ -52,6 +125,12 @@ export default class AccountSettingsView extends React.Component {
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.props.myUserId}</tt>
|
||||
</div>
|
||||
{this.state.alias ?
|
||||
<div className="group">
|
||||
<label className="small"><FormattedMessage id="label_alias" defaultMessage="Alias:"
|
||||
description="Label for user or topic alias" /></label>
|
||||
<tt>@{this.state.alias}</tt>
|
||||
</div> : null}
|
||||
<div className="group">
|
||||
<BadgeList trustedBadges={this.props.trustedBadges} />
|
||||
</div>
|
||||
@@ -61,20 +140,28 @@ export default class AccountSettingsView extends React.Component {
|
||||
<FormattedMessage id="label_description" defaultMessage="Description"
|
||||
description="Label for editing topic description" />
|
||||
</label>
|
||||
<div>{this.state.description}</div>
|
||||
<div className="quoted">{this.state.description}</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('notif');}}>
|
||||
<div className="group">{credentials}</div>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button" onClick={e => {e.preventDefault(); this.props.onNavigate('acc_general');}}>
|
||||
<i className="material-icons">tune</i> <FormattedMessage id="sidepanel_title_acc_general"
|
||||
defaultMessage="General" description="Sidepanel title for AccGeneralView." />
|
||||
</a>
|
||||
<a href="#" className="flat-button" onClick={e => {e.preventDefault(); this.props.onNavigate('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.onNavigate('security');}}>
|
||||
<a href="#" className="flat-button" onClick={e => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||
</a>
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('support');}}>
|
||||
<a href="#" className="flat-button" onClick={e => {e.preventDefault(); this.props.onNavigate('support');}}>
|
||||
<i className="material-icons">contact_support</i> <FormattedMessage id="sidepanel_title_acc_support"
|
||||
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
|
||||
</a>
|
||||
|
||||
@@ -27,8 +27,12 @@ export default class ContactsView extends React.Component {
|
||||
const contacts = [];
|
||||
let unreadThreads = 0;
|
||||
let archivedCount = 0;
|
||||
props.chatList.map((c) => {
|
||||
|
||||
const me = props.tinode.getMeTopic();
|
||||
props.chatList.forEach(c => {
|
||||
const blocked = c.acs && !c.acs.isJoiner();
|
||||
c.pinned = me.pinnedTopicRank(c.topic);
|
||||
|
||||
// Show only blocked contacts only when props.blocked == true.
|
||||
if (blocked && props.blocked) {
|
||||
contacts.push(c);
|
||||
@@ -50,7 +54,8 @@ export default class ContactsView extends React.Component {
|
||||
});
|
||||
|
||||
contacts.sort((a, b) => {
|
||||
return (b.touched || 0) - (a.touched || 0);
|
||||
const pin = b.pinned - a.pinned;
|
||||
return pin != 0 ? pin : (b.touched || 0) - (a.touched || 0);
|
||||
});
|
||||
|
||||
if (archivedCount > 0) {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// Account registration.
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import AvatarCrop from '../widgets/avatar-crop.jsx';
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
import PhoneEdit from '../widgets/phone-edit.jsx';
|
||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||
|
||||
import LocalStorageUtil from '../lib/local-storage.js';
|
||||
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { theCard } from '../lib/utils.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
|
||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
||||
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
|
||||
MAX_TITLE_LENGTH } from '../config.js';
|
||||
|
||||
export default class CreateAccountView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
@@ -18,20 +23,35 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
this.state = {
|
||||
login: '',
|
||||
password: '',
|
||||
meth: '',
|
||||
email: '',
|
||||
tel: '',
|
||||
fn: '', // full/formatted name
|
||||
imageDataUrl: null,
|
||||
errorCleared: false,
|
||||
imageUrl: null,
|
||||
uploadUrl: null,
|
||||
newAvatar: null,
|
||||
newAvatarMime: null,
|
||||
buttonDisabled: 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.handlePhoneChange = this.handlePhoneChange.bind(this);
|
||||
this.handleFnChange = this.handleFnChange.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
||||
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
|
||||
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
|
||||
this.uploadAvatar = this.uploadAvatar.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
|
||||
// Connection will trigger change by changing the this.props.serverVersion.
|
||||
props.tinode.connect()
|
||||
.catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
handleLoginChange(e) {
|
||||
@@ -43,15 +63,23 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleEmailChange(e) {
|
||||
this.setState({email: e.target.value})
|
||||
this.setState({meth: 'email', email: e.target.value});
|
||||
}
|
||||
|
||||
handlePhoneChange(number) {
|
||||
this.setState({meth: 'tel', tel: number});
|
||||
}
|
||||
|
||||
handleFnChange(e) {
|
||||
this.setState({fn: e.target.value});
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
this.setState({imageDataUrl: img});
|
||||
handleImageChanged(mime, img) {
|
||||
this.setState({newAvatar: img, newAvatarMime: mime});
|
||||
if (!img) {
|
||||
// User cleared the image.
|
||||
this.setState({imageUrl: null, uploadUrl: null});
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleSaveToken() {
|
||||
@@ -61,15 +89,75 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
|
||||
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});
|
||||
theCard(truncateString(this.state.fn.trim(), MAX_TITLE_LENGTH), this.state.uploadUrl),
|
||||
{
|
||||
'meth': this.state.meth,
|
||||
'val': this.state.meth == 'email' ?
|
||||
this.state.email :
|
||||
this.state.meth == 'tel' ? this.state.tel : null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// AvatarCropView calls this method when the user has cropped the image.
|
||||
handleAvatarCropped(mime, blob, width, height) {
|
||||
const url = blob ? URL.createObjectURL(blob) : null;
|
||||
this.setState({newAvatar: null, newAvatarMime: null});
|
||||
if (blob) {
|
||||
this.uploadAvatar(mime, blob, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
handleAvatarCropCancel() {
|
||||
this.setState({newAvatar: null, newAvatarMime: null});
|
||||
}
|
||||
|
||||
// Utility method for converting cropped avatar blob to bytes for sending inband or
|
||||
// for uploading it to the server out of band.
|
||||
uploadAvatar(mime, blob, width, height) {
|
||||
const readyToUpload = image => {
|
||||
let {mime, blob} = image;
|
||||
this.setState({imageUrl: URL.createObjectURL(blob), buttonDisabled: true});
|
||||
if (blob.size > MAX_AVATAR_BYTES) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
uploader.upload(blob, 'newacc')
|
||||
.then(url => this.setState({uploadUrl: url}))
|
||||
.catch(err => this.props.onError(err.message, 'err'))
|
||||
.finally(_ => this.setState({buttonDisabled: false}));
|
||||
} else {
|
||||
// Convert blob to base64-encoded bits.
|
||||
blobToBase64(blob)
|
||||
.then(b64 => this.setState({uploadUrl: makeImageUrl({data: b64.bits, type: mime})}))
|
||||
.finally(_ => this.setState({buttonDisabled: false}));
|
||||
}
|
||||
};
|
||||
|
||||
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
|
||||
// Avatar is not square or too large even after cropping. Shrink it and make square.
|
||||
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true)
|
||||
.then(scaled => readyToUpload(scaled))
|
||||
.catch(err => this.props.onError(err.message, 'err'));
|
||||
} else {
|
||||
readyToUpload({mime: mime, blob: blob, width: width, height: height});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.newAvatar) {
|
||||
return (
|
||||
<AvatarCrop
|
||||
avatar={this.state.newAvatar}
|
||||
mime={this.state.newAvatarMime}
|
||||
onSubmit={this.handleAvatarCropped}
|
||||
onCancel={this.handleAvatarCropCancel}
|
||||
onError={this.props.onError} />
|
||||
);
|
||||
}
|
||||
|
||||
let submitClasses = 'primary';
|
||||
if (this.props.disabled) {
|
||||
submitClasses += ' disabled';
|
||||
@@ -78,7 +166,7 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
return (
|
||||
<form className="panel-form-column" onSubmit={this.handleSubmit}>
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<div className="umn">
|
||||
<FormattedMessage id="login_prompt" defaultMessage="Login"
|
||||
description="Placeholer for username/login">{
|
||||
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
|
||||
@@ -93,7 +181,8 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
</div>
|
||||
<AvatarUpload
|
||||
tinode={this.props.tinode}
|
||||
onImageChanged={this.handleImageChanged}
|
||||
avatar={this.state.imageUrl}
|
||||
onImageUpdated={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
@@ -103,13 +192,27 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
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>
|
||||
{this.props.reqCredMethod == 'email' ?
|
||||
<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>
|
||||
: this.props.reqCredMethod == 'tel' ?
|
||||
<><div className="panel-form-row">
|
||||
<label className="small gray"><FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
|
||||
description="Prompt for entering a mobile phone number" /></label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<PhoneEdit
|
||||
autoFocus={false}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onSubmit={this.handlePhoneChange} />
|
||||
</div></>
|
||||
: null
|
||||
}
|
||||
<div className="panel-form-row">
|
||||
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
||||
onChange={this.handleToggleSaveToken} />
|
||||
@@ -119,7 +222,7 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className={submitClasses} type="submit">
|
||||
<button className={submitClasses} type="submit" disabled={this.state.buttonDisabled}>
|
||||
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
|
||||
description="Create account button [Sign Up]" />
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// InfoView: panel with topic/user info.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { FormattedMessage, FormattedNumber, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
import { AccessMode, Tinode } from 'tinode-sdk';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import BadgeList from '../widgets/badge-list.jsx';
|
||||
@@ -12,13 +12,16 @@ import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import GroupManager from '../widgets/group-manager.jsx';
|
||||
import MenuCancel from '../widgets/menu-cancel.jsx';
|
||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||
import TopicCommon from '../widgets/topic-common.jsx';
|
||||
import ShowQRCode from '../widgets/show-qrcode.jsx';
|
||||
import TopicCommonView from './topic-common-view.jsx';
|
||||
import TopicSecurity from '../widgets/topic-security.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH, NO_ACCESS_MODE } from '../config.js';
|
||||
import { MAX_TITLE_LENGTH, MAX_TOPIC_DESCRIPTION_LENGTH,
|
||||
NO_ACCESS_MODE, TOAST_DURATION } from '../config.js';
|
||||
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { arrayEqual, theCard } from '../lib/utils.js';
|
||||
import { theCard } from '../lib/utils.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
@@ -76,12 +79,28 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Edit permissions',
|
||||
description: 'Menu item [Edit permissions]'
|
||||
},
|
||||
qrcode: {
|
||||
id: 'scan_qr_code',
|
||||
defaultMessage: 'Scan QR Code',
|
||||
description: 'Title for scanning QR code'
|
||||
},
|
||||
text_copied: {
|
||||
id: 'text_copied',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
description: 'Notification that text has been copied to clipboard'
|
||||
},
|
||||
self_topic_name: {
|
||||
id: 'self_topic_name',
|
||||
defaultMessage: 'Saved messages',
|
||||
description: 'Name of self topic for UI',
|
||||
},
|
||||
self_topic_comment: {
|
||||
id: 'self_topic_comment',
|
||||
defaultMessage: 'Notes, messages, links, files saved for posterity',
|
||||
description: 'Comment for self topic for UI',
|
||||
},
|
||||
});
|
||||
|
||||
function _clip(str, length) {
|
||||
return str && str.substring(0, length);
|
||||
}
|
||||
|
||||
class InfoView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -95,6 +114,7 @@ class InfoView extends React.Component {
|
||||
muted: false,
|
||||
address: null,
|
||||
groupTopic: undefined,
|
||||
isSelf: false,
|
||||
channel: undefined,
|
||||
fullName: undefined,
|
||||
description: undefined,
|
||||
@@ -114,6 +134,7 @@ class InfoView extends React.Component {
|
||||
previousSubsUpdated: undefined,
|
||||
};
|
||||
|
||||
this.propsUpdated = this.propsUpdated.bind(this);
|
||||
this.resetSubs = this.resetSubs.bind(this);
|
||||
this.resetDesc = this.resetDesc.bind(this);
|
||||
this.resetTags = this.resetTags.bind(this);
|
||||
@@ -124,6 +145,8 @@ class InfoView extends React.Component {
|
||||
this.handleUnarchive = this.handleUnarchive.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
|
||||
this.handleShowQRCode = this.handleShowQRCode.bind(this);
|
||||
this.handleCopyToClipboard = this.handleCopyToClipboard.bind(this);
|
||||
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
|
||||
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
|
||||
this.handleMemberSelected = this.handleMemberSelected.bind(this);
|
||||
@@ -131,8 +154,7 @@ class InfoView extends React.Component {
|
||||
this.handleBackNavigate = this.handleBackNavigate.bind(this);
|
||||
}
|
||||
|
||||
// No need to separately handle component mount.
|
||||
componentDidUpdate(props) {
|
||||
propsUpdated(props) {
|
||||
const topic = this.props.tinode.getTopic(props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
@@ -154,6 +176,15 @@ class InfoView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Must provide this for lazy loaded components.
|
||||
componentDidMount() {
|
||||
this.propsUpdated(this.props);
|
||||
}
|
||||
|
||||
componentDidUpdate(props) {
|
||||
this.propsUpdated(props);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
@@ -199,6 +230,8 @@ class InfoView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const isSelf = topic.isSelfType();
|
||||
|
||||
this.setState({
|
||||
owner: acs && acs.isOwner(),
|
||||
admin: acs && acs.isAdmin(),
|
||||
@@ -206,14 +239,19 @@ class InfoView extends React.Component {
|
||||
deleter: acs && acs.isDeleter(),
|
||||
muted: acs && acs.isMuted(),
|
||||
|
||||
fullName: _clip(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
|
||||
description: _clip(topic.public ? topic.public.note : undefined, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||
fullName: isSelf ?
|
||||
props.intl.formatMessage(messages.self_topic_name) :
|
||||
truncateString(topic.public && topic.public.fn, MAX_TITLE_LENGTH),
|
||||
description: isSelf ?
|
||||
props.intl.formatMessage(messages.self_topic_comment) :
|
||||
truncateString(topic.public && topic.public.note, MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
||||
trustedBadges: badges,
|
||||
private: _clip(topic.private ? topic.private.comment : null, MAX_TITLE_LENGTH),
|
||||
private: truncateString(topic.private && topic.private.comment, MAX_TITLE_LENGTH),
|
||||
archived: topic.isArchived(),
|
||||
address: topic.name,
|
||||
groupTopic: topic.isGroupType(),
|
||||
isSelf: isSelf,
|
||||
channel: topic.isChannelType() || topic.chan,
|
||||
access: acs ? acs.getMode() : undefined,
|
||||
modeGiven: acs ? acs.getGiven() : undefined,
|
||||
@@ -259,7 +297,7 @@ class InfoView extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
handleImageChanged(mime, img) {
|
||||
this.setState({avatar: img});
|
||||
this.props.onTopicDescUpdate(this.props.topic, theCard(null, img || Tinode.DEL_CHAR), null);
|
||||
}
|
||||
@@ -311,7 +349,7 @@ class InfoView extends React.Component {
|
||||
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));
|
||||
toSkip = AccessMode.encode(AccessMode.diff('ASDO', this.state.modeGiven));
|
||||
if (this.state.channel) {
|
||||
// Channels are read-only.
|
||||
toSkip += 'W';
|
||||
@@ -372,6 +410,20 @@ class InfoView extends React.Component {
|
||||
this.props.onNavigate(`perm/${which}`);
|
||||
}
|
||||
|
||||
handleShowQRCode(e) {
|
||||
e.preventDefault();
|
||||
this.props.onNavigate('qrcode');
|
||||
}
|
||||
|
||||
handleCopyToClipboard(e, text) {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(text).then(_ => {
|
||||
this.setState({toast: this.props.intl.formatMessage(messages.text_copied)});
|
||||
setTimeout(_ => { this.setState({toast: ''}); }, TOAST_DURATION);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
handleShowAddMembers(e) {
|
||||
e.preventDefault();
|
||||
this.props.onInitFind();
|
||||
@@ -414,11 +466,10 @@ class InfoView extends React.Component {
|
||||
}
|
||||
|
||||
const isMe = this.props.tinode.isMe(params.topicName);
|
||||
const menuItems = [
|
||||
{title: formatMessage(messages.edit_permissions), handler: () => {
|
||||
this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName);
|
||||
}}
|
||||
];
|
||||
const menuItems = [{
|
||||
title: formatMessage(messages.edit_permissions),
|
||||
handler: _ => this.handleLaunchPermissionsEditor(isMe ? 'want' : 'user', params.topicName)
|
||||
}];
|
||||
if (!isMe) {
|
||||
menuItems.push('member_delete');
|
||||
}
|
||||
@@ -442,8 +493,12 @@ class InfoView extends React.Component {
|
||||
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view])
|
||||
|| messages['info']);
|
||||
|
||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||
const alias = topic && topic.alias();
|
||||
|
||||
return (
|
||||
<div id="info-view">
|
||||
<div className={`toast${this.state.toast ? ' show' : ''}`}>{this.state.toast}</div>
|
||||
<div className="caption-panel" id="info-caption-panel">
|
||||
<div className="panel-title" id="info-title">{panelTitle}</div>
|
||||
<div>
|
||||
@@ -481,11 +536,12 @@ class InfoView extends React.Component {
|
||||
onCancel={this.handleBackNavigate} />
|
||||
:
|
||||
view == 'general' ?
|
||||
<TopicCommon
|
||||
<TopicCommonView
|
||||
tinode={this.props.tinode}
|
||||
topic={this.props.topic}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onCredAdd={this.props.onCredAdd}
|
||||
onTopicTagsUpdateRequest={this.props.onTopicTagsUpdateRequest}
|
||||
onUpdateTagsRequest={this.props.onTopicTagsUpdateRequest}
|
||||
onCredConfirm={this.props.onCredConfirm}
|
||||
onCredDelete={this.props.onCredDelete}
|
||||
onUpdateTopicDesc={this.props.onTopicDescUpdateRequest}
|
||||
@@ -518,12 +574,20 @@ class InfoView extends React.Component {
|
||||
onLaunchPermissionsEditor={this.handleLaunchPermissionsEditor}
|
||||
onNavigate={this.props.onNavigate} />
|
||||
:
|
||||
view == 'qrcode' ?
|
||||
<ShowQRCode
|
||||
uri={Tinode.URI_TOPIC_ID_PREFIX + this.props.topic}
|
||||
onCancel={this.handleBackNavigate} />
|
||||
:
|
||||
<div id="info-view-content" className="scrollable-panel">
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button float-right" onClick={(e) => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
{!this.state.isSelf ?
|
||||
<a href="#" className="flat-button float-right" onClick={e => {e.preventDefault(); this.props.onNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
<center>
|
||||
<AvatarUpload
|
||||
tinode={this.props.tinode}
|
||||
@@ -550,14 +614,61 @@ class InfoView extends React.Component {
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<div className="group">
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.state.address}</tt>
|
||||
</div>
|
||||
<div className="group">
|
||||
<BadgeList trustedBadges={this.state.trustedBadges} />
|
||||
</div>
|
||||
{!this.state.isSelf ?
|
||||
<>
|
||||
<div className="panel-form-row">
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.state.address}</tt>
|
||||
</div>
|
||||
<div style={{marginLeft: 'auto'}}>
|
||||
<a href="#" onClick={e => {this.handleCopyToClipboard(e, this.state.address);}}>
|
||||
<i className="material-icons">content_copy</i>
|
||||
</a>
|
||||
<a href="#" onClick={this.handleShowQRCode}>
|
||||
<i className="material-icons">qr_code</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{alias ?
|
||||
<div className="panel-form-row">
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_alias" defaultMessage="Alias:"
|
||||
description="Label for user or topic alias" /></label>
|
||||
<tt>{alias}</tt>
|
||||
</div>
|
||||
<div style={{marginLeft: 'auto'}}>
|
||||
<a href="#" onClick={e => {this.handleCopyToClipboard(e, alias);}}>
|
||||
<i className="material-icons">content_copy</i>
|
||||
</a>
|
||||
<i className="material-icons" style={{opacity: 0}}>qr_code</i>
|
||||
</div>
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{this.state.trustedBadges.length > 0 ?
|
||||
<div className="group">
|
||||
<BadgeList trustedBadges={this.state.trustedBadges} />
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
{this.state.groupTopic && topic.subcnt > 0 ?
|
||||
<div className="group"><label className="small">
|
||||
{this.state.channel ?
|
||||
<FormattedMessage id="label_subscriber_count"
|
||||
defaultMessage="Subscribers:"
|
||||
description="Label for the count of channel subscribers" />
|
||||
:
|
||||
<FormattedMessage id="label_member_count"
|
||||
defaultMessage="Members:"
|
||||
description="Label for the count of group topic members" />
|
||||
}
|
||||
</label> <FormattedNumber value={topic.subcnt}/></div>
|
||||
: null
|
||||
}
|
||||
{this.state.description ?
|
||||
<div className="group">
|
||||
<label className="small">
|
||||
@@ -567,14 +678,17 @@ class InfoView extends React.Component {
|
||||
<div>{this.state.description}</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<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>
|
||||
{!this.state.isSelf ?
|
||||
<><div className="hr" />
|
||||
<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></>
|
||||
: null
|
||||
}
|
||||
{this.state.archived ?
|
||||
<div className="panel-form-row">
|
||||
<label>
|
||||
@@ -586,13 +700,16 @@ class InfoView extends React.Component {
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||
</a>
|
||||
</div>
|
||||
{!this.state.isSelf ?
|
||||
<><div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="button_security"
|
||||
defaultMessage="Security" description="Navigaton button for security panel." />
|
||||
</a>
|
||||
</div></>
|
||||
: null
|
||||
}
|
||||
{this.state.groupTopic && this.state.sharer ?
|
||||
<>
|
||||
<div className="hr" />
|
||||
|
||||
@@ -5,8 +5,6 @@ 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);
|
||||
@@ -95,4 +93,3 @@ export default class LoginView extends React.Component {
|
||||
);
|
||||
}
|
||||
};
|
||||
/* END Login */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import { APP_NAME } from '../config.js';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
import NewTopicById from '../widgets/new-topic-by-id.jsx';
|
||||
@@ -10,7 +10,7 @@ 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';
|
||||
import { asEmail, asPhone, theCard } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
search_for_contacts: {
|
||||
@@ -25,7 +25,7 @@ const messages = defineMessages({
|
||||
},
|
||||
search_placeholder: {
|
||||
id: "search_placeholder",
|
||||
defaultMessage: "List like email:alice@example.com, tel:17025550003...",
|
||||
defaultMessage: "List like alice@example.com, +17025550003...",
|
||||
description: "Placeholder in contacts search field"
|
||||
},
|
||||
});
|
||||
@@ -35,13 +35,13 @@ class NewTopicView extends React.Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tabSelected: 'p2p',
|
||||
tabSelected: 'find',
|
||||
searchQuery: null
|
||||
};
|
||||
|
||||
this.handleTabClick = this.handleTabClick.bind(this);
|
||||
this.handleSearchContacts = this.handleSearchContacts.bind(this);
|
||||
this.handleContactSelected = this.handleContactSelected.bind(this);
|
||||
this.handleSearchResultSelected = this.handleSearchResultSelected.bind(this);
|
||||
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
|
||||
this.handleGroupByID = this.handleGroupByID.bind(this);
|
||||
}
|
||||
@@ -57,14 +57,34 @@ class NewTopicView extends React.Component {
|
||||
}
|
||||
|
||||
handleSearchContacts(query) {
|
||||
query = query.trim() || Tinode.DEL_CHAR;
|
||||
if (!/[\s,:]/.test(query) && query != Tinode.DEL_CHAR) {
|
||||
// No colons, spaces or commas, not DEL char. Try as email, phone, or alias.
|
||||
const email = asEmail(query);
|
||||
if (email) {
|
||||
query = `${Tinode.TAG_EMAIL}${email}`;
|
||||
} else {
|
||||
const tel = asPhone(query);
|
||||
if (tel) {
|
||||
query = `${Tinode.TAG_PHONE}${tel}`;
|
||||
} else {
|
||||
if (query[0] == '@') {
|
||||
// Strip the leading '@' sign if present.
|
||||
query = query.substring(1);
|
||||
}
|
||||
// 'alice' -> 'alias:alice,alice'
|
||||
query = `${Tinode.TAG_ALIAS}${query},${query}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.props.onSearchContacts(query);
|
||||
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
|
||||
}
|
||||
|
||||
handleContactSelected(uid) {
|
||||
if (this.state.tabSelected == 'p2p') {
|
||||
handleSearchResultSelected(topicName) {
|
||||
if (this.state.tabSelected == 'find') {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
this.props.onCreateTopic(uid);
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +107,8 @@ class NewTopicView extends React.Component {
|
||||
return (
|
||||
<div className="flex-column">
|
||||
<ul className="tabbar">
|
||||
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
|
||||
<a href="#" data-id="p2p" onClick={this.handleTabClick}>
|
||||
<li className={this.state.tabSelected === "find" ? "active" : null}>
|
||||
<a href="#" data-id="find" onClick={this.handleTabClick}>
|
||||
<FormattedMessage id="tabtitle_find_user" defaultMessage="find"
|
||||
description="Tab title Find" />
|
||||
</a>
|
||||
@@ -109,14 +129,15 @@ class NewTopicView extends React.Component {
|
||||
{this.state.tabSelected === 'grp' ?
|
||||
<NewTopicGroup
|
||||
tinode={this.props.tinode}
|
||||
onSubmit={this.handleNewGroupSubmit} /> :
|
||||
onSubmit={this.handleNewGroupSubmit}
|
||||
onError={this.props.onError} /> :
|
||||
this.state.tabSelected === 'byid' ?
|
||||
<NewTopicById
|
||||
myURI={Tinode.URI_TOPIC_ID_PREFIX + this.props.tinode.getCurrentUserID()}
|
||||
onSubmit={this.handleGroupByID}
|
||||
onError={this.props.onError} /> :
|
||||
<div className="flex-column">
|
||||
<SearchContacts
|
||||
type="p2p"
|
||||
placeholder={search_placeholder}
|
||||
onSearchContacts={this.handleSearchContacts} />
|
||||
<ContactList
|
||||
@@ -124,10 +145,11 @@ class NewTopicView extends React.Component {
|
||||
contacts={this.props.searchResults}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage={no_contacts_placeholder}
|
||||
showSelfTopic={!this.state.searchQuery}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
onTopicSelected={this.handleSearchResultSelected} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,43 +1,102 @@
|
||||
// A password reset form.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import PhoneEdit from '../widgets/phone-edit.jsx';
|
||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
export default class PasswordResetView extends React.PureComponent {
|
||||
const messages = defineMessages({
|
||||
password_reset_email_sent: {
|
||||
id: 'password_reset_email_sent',
|
||||
defaultMessage: 'An email with security code has been sent.',
|
||||
description: 'Notification that the email with password reset instructions has been sent'
|
||||
},
|
||||
password_reset_sms_sent: {
|
||||
id: 'password_reset_sms_sent',
|
||||
defaultMessage: 'A text message with security code has been sent.',
|
||||
description: 'Notification that the SMS with password reset instructions has been sent'
|
||||
}
|
||||
});
|
||||
|
||||
class PasswordResetView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tel: '',
|
||||
email: '',
|
||||
password: '',
|
||||
sent: false
|
||||
sent: false,
|
||||
haveCode: false,
|
||||
code: ''
|
||||
};
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
this.handleCodeChange = this.handleCodeChange.bind(this);
|
||||
this.handleShowCodeField = this.handleShowCodeField.bind(this);
|
||||
|
||||
// Connection will trigger change by changing the this.props.serverVersion.
|
||||
props.tinode.connect()
|
||||
.catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const parsed = HashNavigation.parseUrlHash(window.location.hash);
|
||||
this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
|
||||
const newState = {
|
||||
token: parsed.params.token,
|
||||
scheme: parsed.params.scheme,
|
||||
code: parsed.params.code || ''
|
||||
}
|
||||
|
||||
this.savedCred = parsed.params.cred;
|
||||
if (this.props.reqCredMethod && parsed.params.cred) {
|
||||
newState[this.props.reqCredMethod] = this.savedCred;
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.reqCredMethod != this.props.reqCredMethod && this.props.reqCredMethod) {
|
||||
this.setState({[this.props.reqCredMethod]: this.savedCred || ''});
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
// Reset using token.
|
||||
this.props.onReset(this.state.password.trim(), {
|
||||
scheme: 'token',
|
||||
secret: this.state.token
|
||||
});
|
||||
} else if (this.state.code && this.props.reqCredMethod) {
|
||||
// Reset using security code.
|
||||
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
|
||||
const cred = this.props.reqCredMethod == 'email' ? this.state.email.trim() : this.state.tel.trim();
|
||||
this.props.onReset(this.state.password.trim(), {
|
||||
scheme: 'code',
|
||||
secret: btoa(`${this.state.code}:${this.props.reqCredMethod}:${cred}`)
|
||||
});
|
||||
} else {
|
||||
// Send email or SMS with the secret code.
|
||||
const email = this.state.email.trim();
|
||||
const tel = this.state.tel.trim();
|
||||
this.setState({email: email, tel: tel});
|
||||
this.props.onRequest(this.props.reqCredMethod, email || tel)
|
||||
.then(_ => {
|
||||
this.setState({sent: true});
|
||||
const msg = this.props.reqCredMethod == 'email' ? messages.password_reset_email_sent :
|
||||
this.props.reqCredMethod == 'tel' ? messages.password_reset_sms_sent : null;
|
||||
if (msg) {
|
||||
this.props.onError(this.props.intl.formatMessage(msg), 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,60 +108,124 @@ export default class PasswordResetView extends React.PureComponent {
|
||||
this.setState({password: e.target.value});
|
||||
}
|
||||
|
||||
handleCodeChange(e) {
|
||||
this.setState({code: e.target.value.replace(/[^\d]/g, '')});
|
||||
}
|
||||
|
||||
handleShowCodeField(e) {
|
||||
e.preventDefault();
|
||||
this.setState({haveCode: true});
|
||||
}
|
||||
|
||||
render() {
|
||||
const showCredentialInput = !((this.state.token || (this.state.code && this.props.reqCredMethod)) && this.state.scheme);
|
||||
const showPasswordInput = !showCredentialInput || this.state.haveCode || this.state.sent;
|
||||
|
||||
const passwordInput = (<>
|
||||
<label className="small gray" htmlFor="new-password">
|
||||
<FormattedMessage id="label_new_password"
|
||||
defaultMessage="New password"
|
||||
description="Label for entering a new password" />
|
||||
</label>
|
||||
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
|
||||
description="Placeholder for entering new password">{
|
||||
(placeholder) => <VisiblePassword
|
||||
id="new-password"
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
value={this.state.password}
|
||||
required={true} autoFocus={true}
|
||||
onChange={this.handlePasswordChange} />
|
||||
}</FormattedMessage>
|
||||
</>);
|
||||
|
||||
const emailInput = (<>
|
||||
{this.state.haveCode ?
|
||||
null :
|
||||
<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>
|
||||
</>);
|
||||
|
||||
const phoneInput = (<>
|
||||
{this.state.haveCode ?
|
||||
null :
|
||||
<div><label>
|
||||
<FormattedMessage id="label_reset_password_tel"
|
||||
defaultMessage="Send SMS to reset password"
|
||||
description="Label for password reset field by phone" />
|
||||
</label></div>
|
||||
}
|
||||
<div>
|
||||
<label className="small gray">
|
||||
<FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
|
||||
description="Prompt for entering a mobile phone number" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<PhoneEdit
|
||||
autoFocus={true}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onSubmit={number => this.setState({tel: number})} />
|
||||
</div>
|
||||
</>);
|
||||
|
||||
const codeInput = (<>
|
||||
<div>
|
||||
<label className="small gray" htmlFor="enter-confirmation-code">
|
||||
<FormattedMessage id="enter_confirmation_code_prompt"
|
||||
defaultMessage="Confirmation code"
|
||||
description="Request to enter confirmation code" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<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} maxLength={10}
|
||||
value={this.state.code} onChange={this.handleCodeChange} required />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
</>);
|
||||
|
||||
const credentialInput = this.props.reqCredMethod == 'email' ?
|
||||
emailInput : this.props.reqCredMethod == 'tel' ? phoneInput : null;
|
||||
|
||||
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>
|
||||
</>
|
||||
}
|
||||
{!this.state.sent && showCredentialInput ? credentialInput : null}
|
||||
{this.state.haveCode || this.state.sent ? codeInput : null}
|
||||
{showPasswordInput ? passwordInput : null}
|
||||
<div className="dialog-buttons">
|
||||
{this.state.haveCode || this.state.sent || this.state.token || this.state.code ? null :
|
||||
<a href="#" onClick={this.handleShowCodeField} style={{marginRight: 'auto'}}>
|
||||
<FormattedMessage id="password_i_have_code" defaultMessage="I have code"
|
||||
description="Call to open field to enter password reset code" />
|
||||
</a>
|
||||
}
|
||||
<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]" />
|
||||
showPasswordInput ?
|
||||
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
|
||||
:
|
||||
<FormattedMessage id="button_send_request" defaultMessage="Send request"
|
||||
description="Button [Send request]" />
|
||||
<FormattedMessage id="button_send_request" defaultMessage="Send request"
|
||||
description="Button [Send request]" />
|
||||
}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(PasswordResetView);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
|
||||
import HostSelector from '../widgets/host-selector.jsx';
|
||||
|
||||
export default class SettingsView extends React.PureComponent {
|
||||
@@ -11,18 +13,21 @@ export default class SettingsView extends React.PureComponent {
|
||||
this.state = {
|
||||
transport: props.transport || 'def',
|
||||
serverAddress: props.serverAddress,
|
||||
secureConnection: props.secureConnection,
|
||||
};
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleTransportSelected = this.handleTransportSelected.bind(this);
|
||||
this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
|
||||
this.handleToggleSecure = this.handleToggleSecure.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.onUpdate({
|
||||
transport: this.state.transport,
|
||||
serverAddress: this.state.serverAddress
|
||||
serverAddress: this.state.serverAddress,
|
||||
secureConnection: this.state.secureConnection,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,10 +39,14 @@ export default class SettingsView extends React.PureComponent {
|
||||
this.setState({serverAddress: name});
|
||||
}
|
||||
|
||||
handleToggleSecure(e) {
|
||||
this.setState({secureConnection: !this.state.secureConnection});
|
||||
}
|
||||
|
||||
render() {
|
||||
const names = {def: "default", ws: "websocket", lp: "long polling"};
|
||||
const transportOptions = [];
|
||||
['def', 'ws', 'lp'].map((item) => {
|
||||
['def', 'ws', 'lp'].forEach(item => {
|
||||
const id = 'transport-' + item;
|
||||
const name = names[item];
|
||||
transportOptions.push(
|
||||
@@ -58,8 +67,14 @@ export default class SettingsView extends React.PureComponent {
|
||||
description="Label for server selector in SettingsView" />
|
||||
</label>
|
||||
</div>
|
||||
<HostSelector serverAddress={this.state.serverAddress}
|
||||
onServerAddressChange={this.handleServerAddressChange} />
|
||||
<HostSelector serverAddress={this.state.serverAddress} onServerAddressChange={this.handleServerAddressChange} />
|
||||
<div className="panel-form-row">
|
||||
<CheckBox id="secure-connection" name="secure-connection" checked={this.state.secureConnection}
|
||||
className="quoted" onChange={this.handleToggleSecure} /><label htmlFor="secure-connection">
|
||||
<FormattedMessage id="label_use_secure_connection" defaultMessage="Use secure connection"
|
||||
description="Label for WS/WSS connection type in SettingsView" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_wire_transport" defaultMessage="Wire transport:"
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import React, { Suspense } from 'react';
|
||||
import { defineMessages, FormattedMessage, 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 TopicCommon from '../widgets/topic-common.jsx';
|
||||
import TopicCommonView from './topic-common-view.jsx';
|
||||
|
||||
import AccountSettingsView from './account-settings-view.jsx';
|
||||
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
|
||||
import ContactsView from './contacts-view.jsx';
|
||||
import CreateAccountView from './create-account-view.jsx';
|
||||
const CreateAccountView = React.lazy(_ => import('./create-account-view.jsx'));
|
||||
import AccGeneralView from './acc-general-view.jsx';
|
||||
import AccNotificationsView from './acc-notifications-view.jsx';
|
||||
import AccSecurityView from './acc-security-view.jsx';
|
||||
const AccSecurityView = React.lazy(_ => import('./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';
|
||||
const PasswordResetView = React.lazy(_ => import('./password-reset-view.jsx'));
|
||||
import SettingsView from './settings-view.jsx';
|
||||
import ValidationView from './validation-view.jsx';
|
||||
|
||||
@@ -60,6 +61,11 @@ const messages = defineMessages({
|
||||
description: 'Sidepanel title for AccNotificationsView.',
|
||||
defaultMessage: 'Notifications'
|
||||
},
|
||||
acc_general: {
|
||||
id: 'sidepanel_title_acc_general',
|
||||
description: 'Sidepanel title for AccGeneralView.',
|
||||
defaultMessage: 'General'
|
||||
},
|
||||
support: {
|
||||
id: 'sidepanel_title_acc_support',
|
||||
description: 'Sidepanel title for AccSupportView.',
|
||||
@@ -92,7 +98,7 @@ const messages = defineMessages({
|
||||
}
|
||||
});
|
||||
|
||||
class SidepanelView extends React.Component {
|
||||
class SidepanelView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -124,7 +130,7 @@ class SidepanelView extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
|
||||
<div id="sidepanel">
|
||||
<SideNavbar
|
||||
state={view}
|
||||
title={title}
|
||||
@@ -155,57 +161,82 @@ class SidepanelView extends React.Component {
|
||||
onPersistenceChange={this.props.onPersistenceChange} /> :
|
||||
|
||||
view === 'register' ?
|
||||
<CreateAccountView
|
||||
tinode={this.props.tinode}
|
||||
onCreateAccount={this.props.onCreateAccount}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} /> :
|
||||
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
|
||||
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
|
||||
<CreateAccountView
|
||||
tinode={this.props.tinode}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onCreateAccount={this.props.onCreateAccount}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} />
|
||||
</Suspense> :
|
||||
|
||||
view === 'settings' ?
|
||||
<SettingsView
|
||||
transport={this.props.transport}
|
||||
serverAddress={this.props.serverAddress}
|
||||
secureConnection={this.props.secureConnection}
|
||||
onCancel={this.props.onCancel}
|
||||
onUpdate={this.props.onGlobalSettings} /> :
|
||||
|
||||
view === 'edit' ?
|
||||
<AccountSettingsView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
trustedBadges={this.props.trustedBadges}
|
||||
onNavigate={this.props.onNavigate} /> :
|
||||
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
|
||||
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
|
||||
<AccountSettingsView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
trustedBadges={this.props.trustedBadges}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onNavigate={this.props.onNavigate}
|
||||
onCredAdd={this.props.onCredAdd}
|
||||
onCredDelete={this.props.onCredDelete}
|
||||
onCredConfirm={this.props.onCredConfirm}
|
||||
onError={this.props.onError} />
|
||||
</Suspense> :
|
||||
|
||||
(view === 'general' || view === 'crop') ?
|
||||
<TopicCommon
|
||||
<TopicCommonView
|
||||
topic="me"
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onUpdateTopicDesc={this.props.onUpdateAccountDesc}
|
||||
onUpdateTags={this.props.onUpdateAccountTags}
|
||||
onCredAdd={this.props.onCredAdd}
|
||||
onCredDelete={this.props.onCredDelete}
|
||||
onCredConfirm={this.props.onCredConfirm}
|
||||
onUpdateTagsRequest={this.props.onUpdateAccountTags}
|
||||
onError={this.props.onError} /> :
|
||||
|
||||
view === 'acc_general' ?
|
||||
<AccGeneralView
|
||||
colorSchema={this.props.colorSchema}
|
||||
textSize={this.props.textSize}
|
||||
sendOnEnter={this.props.sendOnEnter}
|
||||
onChangeColorSchema={this.props.onChangeColorSchema}
|
||||
onTextSizeChanged={this.props.onTextSizeChanged}
|
||||
onSendOnEnterChanged={this.props.onSendOnEnterChanged} /> :
|
||||
|
||||
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} /> :
|
||||
onToggleMessageSounds={this.props.onToggleMessageSounds} /> :
|
||||
|
||||
view === 'security' ?
|
||||
<AccSecurityView
|
||||
tinode={this.props.tinode}
|
||||
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
|
||||
onUpdatePassword={this.props.onUpdatePassword}
|
||||
onLogout={this.props.onLogout}
|
||||
onDeleteAccount={this.props.onDeleteAccount}
|
||||
onShowAlert={this.props.onShowAlert}
|
||||
onShowBlocked={this.props.onShowBlocked} /> :
|
||||
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
|
||||
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
|
||||
<AccSecurityView
|
||||
tinode={this.props.tinode}
|
||||
incognitoMode={this.props.incognitoMode}
|
||||
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
|
||||
onUpdatePassword={this.props.onUpdatePassword}
|
||||
onLogout={this.props.onLogout}
|
||||
onDeleteAccount={this.props.onDeleteAccount}
|
||||
onShowAlert={this.props.onShowAlert}
|
||||
onShowBlocked={this.props.onShowBlocked}
|
||||
onToggleIncognitoMode={this.props.onToggleIncognitoMode} />
|
||||
</Suspense> :
|
||||
|
||||
view === 'support' ?
|
||||
<AccSupportView
|
||||
@@ -238,15 +269,22 @@ class SidepanelView extends React.Component {
|
||||
<ValidationView
|
||||
credCode={this.props.credCode}
|
||||
credMethod={this.props.credMethod}
|
||||
credToken={this.props.credToken}
|
||||
onSubmit={this.props.onValidateCredentials}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} /> :
|
||||
onCancel={this.props.onCancel} /> :
|
||||
|
||||
view === 'reset' ?
|
||||
<PasswordResetView
|
||||
onRequest={this.props.onPasswordResetRequest}
|
||||
onReset={this.props.onResetPassword}
|
||||
onCancel={this.props.onCancel} /> :
|
||||
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
|
||||
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
|
||||
<PasswordResetView
|
||||
tinode={this.props.tinode}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onRequest={this.props.onPasswordResetRequest}
|
||||
onReset={this.props.onResetPassword}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} />
|
||||
</Suspense> :
|
||||
null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// View for editing topic parameters (including 'me' topic).
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import TopicDescEdit from '../widgets/topic-desc-edit.jsx';
|
||||
|
||||
import { arrayEqual } from '../lib/utils.js';
|
||||
|
||||
export default class TopicCommonView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
const acs = topic.getAccessMode();
|
||||
this.state = {
|
||||
tags: []
|
||||
};
|
||||
|
||||
this.previousTagsUpdated = undefined;
|
||||
|
||||
this.onTagsUpdated = this.onTagsUpdated.bind(this);
|
||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||
}
|
||||
|
||||
// No need to separately handle component mount.
|
||||
componentDidUpdate(props) {
|
||||
const topic = this.props.tinode.getTopic(props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic.onTagsUpdated != this.onTagsUpdated) {
|
||||
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});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
topic.onTagsUpdated = this.previousTagsUpdated;
|
||||
}
|
||||
|
||||
// Server informs that the tags have been updated.
|
||||
onTagsUpdated(tags) {
|
||||
this.setState({tags: tags});
|
||||
|
||||
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
|
||||
this.previousTagsUpdated(tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Request server to change tags.
|
||||
handleTagsUpdated(tags) {
|
||||
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||
this.props.onUpdateTagsRequest(this.props.topic, tags);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<TopicDescEdit
|
||||
tinode={this.props.tinode}
|
||||
topic={this.props.topic}
|
||||
onUpdateTopicDesc={this.props.onUpdateTopicDesc}
|
||||
onUpdateTags={this.handleTagsUpdated}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -20,17 +20,44 @@ class ValidationView extends React.PureComponent {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
code: props.credCode || ''
|
||||
code: props.credCode || '',
|
||||
codeReceived: props.credCode
|
||||
};
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleCodeChange = this.handleCodeChange.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});
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (nextProps.credCode != prevState.codeReceived) {
|
||||
return {
|
||||
code: nextProps.credCode || '',
|
||||
codeReceived: nextProps.credCode
|
||||
};
|
||||
}
|
||||
|
||||
return prevState;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Submit code automatically if it's received from the parent.
|
||||
if (this.props.credCode) {
|
||||
this.props.onSubmit(this.props.credMethod, this.props.credCode, this.props.credToken);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Submit code automatically if it's received from the parent.
|
||||
if (this.state.codeReceived && this.state.code && this.state.code != prevState.code) {
|
||||
this.props.onSubmit(this.props.credMethod, this.state.code, this.props.credToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleCodeChange(e) {
|
||||
this.setState({code: e.target.value.replace(/[^\d]/g, '')});
|
||||
}
|
||||
|
||||
handleKeyPress(e) {
|
||||
@@ -44,7 +71,7 @@ class ValidationView extends React.PureComponent {
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.code && this.state.code.trim()) {
|
||||
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
|
||||
this.props.onSubmit(this.props.credMethod, this.state.code.trim(), this.props.credToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,29 +80,28 @@ class ValidationView extends React.PureComponent {
|
||||
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;
|
||||
const 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">
|
||||
<label className="small gray" htmlFor="enter-confirmation-code">
|
||||
<FormattedMessage id="enter_confirmation_code_prompt"
|
||||
defaultMessage="Enter confirmation code sent to you by {method}:"
|
||||
defaultMessage="Confirmation code"
|
||||
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>
|
||||
<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.handleCodeChange}
|
||||
onKeyDown={this.handleKeyPress} required />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className="secondary" onClick={this.handleCancel}>
|
||||
|
||||
@@ -3,26 +3,27 @@ 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
|
||||
}
|
||||
return (<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
|
||||
}
|
||||
{this.props.onConfirm ?
|
||||
<button className="primary" onClick={this.props.onConfirm}>
|
||||
{this.props.confirm || <FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />}
|
||||
</button>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
null;
|
||||
</div>);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,15 +27,11 @@ export default class Attachment extends React.Component {
|
||||
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) => {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { secondsToTime } from '../lib/strformat';
|
||||
import { base64ToIntArray } from '../lib/blob-helpers';
|
||||
|
||||
// Make canvas bigger than the element size to reduce blurring.
|
||||
const CANVAS_UPSCALING = 2.0;
|
||||
// Thickness of a visualization bar.
|
||||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||||
// Spacing between two visualization bars.
|
||||
const SPACING = 2 * CANVAS_UPSCALING;
|
||||
// Color of histogram bars.
|
||||
const BAR_COLOR = '#888A';
|
||||
const BAR_COLOR_DARK = '#666C';
|
||||
const THUMB_COLOR = '#444E';
|
||||
// Minimum number of amplitude bars to draw.
|
||||
const MIN_PREVIEW_LENGTH = 16;
|
||||
|
||||
const messages = defineMessages({
|
||||
icon_title_play: {
|
||||
id: 'icon_title_play',
|
||||
defaultMessage: 'Play recording',
|
||||
description: 'Icon tool tip for starting audio playback'
|
||||
}
|
||||
});
|
||||
|
||||
class AudioPlayer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
let preview = base64ToIntArray(this.props.preview);
|
||||
if (!Array.isArray(preview) || preview.length < MIN_PREVIEW_LENGTH) {
|
||||
preview = null;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
canPlay: false,
|
||||
playing: false,
|
||||
currentTime: '0:00',
|
||||
duration: this.props.duration > 0 ? secondsToTime(this.props.duration / 1000) : '-:--',
|
||||
longMin: this.props.duration >= 600000,
|
||||
preview: preview
|
||||
};
|
||||
|
||||
this.initAudio = this.initAudio.bind(this);
|
||||
this.initCanvas = this.initCanvas.bind(this);
|
||||
this.resampleBars = this.resampleBars.bind(this);
|
||||
this.visualize = this.visualize.bind(this);
|
||||
|
||||
this.handlePlay = this.handlePlay.bind(this);
|
||||
this.handleSeek = this.handleSeek.bind(this);
|
||||
this.handleError = this.handleError.bind(this);
|
||||
|
||||
this.audioPlayer = null;
|
||||
|
||||
this.viewBuffer = [];
|
||||
this.canvasRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.src) {
|
||||
this.initAudio();
|
||||
}
|
||||
|
||||
this.initCanvas();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.onloadedmetadata = null;
|
||||
this.audioPlayer.ontimeupdate = null;
|
||||
this.audioPlayer.onended = null;
|
||||
this.audioPlayer.pause();
|
||||
this.audioPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.src != prevProps.src) {
|
||||
this.initAudio();
|
||||
}
|
||||
|
||||
if (this.props.preview != prevProps.preview) {
|
||||
let preview = base64ToIntArray(this.props.preview);
|
||||
if (!Array.isArray(preview) || preview.length < MIN_PREVIEW_LENGTH) {
|
||||
preview = null;
|
||||
}
|
||||
this.setState({preview: preview}, this.initCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
initAudio() {
|
||||
this.audioPlayer = new Audio(this.props.src);
|
||||
this.audioPlayer.onloadedmetadata = _ => this.setState({canPlay: true});
|
||||
this.audioPlayer.ontimeupdate = _ => this.setState({
|
||||
currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)
|
||||
});
|
||||
this.audioPlayer.onended = _ => {
|
||||
this.audioPlayer.currentTime = 0;
|
||||
this.setState({playing: false, currentTime: secondsToTime(0, this.state.longMin)})
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
// Force canvas aspect ratio to match one of the element + upscale canvas to reduce blurring.
|
||||
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
|
||||
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
|
||||
|
||||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||||
this.canvasContext.lineCap = 'round';
|
||||
|
||||
this.viewBuffer = this.resampleBars(this.state.preview);
|
||||
this.visualize();
|
||||
}
|
||||
|
||||
// Draw amplitude of sound.
|
||||
visualize() {
|
||||
if (!this.canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = this.effectiveWidth;
|
||||
const height = this.canvasRef.current.height;
|
||||
|
||||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||||
|
||||
const drawFrame = _ => {
|
||||
if (!this.canvasRef.current || !this.audioPlayer) {
|
||||
// The component is unmounted.
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvasContext.clearRect(0, 0, this.canvasRef.current.width, height);
|
||||
|
||||
if (this.viewBuffer) {
|
||||
if (this.state.playing) {
|
||||
window.requestAnimationFrame(drawFrame);
|
||||
}
|
||||
|
||||
// Current playback position.
|
||||
const thumbAt = this.props.duration ?
|
||||
Math.max(0, Math.min(this.audioPlayer.currentTime * 1000 / this.props.duration, 1)) * (width - LINE_WIDTH * 2) : -1;
|
||||
|
||||
// Draw amplitude bars.
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.strokeStyle = BAR_COLOR_DARK;
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = 1 + i * (LINE_WIDTH + SPACING) + LINE_WIDTH * 0.5;
|
||||
let y = Math.max(this.viewBuffer[i] * height * 0.9, 1);
|
||||
|
||||
const color = x < thumbAt ? BAR_COLOR_DARK : BAR_COLOR;
|
||||
if (this.canvasContext.strokeStyle != color) {
|
||||
this.canvasContext.stroke();
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.strokeStyle = color;
|
||||
}
|
||||
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
|
||||
// Draw thumb.
|
||||
if (this.props.duration) {
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.arc(thumbAt + LINE_WIDTH * 2, height * 0.5, LINE_WIDTH * 2, 0, 2 * Math.PI);
|
||||
this.canvasContext.fillStyle = THUMB_COLOR;
|
||||
this.canvasContext.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawFrame();
|
||||
}
|
||||
|
||||
// Quick and dirty downsampling of the original preview bars into a smaller (or equal) number of bars we can display here.
|
||||
resampleBars(original) {
|
||||
const dstCount = ((this.canvasRef.current.width - SPACING) / (LINE_WIDTH + SPACING)) | 0;
|
||||
// Remove extra padding on the right due to fractional bar which is not drawn.
|
||||
this.effectiveWidth = dstCount * (LINE_WIDTH + SPACING) + SPACING;
|
||||
|
||||
if (!Array.isArray(original) || original.length == 0) {
|
||||
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
|
||||
}
|
||||
|
||||
const factor = original.length / dstCount;
|
||||
let amps = [];
|
||||
let maxAmp = -1;
|
||||
for (let i=0; i<dstCount; i++) {
|
||||
let lo = (i * factor) | 0; // low bound;
|
||||
let hi = ((i + 1) * factor) | 0; // high bound;
|
||||
if (hi == lo) {
|
||||
amps[i] = original[lo];
|
||||
} else {
|
||||
let amp = 0.0;
|
||||
for (let j=lo; j<hi; j++) {
|
||||
amp += original[j]
|
||||
}
|
||||
amps[i] = Math.max(0, amp / (hi - lo));
|
||||
}
|
||||
maxAmp = Math.max(amps[i], maxAmp);
|
||||
}
|
||||
|
||||
if (maxAmp > 0) {
|
||||
return amps.map(a => a / maxAmp);
|
||||
}
|
||||
return Array.apply(null, Array(dstCount)).map(_ => 0.01);
|
||||
}
|
||||
|
||||
handlePlay(e) {
|
||||
e.preventDefault();
|
||||
if (!this.state.canPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.playing) {
|
||||
this.audioPlayer.pause();
|
||||
this.setState({playing: false});
|
||||
} else if (this.audioPlayer.readyState >= 2) {
|
||||
this.audioPlayer.play();
|
||||
this.setState({playing: true}, this.visualize);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
handleSeek(e) {
|
||||
e.preventDefault();
|
||||
if (e.target && this.props.duration) {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const offset = (e.clientX - rect.left) / this.effectiveWidth * CANVAS_UPSCALING;
|
||||
this.audioPlayer.currentTime = this.props.duration * offset / 1000;
|
||||
this.setState({currentTime: secondsToTime(this.audioPlayer.currentTime, this.state.longMin)});
|
||||
if (!this.state.playing) {
|
||||
this.visualize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const playClass = 'material-icons' +
|
||||
(this.props.short ? '' : ' large') +
|
||||
(this.state.canPlay ? '' : ' disabled');
|
||||
const play = (<a href="#" onClick={this.handlePlay} title={this.props.intl.formatMessage(messages.icon_title_play)}>
|
||||
<i className={playClass}>{this.state.playing ? 'pause_circle' :
|
||||
(this.state.canPlay ? 'play_circle' : 'not_interested')}</i>
|
||||
</a>);
|
||||
return (<div className="audio-player">{this.props.short ?
|
||||
<>
|
||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||||
{play}
|
||||
</>
|
||||
:
|
||||
<>
|
||||
{play}
|
||||
<div>
|
||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||||
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(AudioPlayer);
|
||||
@@ -0,0 +1,419 @@
|
||||
// Audio recorder widget.
|
||||
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import AudioPlayer from './audio-player.jsx';
|
||||
// Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=642012
|
||||
// It adds duration and SeekHead to the webm record.
|
||||
import fixWebmDuration from 'webm-duration-fix';
|
||||
|
||||
import { intArrayToBase64 } from '../lib/blob-helpers.js'
|
||||
import { secondsToTime } from '../lib/strformat';
|
||||
import { KEYPRESS_DELAY, MAX_DURATION, MIN_DURATION } from '../config.js';
|
||||
|
||||
// FFT resolution.
|
||||
const BUFFER_SIZE = 256;
|
||||
// Make canvas bigger than the element size to reduce blurring.
|
||||
const CANVAS_UPSCALING = 2.0;
|
||||
// Thickness of a visualization bar.
|
||||
const LINE_WIDTH = 3 * CANVAS_UPSCALING;
|
||||
// Spacing between two visualization bars.
|
||||
const SPACING = 2 * CANVAS_UPSCALING;
|
||||
// Duration represented by one visualization bar.
|
||||
const MILLIS_PER_BAR = 100;
|
||||
// Color of histogram bars
|
||||
const BAR_COLOR = '#BBBD';
|
||||
// Vertical scaling for visualization bars.
|
||||
const BAR_SCALE = 64.0;
|
||||
// Number of bars in preview.
|
||||
const VISUALIZATION_BARS = 96;
|
||||
// Maximum number of samples per bar.
|
||||
const MAX_SAMPLES_PER_BAR = 10;
|
||||
|
||||
// Default recording format (FF, Chrome except on iOS).
|
||||
const DEFAULT_AUDIO_MIME_TYPE = 'audio/webm';
|
||||
// Safari supports only mp4 as audio recording format.
|
||||
const SAFARI_AUDIO_MIME_TYPE = 'audio/mp4';
|
||||
const AUDIO_MIME_TYPES = [DEFAULT_AUDIO_MIME_TYPE, SAFARI_AUDIO_MIME_TYPE, ''];
|
||||
|
||||
const messages = defineMessages({
|
||||
icon_title_delete: {
|
||||
id: 'icon_title_delete',
|
||||
defaultMessage: 'Delete recording',
|
||||
description: 'Icon tool tip for deleting recorded audio'
|
||||
},
|
||||
icon_title_pause: {
|
||||
id: 'icon_title_pause',
|
||||
defaultMessage: 'Pause playback',
|
||||
description: 'Icon tool tip for pausing audio playback'
|
||||
},
|
||||
icon_title_resume: {
|
||||
id: 'icon_title_resume',
|
||||
defaultMessage: 'Resume playback',
|
||||
description: 'Icon tool tip for resuming audio playback'
|
||||
},
|
||||
icon_title_send: {
|
||||
id: 'icon_title_send',
|
||||
defaultMessage: 'Send message',
|
||||
description: 'Icon tool tip for sending a message'
|
||||
},
|
||||
failed_to_init_audio: {
|
||||
id: 'failed_to_init_audio',
|
||||
defaultMessage: 'Failed to initialize audio recording',
|
||||
description: 'Error message when audio is not available'
|
||||
}
|
||||
});
|
||||
|
||||
class AudioRecorder extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
enabled: true,
|
||||
audioRecord: null,
|
||||
recording: true,
|
||||
paused: false,
|
||||
duration: '0:00',
|
||||
blobUrl: null,
|
||||
preview: null
|
||||
};
|
||||
|
||||
this.visualize = this.visualize.bind(this);
|
||||
this.initMediaRecording = this.initMediaRecording.bind(this);
|
||||
this.initCanvas = this.initCanvas.bind(this);
|
||||
this.getRecording = this.getRecording.bind(this);
|
||||
this.cleanUp = this.cleanUp.bind(this);
|
||||
|
||||
this.handleResume = this.handleResume.bind(this);
|
||||
this.handlePause = this.handlePause.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handleDone = this.handleDone.bind(this);
|
||||
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.viewBuffer = [];
|
||||
this.canvasRef = React.createRef();
|
||||
|
||||
// Timestamp for sending "recording" notifications.
|
||||
this.recordingTimestamp = 0;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.stream = null;
|
||||
this.mediaRecorder = null;
|
||||
this.audioContext = null;
|
||||
this.audioInput = null;
|
||||
this.analyser = null;
|
||||
|
||||
this.audioChunks = [];
|
||||
|
||||
// Start recorder right away.
|
||||
try {
|
||||
navigator.mediaDevices.getUserMedia({audio: true, video: false}).then(this.initMediaRecording, this.props.onError);
|
||||
} catch (err) {
|
||||
this.props.onError(err);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.startedOn = null;
|
||||
if (this.stream) {
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw amplitude of sound.
|
||||
visualize() {
|
||||
this.initCanvas();
|
||||
|
||||
const pcmData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
const width = this.canvasWidth;
|
||||
const height = this.canvasHeight;
|
||||
// Number of bars.
|
||||
const viewLength = (width / (LINE_WIDTH + SPACING)) | 0;
|
||||
// Duration of audio which fits onto the screen.
|
||||
const viewDuration = MILLIS_PER_BAR * viewLength;
|
||||
|
||||
this.canvasContext.lineWidth = LINE_WIDTH;
|
||||
this.canvasContext.strokeStyle = BAR_COLOR;
|
||||
|
||||
let prevBarCount = 0;
|
||||
let volume = 0.0;
|
||||
let countPerBar = 0;
|
||||
const drawFrame = _ => {
|
||||
if (!this.startedOn) {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(drawFrame);
|
||||
|
||||
const duration = this.durationMillis + (Date.now() - this.startedOn);
|
||||
// Update record length timer.
|
||||
this.setState({duration: secondsToTime(duration / 1000)});
|
||||
|
||||
// Check if record is too long.
|
||||
if (duration > MAX_DURATION) {
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.pause();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.setState({enabled: false, recording: false, duration: secondsToTime(this.durationMillis / 1000)});
|
||||
}
|
||||
|
||||
// Draw histogram.
|
||||
|
||||
// Get current waveform and calculate its amplitude.
|
||||
this.analyser.getByteTimeDomainData(pcmData);
|
||||
let amp = 0.0;
|
||||
for (const amplitude of pcmData) {
|
||||
amp += (amplitude - 127) ** 2;
|
||||
}
|
||||
|
||||
// Sum the amplitude.
|
||||
volume += Math.sqrt(amp/pcmData.length);
|
||||
countPerBar ++;
|
||||
|
||||
let barCount = (duration / MILLIS_PER_BAR) | 0;
|
||||
// Shift of the histogram along x-axis to make scrolling smooth. No need to shift if recording is too short.
|
||||
const dx = viewDuration > duration ? 0 :
|
||||
(duration - MILLIS_PER_BAR * barCount) / MILLIS_PER_BAR * (LINE_WIDTH + SPACING);
|
||||
|
||||
if (prevBarCount != barCount) {
|
||||
prevBarCount = barCount;
|
||||
// Add new amplitude visualization bar.
|
||||
this.viewBuffer.push(volume/countPerBar);
|
||||
volume = 0.0;
|
||||
countPerBar = 0;
|
||||
if (this.viewBuffer.length > viewLength) {
|
||||
// Keep at most 'viewLength' amplitude bars.
|
||||
this.viewBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear canvas.
|
||||
this.canvasContext.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw amplitude bars.
|
||||
this.canvasContext.beginPath();
|
||||
for (let i = 0; i < this.viewBuffer.length; i++) {
|
||||
let x = i * (LINE_WIDTH + SPACING) - dx;
|
||||
let y = Math.max(Math.min(this.viewBuffer[i] / BAR_SCALE, 0.9) * height, 1);
|
||||
|
||||
this.canvasContext.moveTo(x, (height - y) * 0.5);
|
||||
this.canvasContext.lineTo(x, (height + y) * 0.5);
|
||||
}
|
||||
// Actually draw the bars on canvas.
|
||||
this.canvasContext.stroke();
|
||||
|
||||
// Send notification, if needed.
|
||||
const now = new Date().getTime();
|
||||
if (now - this.recordingTimestamp > KEYPRESS_DELAY) {
|
||||
this.props.onRecordingProgress();
|
||||
this.recordingTimestamp = now;
|
||||
}
|
||||
}
|
||||
|
||||
drawFrame();
|
||||
}
|
||||
|
||||
handlePause(e) {
|
||||
e.preventDefault();
|
||||
this.mediaRecorder.pause();
|
||||
this.mediaRecorder.requestData();
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
this.setState({recording: false});
|
||||
}
|
||||
|
||||
handleResume(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.enabled) {
|
||||
this.startedOn = Date.now();
|
||||
this.mediaRecorder.resume();
|
||||
this.setState({recording: true}, this.visualize);
|
||||
}
|
||||
}
|
||||
|
||||
handleDelete(e) {
|
||||
e.preventDefault();
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = null;
|
||||
this.mediaRecorder.stop();
|
||||
this.cleanUp();
|
||||
this.setState({recording: false});
|
||||
}
|
||||
|
||||
handleDone(e) {
|
||||
e.preventDefault();
|
||||
this.setState({recording: false});
|
||||
if (this.startedOn) {
|
||||
this.durationMillis += Date.now() - this.startedOn;
|
||||
this.startedOn = null;
|
||||
}
|
||||
// Stop recording and return data.
|
||||
if (this.mediaRecorder) {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
this.canvasRef.current.width = this.canvasRef.current.offsetWidth * CANVAS_UPSCALING;
|
||||
this.canvasRef.current.height = this.canvasRef.current.offsetHeight * CANVAS_UPSCALING;
|
||||
|
||||
this.canvasContext = this.canvasRef.current.getContext('2d');
|
||||
this.canvasContext.lineCap = 'round';
|
||||
// To reduce line blurring.
|
||||
this.canvasContext.translate(0.5, 0.5);
|
||||
this.canvasWidth = this.canvasRef.current.width;
|
||||
this.canvasHeight = this.canvasRef.current.height;
|
||||
}
|
||||
|
||||
initMediaRecording(stream) {
|
||||
this.stream = stream;
|
||||
AUDIO_MIME_TYPES.some(mimeType => {
|
||||
if (MediaRecorder.isTypeSupported(mimeType)) {
|
||||
this.mediaRecorder = new MediaRecorder(stream, {mimeType: mimeType, audioBitsPerSecond: 24_000});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!this.mediaRecorder) {
|
||||
console.warn('MediaRecorder failed to initialize: no supported audio formats');
|
||||
this.props.onError(this.props.intl.formatMessage(messages.failed_to_init_audio));
|
||||
return;
|
||||
}
|
||||
|
||||
// The following code is needed for visualization.
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioInput = this.audioContext.createMediaStreamSource(stream);
|
||||
if (!this.audioInput) {
|
||||
console.warn('createMediaStreamSource returned null: audio input unavailable');
|
||||
this.props.onError(this.props.intl.formatMessage(messages.failed_to_init_audio));
|
||||
return;
|
||||
}
|
||||
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = BUFFER_SIZE;
|
||||
this.audioInput.connect(this.analyser);
|
||||
|
||||
this.mediaRecorder.onstop = _ => {
|
||||
if (this.durationMillis > MIN_DURATION) {
|
||||
this.getRecording(this.mediaRecorder.mimeType)
|
||||
.then(result => this.props.onFinished(result.url, result.preview, this.durationMillis));
|
||||
} else {
|
||||
this.props.onDeleted();
|
||||
}
|
||||
this.cleanUp();
|
||||
}
|
||||
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
this.audioChunks.push(e.data);
|
||||
}
|
||||
|
||||
if (this.mediaRecorder.state != 'inactive') {
|
||||
this.getRecording(this.mediaRecorder.mimeType).then(result => {
|
||||
this.setState({
|
||||
blobUrl: result.url,
|
||||
preview: result.preview
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.durationMillis = 0;
|
||||
this.startedOn = Date.now();
|
||||
this.mediaRecorder.start();
|
||||
this.visualize();
|
||||
|
||||
this.props.onRecordingProgress();
|
||||
this.recordingTimestamp = this.startedOn;
|
||||
}
|
||||
|
||||
// Obtain data in a form sutable for sending or playing back.
|
||||
getRecording(mimeType) {
|
||||
mimeType = mimeType || DEFAULT_AUDIO_MIME_TYPE;
|
||||
let blob = new Blob(this.audioChunks, {type: mimeType});
|
||||
// If duration is valid, apply fix for Chrome's WebM duration bug.
|
||||
const result = mimeType == DEFAULT_AUDIO_MIME_TYPE ?
|
||||
fixWebmDuration(blob, mimeType) :
|
||||
Promise.resolve(blob);
|
||||
return result
|
||||
.then(fixedBlob => { blob = fixedBlob; return fixedBlob.arrayBuffer(); })
|
||||
.then(arrayBuff => this.audioContext.decodeAudioData(arrayBuff))
|
||||
.then(decoded => this.createPreview(decoded))
|
||||
.then(preview => ({url: window.URL.createObjectURL(blob), preview: intArrayToBase64(preview)}));
|
||||
}
|
||||
|
||||
// Preview must be calculated at the source: Chrome does not allow background AudioContext.
|
||||
createPreview(audio) {
|
||||
const data = audio.getChannelData(0);
|
||||
// Number of amplitude bars in preview.
|
||||
const viewLength = Math.min(data.length, VISUALIZATION_BARS);
|
||||
// The number of samples in each bar.
|
||||
const totalSPB = (data.length / viewLength) | 0;
|
||||
// Distance between samples: we are going to take just a fracton of samples.
|
||||
const samplingRate = Math.max(1, (totalSPB / MAX_SAMPLES_PER_BAR) | 0);
|
||||
|
||||
let buffer = [];
|
||||
let max = -1;
|
||||
for (let i = 0; i < viewLength; i++) {
|
||||
let amplitude = 0;
|
||||
let count = 0;
|
||||
for (let j = 0; j < totalSPB; j += samplingRate) {
|
||||
amplitude += data[totalSPB * i + j] ** 2;
|
||||
count ++;
|
||||
}
|
||||
const val = Math.sqrt(amplitude / count);
|
||||
buffer.push(val);
|
||||
max = Math.max(max, val);
|
||||
}
|
||||
// Normalize amplitude to 0..100.
|
||||
if (max > 0) {
|
||||
buffer = buffer.map(a => (100 * a / max) | 0);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
if (this.audioInput) {
|
||||
this.audioInput.disconnect();
|
||||
}
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||
return (
|
||||
<div className="audio">
|
||||
<a href="#" onClick={this.handleDelete} title={formatMessage(messages.icon_title_delete)}>
|
||||
<i className="material-icons gray">delete_outline</i>
|
||||
</a>
|
||||
{this.state.recording ?
|
||||
<canvas ref={this.canvasRef} />
|
||||
:
|
||||
<AudioPlayer
|
||||
src={this.state.blobUrl}
|
||||
preview={this.state.preview}
|
||||
duration={this.durationMillis}
|
||||
short={true} />
|
||||
}
|
||||
<div className="duration">{this.state.duration}</div>
|
||||
{this.state.recording ?
|
||||
<a href="#" onClick={this.handlePause} title={formatMessage(messages.icon_title_pause)}>
|
||||
<i className="material-icons">pause_circle_outline</i>
|
||||
</a> :
|
||||
<a href="#" onClick={this.handleResume} title={formatMessage(messages.icon_title_resume)}>
|
||||
<i className={resumeClass}>radio_button_checked</i>
|
||||
</a>
|
||||
}
|
||||
<a href="#" onClick={this.handleDone} title={formatMessage(messages.icon_title_send)}>
|
||||
<i className="material-icons">send</i>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(AudioRecorder);
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import LoadSpinner from './load-spinner.jsx';
|
||||
|
||||
import { sanitizeImageUrl } from '../lib/utils.js';
|
||||
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||
|
||||
import { AVATAR_SIZE } from '../config.js';
|
||||
|
||||
@@ -34,7 +34,7 @@ export default class AvatarUpload extends React.Component {
|
||||
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 randId = 'file-input-avatar-' + ('' + Math.random()).substring(0, 4);
|
||||
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -44,7 +44,7 @@ export default class AvatarUpload extends React.Component {
|
||||
<i className="material-icons">clear</i>
|
||||
</a>}
|
||||
{this.state.source ?
|
||||
<img src={this.props.tinode.authorizeURL(sanitizeImageUrl(this.state.source))} className="preview" /> :
|
||||
<img src={this.props.tinode.authorizeURL(sanitizeUrlForMime(this.state.source, 'image'))} className="preview" /> :
|
||||
this.props.readOnly && this.props.uid ?
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
|
||||
@@ -29,8 +29,8 @@ class BadgeList extends React.PureComponent {
|
||||
let badges = null;
|
||||
if (this.props.trustedBadges && this.props.trustedBadges.length > 0) {
|
||||
badges = [];
|
||||
this.props.trustedBadges.map((b) => {
|
||||
const comment = formatMessage(messages['badge_' + b]);
|
||||
this.props.trustedBadges.forEach(b => {
|
||||
const comment = this.props.short ? null : formatMessage(messages['badge_' + b]);
|
||||
const style = 'material-icons ' + b + '-color';
|
||||
badges.push(<div className="trusted-badge" key={b}><i className={style}>{icon_mapping[b] || b}</i> {comment}</div>);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// IncomingCall: displays Accept & Reject buttons for incoming calls.
|
||||
import React from 'react';
|
||||
|
||||
import BadgeList from './badge-list.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH, MAX_PEER_TITLE_LENGTH } from '../config.js';
|
||||
import { CALL_STATE_INCOMING_RECEIVED } from '../constants.js';
|
||||
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
|
||||
const RING_SOUND = new Audio('audio/call-in.m4a');
|
||||
|
||||
export default class CallIncoming extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
topic: null,
|
||||
fullName: undefined,
|
||||
avatar: null,
|
||||
trustedBadges: [],
|
||||
previousMetaDesc: undefined,
|
||||
};
|
||||
|
||||
this.resetDesc = this.resetDesc.bind(this);
|
||||
this.onMetaDesc = this.onMetaDesc.bind(this);
|
||||
this.handleRejectCall = this.handleRejectCall.bind(this);
|
||||
this.handleAcceptCall = this.handleAcceptCall.bind(this);
|
||||
this.ringTimer = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetDesc(topic);
|
||||
if (this.props.callState == CALL_STATE_INCOMING_RECEIVED) {
|
||||
// play() throws if the user did not click the app first: https://goo.gl/xX8pDD.
|
||||
RING_SOUND.play().catch(_ => {});
|
||||
this.ringTimer = setInterval(_ => {RING_SOUND.play().catch(_ => {})}, 2000);
|
||||
this.props.onRinging(this.props.topic, this.props.seq);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (this.state.topic != props.topic) {
|
||||
this.setState({topic: props.topic});
|
||||
this.resetDesc(topic, props);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.ringTimer != null) {
|
||||
clearInterval(this.ringTimer);
|
||||
RING_SOUND.pause();
|
||||
}
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
this.setState({topic: null});
|
||||
topic.onMetaDesc = this.previousMetaDesc;
|
||||
}
|
||||
|
||||
resetDesc(topic) {
|
||||
const badges = [];
|
||||
if (topic.trusted) {
|
||||
for (const [key, val] of Object.entries(topic.trusted)) {
|
||||
if (val) {
|
||||
badges.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
fullName: truncateString(topic.public ? topic.public.fn : undefined, MAX_TITLE_LENGTH),
|
||||
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
||||
trustedBadges: badges,
|
||||
});
|
||||
}
|
||||
|
||||
onMetaDesc(desc) {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
if (!topic) {
|
||||
return;
|
||||
}
|
||||
this.resetDesc(topic);
|
||||
|
||||
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
|
||||
this.previousMetaDesc(desc);
|
||||
}
|
||||
}
|
||||
|
||||
handleAcceptCall() {
|
||||
this.props.onAcceptCall(this.props.topic);
|
||||
}
|
||||
|
||||
handleRejectCall() {
|
||||
this.props.onReject(this.props.topic, this.props.seq);
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="alert-container">
|
||||
<div className="incoming-call">
|
||||
<div className="caller-card incoming pulse">
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.state.avatar || true}
|
||||
topic={this.props.topic}
|
||||
title={this.state.fullName} />
|
||||
</div>
|
||||
<div className="caller-name">{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
|
||||
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></div>
|
||||
</div>
|
||||
<div id="controls">
|
||||
{this.props.callState == CALL_STATE_INCOMING_RECEIVED ?
|
||||
<>
|
||||
<button className="danger" onClick={this.handleRejectCall}>
|
||||
<i className="material-icons">call_end</i>
|
||||
</button>
|
||||
<button className="positive" onClick={this.handleAcceptCall}>
|
||||
<i className="material-icons">call</i>
|
||||
</button>
|
||||
</>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Chat message describing a video or voice call.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { secondsToTime } from '../lib/strformat.js';
|
||||
|
||||
export default class CallMessage extends React.PureComponent {
|
||||
render() {
|
||||
const isCallDropped = ['busy', 'declined', 'disconnected', 'missed'].includes(this.props.callState);
|
||||
const successClass = 'material-icons medium ' + (isCallDropped ? 'red' : 'green');
|
||||
const callIcon = this.props.incoming ?
|
||||
(isCallDropped ? 'call_missed' : 'call_received') :
|
||||
(isCallDropped ? 'call_missed_outgoing' : 'call_made');
|
||||
const title = this.props.incoming ?
|
||||
<FormattedMessage id="calls_incoming" defaultMessage="Incoming call" description="Incoming call label" /> :
|
||||
<FormattedMessage id="calls_outgoing" defaultMessage="Outgoing call" description="Outgoing call label" />;
|
||||
let duration;
|
||||
if (isCallDropped) {
|
||||
switch (this.props.callState) {
|
||||
case 'busy':
|
||||
duration = <FormattedMessage id="call_busy" defaultMessage="busy" description="Label for busy call line" />;
|
||||
break;
|
||||
case 'declined':
|
||||
duration = <FormattedMessage id="call_declined" defaultMessage="declined" description="Label for declined call" />;
|
||||
break;
|
||||
case 'missed':
|
||||
duration = this.props.incoming ?
|
||||
<FormattedMessage id="call_missed" defaultMessage="missed" description="Label for missed incoming call" /> :
|
||||
<FormattedMessage id="call_cancelled" defaultMessage="cancelled" description="Label for cancelled outgoing call" />;
|
||||
break;
|
||||
default:
|
||||
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
||||
}
|
||||
return <div className="call-message">
|
||||
<div><i className="material-icons big gray">call</i></div>
|
||||
<div className="flex-column narrow">
|
||||
<div>{title}</div>
|
||||
<div className="duration"><i className={successClass}>{callIcon}</i> {duration}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// CallPanel displays call in progress: local and remote viewports and controls.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
import { MAX_PEER_TITLE_LENGTH } from '../config.js';
|
||||
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
|
||||
const RING_SOUND = new Audio('audio/call-out.m4a');
|
||||
RING_SOUND.loop = true;
|
||||
const CALL_ENDED_SOUND = new Audio('audio/call-end.m4a');
|
||||
CALL_ENDED_SOUND.loop = true;
|
||||
const DIALING_SOUND = new Audio('audio/dialing.m4a');
|
||||
|
||||
const VIDEO_MUTED_EVENT = 'video:muted';
|
||||
const VIDEO_UNMUTED_EVENT = 'video:unmuted';
|
||||
|
||||
const messages = defineMessages({
|
||||
already_in_call: {
|
||||
id: 'already_in_call',
|
||||
defaultMessage: 'You already in an ongoing call!',
|
||||
description: 'Error message when the user tried to accept a new call without finishing pervious one',
|
||||
}
|
||||
});
|
||||
|
||||
class CallPanel extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
localStream: undefined,
|
||||
remoteStream: undefined,
|
||||
pc: undefined,
|
||||
dataChannel: undefined,
|
||||
|
||||
previousOnInfo: undefined,
|
||||
waitingForPeer: false,
|
||||
// If true, the client has received a remote SDP from the peer and has sent a local SDP to the peer.
|
||||
callInitialSetupComplete: false,
|
||||
audioOnly: props.callAudioOnly,
|
||||
// Video mute/unmute in progress.
|
||||
videoToggleInProgress: false,
|
||||
// Indicates if the remote peer has informed us that their camera is on.
|
||||
remoteVideoLive: false,
|
||||
};
|
||||
|
||||
this.localStreamConstraints = {
|
||||
audio: true,
|
||||
video: !props.callAudioOnly
|
||||
};
|
||||
this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED;
|
||||
|
||||
this.containerRef = React.createRef();
|
||||
this.localRef = React.createRef();
|
||||
this.remoteRef = React.createRef();
|
||||
|
||||
// Cache for remote ice candidates until initial setup gets completed.
|
||||
this.remoteIceCandidatesCache = [];
|
||||
|
||||
this.onInfo = this.onInfo.bind(this);
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
|
||||
this.createPeerConnection = this.createPeerConnection.bind(this);
|
||||
this.canSendOffer = this.canSendOffer.bind(this);
|
||||
this.drainRemoteIceCandidatesCache = this.drainRemoteIceCandidatesCache.bind(this);
|
||||
|
||||
this.handleNegotiationNeededEvent = this.handleNegotiationNeededEvent.bind(this);
|
||||
this.handleICECandidateEvent = this.handleICECandidateEvent.bind(this);
|
||||
this.handleNewICECandidateMsg = this.handleNewICECandidateMsg.bind(this);
|
||||
this.handleICEConnectionStateChangeEvent = this.handleICEConnectionStateChangeEvent.bind(this);
|
||||
this.handleSignalingStateChangeEvent = this.handleSignalingStateChangeEvent.bind(this);
|
||||
this.handleICEGatheringStateChangeEvent = this.handleICEGatheringStateChangeEvent.bind(this);
|
||||
this.handleIceCandidateErrorEvent = this.handleIceCandidateErrorEvent.bind(this);
|
||||
this.handleTrackEvent = this.handleTrackEvent.bind(this);
|
||||
|
||||
this.handleVideoOfferMsg = this.handleVideoOfferMsg.bind(this);
|
||||
this.handleVideoAnswerMsg = this.handleVideoAnswerMsg.bind(this);
|
||||
this.handleNewICECandidateMsg = this.handleNewICECandidateMsg.bind(this);
|
||||
|
||||
this.reportError = this.reportError.bind(this);
|
||||
this.handleGetUserMediaError = this.handleGetUserMediaError.bind(this);
|
||||
|
||||
this.stopTracks = this.stopTracks.bind(this);
|
||||
this.disconnectMedia = this.disconnectMedia.bind(this);
|
||||
|
||||
this.handleCloseClick = this.handleCloseClick.bind(this);
|
||||
this.handleToggleCameraClick = this.handleToggleCameraClick.bind(this);
|
||||
this.handleToggleMicClick = this.handleToggleMicClick.bind(this);
|
||||
|
||||
this.handleRemoteHangup = this.handleRemoteHangup.bind(this);
|
||||
this.handleVideoCallAccepted = this.handleVideoCallAccepted.bind(this);
|
||||
|
||||
this.muteVideo = this.muteVideo.bind(this);
|
||||
this.unmuteVideo = this.unmuteVideo.bind(this);
|
||||
this.emptyVideoTrack = this.emptyVideoTrack.bind(this);
|
||||
|
||||
this.handleDataChannelEvent = this.handleDataChannelEvent.bind(this);
|
||||
this.handleDataChannelError = this.handleDataChannelError.bind(this);
|
||||
this.handleDataChannelMessage = this.handleDataChannelMessage.bind(this);
|
||||
this.handleDataChannelOpen = this.handleDataChannelOpen.bind(this);
|
||||
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
|
||||
|
||||
this.bounds = {};
|
||||
|
||||
this.dragStart = (event) => {
|
||||
if (!event.target.classList.contains('draggable')) {
|
||||
// Prevent dragging when clicking on buttons or other non-draggable elements.
|
||||
return;
|
||||
}
|
||||
if (window.matchMedia('(width<=640px)')) {
|
||||
// No dragging on small screens.
|
||||
return;
|
||||
}
|
||||
if (this.props.minimized && this.containerRef) {
|
||||
event.stopPropagation();
|
||||
this.containerRef.current.setPointerCapture(event.pointerId);
|
||||
this.containerRef.current.style.cursor = 'grabbing';
|
||||
this.bounds.left = this.containerRef.current.offsetLeft;
|
||||
this.bounds.top = this.containerRef.current.offsetTop;
|
||||
this.bounds.width = this.containerRef.current.offsetWidth;
|
||||
this.bounds.height = this.containerRef.current.offsetHeight;
|
||||
|
||||
const parent = this.containerRef.current.offsetParent;
|
||||
this.bounds.maxX = parent.offsetWidth;
|
||||
this.bounds.maxY = parent.offsetHeight;
|
||||
}
|
||||
};
|
||||
this.drag = (event) => {
|
||||
event.stopPropagation();
|
||||
const el = this.containerRef.current;
|
||||
if (this.props.minimized && this.containerRef && el.hasPointerCapture(event.pointerId)) {
|
||||
// Calculate new position. Restrict to the visible area of the parent element.
|
||||
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0),
|
||||
this.bounds.maxX - this.bounds.width);
|
||||
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0),
|
||||
this.bounds.maxY - this.bounds.height);
|
||||
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
}
|
||||
};
|
||||
this.dragEnd = (event) => {
|
||||
event.stopPropagation();
|
||||
if (this.props.minimized && this.containerRef) {
|
||||
this.containerRef.current.releasePointerCapture(event.pointerId);
|
||||
this.containerRef.current.style.cursor = 'grab';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
this.previousOnInfo = topic.onInfo;
|
||||
topic.onInfo = this.onInfo;
|
||||
if ((this.props.callState == CALL_STATE_OUTGOING_INITATED ||
|
||||
this.props.callState == CALL_STATE_IN_PROGRESS) && this.localRef.current) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
if (this.containerRef) {
|
||||
this.containerRef.current.addEventListener('pointerdown', this.dragStart);
|
||||
this.containerRef.current.addEventListener('pointermove', this.drag);
|
||||
this.containerRef.current.addEventListener('pointerup', this.dragEnd);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
topic.onInfo = this.previousOnInfo;
|
||||
this.stop();
|
||||
|
||||
if (this.containerRef) {
|
||||
this.containerRef.current.removeEventListener('pointerdown', this.dragStart);
|
||||
this.containerRef.current.removeEventListener('pointermove', this.drag);
|
||||
this.containerRef.current.removeEventListener('pointerup', this.dragEnd);
|
||||
}
|
||||
}
|
||||
|
||||
handleVideoCallAccepted(info) {
|
||||
RING_SOUND.pause();
|
||||
// Create peer connection with a data channel.
|
||||
const pc = this.createPeerConnection(true);
|
||||
const stream = this.state.localStream;
|
||||
stream.getTracks().forEach(track => {
|
||||
pc.addTrack(track, stream);
|
||||
|
||||
if (track.kind == 'video' && this.state.audioOnly) {
|
||||
// This is an audio-only call.
|
||||
// Remove dummy video track (placeholder remains).
|
||||
track.enabled = false;
|
||||
track.stop();
|
||||
stream.removeTrack(track);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onInfo(info) {
|
||||
if (info.what != 'call') {
|
||||
return;
|
||||
}
|
||||
switch (info.event) {
|
||||
case 'accept':
|
||||
this.handleVideoCallAccepted(info);
|
||||
break;
|
||||
case 'answer':
|
||||
this.handleVideoAnswerMsg(info);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
this.handleNewICECandidateMsg(info);
|
||||
break;
|
||||
case 'hang-up':
|
||||
this.handleRemoteHangup(info);
|
||||
break;
|
||||
case 'offer':
|
||||
this.handleVideoOfferMsg(info);
|
||||
break;
|
||||
case 'ringing':
|
||||
// play() throws if the user did not click the app first: https://goo.gl/xX8pDD.
|
||||
RING_SOUND.play().catch(_ => {});
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown call event", info.event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Creates an empty video track placeholder.
|
||||
emptyVideoTrack() {
|
||||
const width = 640;
|
||||
const height = 480;
|
||||
const canvas = Object.assign(document.createElement("canvas"), {width, height});
|
||||
canvas.getContext('2d').fillRect(0, 0, width, height);
|
||||
const stream = canvas.captureStream(0);
|
||||
return Object.assign(stream.getVideoTracks()[0], {enabled: false});
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.state.localStream) {
|
||||
this.props.onError(this.props.intl.formatMessage(messages.already_in_call), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.callState == CALL_STATE_IN_PROGRESS) {
|
||||
// We apparently just accepted the call.
|
||||
this.props.onInvite(this.props.topic, this.props.seq, CALL_STATE_IN_PROGRESS, this.props.callAudioOnly);
|
||||
return;
|
||||
}
|
||||
|
||||
// This is an outgoing call waiting for the other side to pick up.
|
||||
// Start local video.
|
||||
navigator.mediaDevices.getUserMedia(this.localStreamConstraints)
|
||||
.then(stream => {
|
||||
if (!this.localStreamConstraints.video) {
|
||||
// Starting an audio-only call. Create a dummy video track
|
||||
// (so video can be enabled during the call if the user desires).
|
||||
stream.addTrack(this.emptyVideoTrack());
|
||||
}
|
||||
this.setState({localStream: stream, waitingForPeer: true});
|
||||
this.localRef.current.srcObject = stream;
|
||||
|
||||
DIALING_SOUND.play();
|
||||
|
||||
// Send call invitation.
|
||||
this.props.onInvite(this.props.topic, this.props.seq, this.props.callState, this.props.callAudioOnly);
|
||||
})
|
||||
.catch(this.handleGetUserMediaError);
|
||||
}
|
||||
|
||||
stop() {
|
||||
CALL_ENDED_SOUND.pause();
|
||||
CALL_ENDED_SOUND.currentTime = 0;
|
||||
RING_SOUND.pause();
|
||||
RING_SOUND.currentTime = 0;
|
||||
|
||||
this.stopTracks(this.state.localStream);
|
||||
this.stopTracks(this.state.remoteStream);
|
||||
this.disconnectMedia(this.localRef.current);
|
||||
this.disconnectMedia(this.remoteRef.current);
|
||||
if (this.state.pc) {
|
||||
this.state.pc.ontrack = null;
|
||||
this.state.pc.onremovetrack = null;
|
||||
this.state.pc.onremovestream = null;
|
||||
this.state.pc.onicecandidate = null;
|
||||
this.state.pc.oniceconnectionstatechange = null;
|
||||
this.state.pc.onsignalingstatechange = null;
|
||||
this.state.pc.onicegatheringstatechange = null;
|
||||
this.state.pc.onnegotiationneeded = null;
|
||||
this.state.pc.onicecandidateerror = null;
|
||||
this.state.pc.ondatachannel = null;
|
||||
|
||||
if (this.state.dataChannel && (this.state.dataChannel.readyState == 'open' || this.state.dataChannel.readyState == 'connecting')) {
|
||||
this.state.dataChannel.close();
|
||||
}
|
||||
this.state.pc.close();
|
||||
}
|
||||
this.setState({pc: null, waitingForPeer: false});
|
||||
}
|
||||
|
||||
disconnectMedia(el) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.srcObject = null;
|
||||
el.src = '';
|
||||
}
|
||||
|
||||
stopTracks(stream) {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tracks = stream.getTracks();
|
||||
if (tracks) {
|
||||
tracks.forEach(track => {
|
||||
track.stop();
|
||||
track.enabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleDataChannelError(error) {
|
||||
console.error('data channel error', error);
|
||||
}
|
||||
|
||||
handleDataChannelMessage(event) {
|
||||
switch (event.data) {
|
||||
case VIDEO_MUTED_EVENT:
|
||||
this.setState({remoteVideoLive: false}, _ => { this.remoteRef.current.srcObject = this.state.remoteStream; });
|
||||
break;
|
||||
case VIDEO_UNMUTED_EVENT:
|
||||
this.setState({remoteVideoLive: true}, _ => { this.remoteRef.current.srcObject = this.state.remoteStream; });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleDataChannelOpen(event) {
|
||||
if (!this.state.audioOnly) {
|
||||
event.target.send(VIDEO_UNMUTED_EVENT);
|
||||
}
|
||||
}
|
||||
|
||||
handleDataChannelClose(event) {
|
||||
console.log('close data channel:', event);
|
||||
}
|
||||
|
||||
handleDataChannelEvent(event) {
|
||||
console.log('data channel event:', event);
|
||||
const channel = event.channel;
|
||||
channel.onerror = this.handleDataChannelError;
|
||||
channel.onmessage = this.handleDataChannelMessage;
|
||||
channel.onopen = this.handleDataChannelOpen;
|
||||
channel.onclose = this.handleDataChannelClose;
|
||||
this.setState({dataChannel: channel});
|
||||
}
|
||||
|
||||
createPeerConnection(withDataChannel) {
|
||||
const iceServers = this.props.tinode.getServerParam('iceServers', null);
|
||||
const pc = iceServers ? new RTCPeerConnection({iceServers: iceServers}) : new RTCPeerConnection();
|
||||
|
||||
pc.onicecandidate = this.handleICECandidateEvent;
|
||||
pc.oniceconnectionstatechange = this.handleICEConnectionStateChangeEvent;
|
||||
pc.onicegatheringstatechange = this.handleICEGatheringStateChangeEvent;
|
||||
pc.onsignalingstatechange = this.handleSignalingStateChangeEvent;
|
||||
pc.onnegotiationneeded = this.handleNegotiationNeededEvent;
|
||||
pc.onicecandidateerror = this.handleIceCandidateErrorEvent;
|
||||
pc.ontrack = this.handleTrackEvent;
|
||||
pc.ondatachannel = this.handleDataChannelEvent;
|
||||
|
||||
let stateUpdate = {pc: pc, waitingForPeer: false};
|
||||
if (withDataChannel) {
|
||||
// Create data channel for exchanging events.
|
||||
const channel = pc.createDataChannel("events", {ordered: true});
|
||||
channel.onerror = this.handleDataChannelError;
|
||||
channel.onmessage = this.handleDataChannelMessage;
|
||||
channel.onopen = this.handleDataChannelOpen;
|
||||
channel.onclose = this.handleDataChannelClose;
|
||||
stateUpdate.dataChannel = channel;
|
||||
}
|
||||
this.setState(stateUpdate);
|
||||
return pc;
|
||||
}
|
||||
|
||||
handleVideoAnswerMsg(info) {
|
||||
// Configure the remote description, which is the SDP payload
|
||||
// in 'info' message.
|
||||
const desc = new RTCSessionDescription(info.payload);
|
||||
this.state.pc.setRemoteDescription(desc)
|
||||
.then(_ => {
|
||||
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
|
||||
})
|
||||
.catch(err => this.reportError(err));
|
||||
}
|
||||
|
||||
reportError(err) {
|
||||
this.props.onError(err.message, 'err');
|
||||
}
|
||||
|
||||
canSendOffer() {
|
||||
return this.isOutgoingCall || this.state.callInitialSetupComplete;
|
||||
}
|
||||
|
||||
handleNegotiationNeededEvent(event) {
|
||||
const pc = event.target;
|
||||
if (!this.canSendOffer()) {
|
||||
return;
|
||||
}
|
||||
pc.createOffer().then(offer => {
|
||||
return pc.setLocalDescription(offer);
|
||||
})
|
||||
.then(_ => {
|
||||
this.props.onSendOffer(this.props.topic, this.props.seq, pc.localDescription.toJSON());
|
||||
})
|
||||
.catch(err => this.reportError(err));
|
||||
}
|
||||
|
||||
handleIceCandidateErrorEvent(event) {
|
||||
console.warn("ICE candidate error:", event);
|
||||
}
|
||||
|
||||
handleICECandidateEvent(event) {
|
||||
if (event.candidate) {
|
||||
this.props.onIceCandidate(this.props.topic, this.props.seq, event.candidate.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
handleNewICECandidateMsg(info) {
|
||||
const candidate = new RTCIceCandidate(info.payload);
|
||||
if (this.state.callInitialSetupComplete) {
|
||||
this.state.pc.addIceCandidate(candidate)
|
||||
.catch(err => {
|
||||
if (candidate.candidate) {
|
||||
this.reportError(err);
|
||||
}
|
||||
console.warn("Error adding new ice candidate", candidate, err);
|
||||
});
|
||||
} else {
|
||||
this.remoteIceCandidatesCache.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
drainRemoteIceCandidatesCache() {
|
||||
this.remoteIceCandidatesCache.forEach(candidate => {
|
||||
this.state.pc.addIceCandidate(candidate)
|
||||
.catch(err => {
|
||||
if (candidate.candidate) {
|
||||
this.reportError(err);
|
||||
}
|
||||
console.warn("Error adding cached ice candidate", candidate, err);
|
||||
});
|
||||
});
|
||||
this.remoteIceCandidatesCache = [];
|
||||
}
|
||||
|
||||
handleICEConnectionStateChangeEvent(event) {
|
||||
switch (event.target.iceConnectionState) {
|
||||
case 'closed':
|
||||
case 'failed':
|
||||
this.handleCloseClick();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleSignalingStateChangeEvent(event) {
|
||||
if (event.target.signalingState == 'closed') {
|
||||
this.handleCloseClick();
|
||||
}
|
||||
}
|
||||
|
||||
handleICEGatheringStateChangeEvent(event) {
|
||||
// ICE gathering change state
|
||||
}
|
||||
|
||||
handleTrackEvent(event) {
|
||||
// Remote video becomes available.
|
||||
this.remoteRef.current.srcObject = event.streams[0];
|
||||
this.setState({remoteStream: event.streams[0]});
|
||||
}
|
||||
|
||||
handleGetUserMediaError(e) {
|
||||
console.error("Error opening camera and/or microphone", e);
|
||||
switch(e.name) {
|
||||
case 'NotFoundError':
|
||||
// Cannot start the call b/c no camera and/or microphone found.
|
||||
this.reportError(e);
|
||||
break;
|
||||
case 'SecurityError':
|
||||
case 'PermissionDeniedError':
|
||||
// Do nothing; this is the same as the user canceling the call.
|
||||
break;
|
||||
default:
|
||||
this.reportError(e);
|
||||
break;
|
||||
}
|
||||
|
||||
// Make sure we shut down our end of the RTCPeerConnection so we're
|
||||
// ready to try again.
|
||||
this.handleCloseClick();
|
||||
}
|
||||
|
||||
handleVideoOfferMsg(info) {
|
||||
let localStream = null;
|
||||
// If needed, create peer connection.
|
||||
// Not creating data channel since it should be created by the peer.
|
||||
const pc = this.state.pc ? this.state.pc : this.createPeerConnection(false);
|
||||
const desc = new RTCSessionDescription(info.payload);
|
||||
|
||||
pc.setRemoteDescription(desc).then(_ => {
|
||||
return navigator.mediaDevices.getUserMedia(this.localStreamConstraints);
|
||||
})
|
||||
.then(stream => {
|
||||
let dummyVideo;
|
||||
if (!this.localStreamConstraints.video) {
|
||||
// Starting an audio-only call. Create an empty video track so
|
||||
// so the user can enable the video during the call.
|
||||
dummyVideo = this.emptyVideoTrack();
|
||||
stream.addTrack(dummyVideo);
|
||||
}
|
||||
localStream = stream;
|
||||
this.localRef.current.srcObject = stream;
|
||||
this.setState({localStream: stream});
|
||||
|
||||
localStream.getTracks().forEach(track => {
|
||||
pc.addTrack(track, localStream);
|
||||
});
|
||||
|
||||
if (dummyVideo) {
|
||||
dummyVideo.enabled = false;
|
||||
dummyVideo.stop();
|
||||
stream.removeTrack(dummyVideo);
|
||||
}
|
||||
})
|
||||
.then(_ => {
|
||||
return pc.createAnswer();
|
||||
})
|
||||
.then(answer => {
|
||||
return pc.setLocalDescription(answer);
|
||||
})
|
||||
.then(_ => {
|
||||
this.props.onSendAnswer(this.props.topic, this.props.seq, pc.localDescription.toJSON());
|
||||
this.setState({ callInitialSetupComplete: true }, _ => this.drainRemoteIceCandidatesCache());
|
||||
})
|
||||
.catch(this.handleGetUserMediaError);
|
||||
}
|
||||
|
||||
// Call disconnected by remote.
|
||||
handleRemoteHangup() {
|
||||
if (!this.state.waitingForPeer) {
|
||||
// This is live call, just hang up.
|
||||
this.handleCloseClick();
|
||||
} else {
|
||||
// This is a call which is not yet connected.
|
||||
// Stop pulse animation.
|
||||
this.setState({waitingForPeer: false});
|
||||
// Change sound and wait a bit before ending it.
|
||||
RING_SOUND.pause();
|
||||
RING_SOUND.currentTime = 0;
|
||||
CALL_ENDED_SOUND.loop = true;
|
||||
CALL_ENDED_SOUND.play().catch(_ => {});
|
||||
setTimeout(_ => {
|
||||
this.handleCloseClick();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
handleCloseClick() {
|
||||
this.stop();
|
||||
this.props.onHangup(this.props.topic, this.props.seq);
|
||||
}
|
||||
|
||||
// Ends video track and turns off the camera.
|
||||
muteVideo() {
|
||||
if (!this.state.pc || !this.state.dataChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = this.state.localStream;
|
||||
const t = stream.getVideoTracks()[0];
|
||||
t.enabled = false;
|
||||
t.stop();
|
||||
|
||||
stream.removeTrack(t);
|
||||
this.state.dataChannel.send(VIDEO_MUTED_EVENT);
|
||||
this.setState({videoToggleInProgress: false});
|
||||
}
|
||||
|
||||
unmuteVideo() {
|
||||
if (!this.state.pc || !this.state.dataChannel) {
|
||||
return;
|
||||
}
|
||||
navigator.mediaDevices.getUserMedia({ video: true })
|
||||
.then(stream => {
|
||||
// Will extract video track from stream and throw stream away,
|
||||
// and replace video track in the media sender.
|
||||
this.localRef.current.srcObject = null;
|
||||
const sender = this.state.pc.getSenders().find(s => s.track.kind == 'video');
|
||||
const track = stream.getVideoTracks()[0];
|
||||
// Remote track from new stream.
|
||||
stream.removeTrack(track);
|
||||
// Add this track to the existing local stream.
|
||||
this.state.localStream.addTrack(track);
|
||||
return sender.replaceTrack(track);
|
||||
})
|
||||
.then(_ => {
|
||||
this.localRef.current.srcObject = this.state.localStream;
|
||||
this.state.dataChannel.send(VIDEO_UNMUTED_EVENT);
|
||||
})
|
||||
.catch(err => this.handleGetUserMediaError(err))
|
||||
.finally(_ => { this.setState({videoToggleInProgress: false}); }); // Make sure we redraw the mute/unmute icons (e.g. camera -> camera_off).
|
||||
}
|
||||
|
||||
handleToggleCameraClick() {
|
||||
if (this.state.videoToggleInProgress) {
|
||||
// Toggle currently in progress.
|
||||
return;
|
||||
}
|
||||
const tracks = this.state.localStream.getVideoTracks();
|
||||
this.setState({videoToggleInProgress: true}, _ => {
|
||||
if (tracks && tracks.length > 0 && tracks[0].enabled && tracks[0].readyState == 'live') {
|
||||
this.muteVideo();
|
||||
} else {
|
||||
this.unmuteVideo();
|
||||
}
|
||||
this.setState({audioOnly: !this.state.audioOnly});
|
||||
});
|
||||
}
|
||||
|
||||
handleToggleMicClick() {
|
||||
const stream = this.state.localStream;
|
||||
const t = stream.getAudioTracks()[0];
|
||||
t.enabled = !t.enabled;
|
||||
// Make sure we redraw the mute/unmute icons (e.g. mic -> mic_off).
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
const audioTracks = this.state.localStream && this.state.localStream.getAudioTracks();
|
||||
const videoTracks = !this.state.audioOnly && this.state.localStream && this.state.localStream.getVideoTracks();
|
||||
const disabled = !this.state.pc || !this.state.dataChannel || !(audioTracks && audioTracks[0]);
|
||||
const audioIcon = audioTracks && audioTracks[0] && audioTracks[0].enabled ? 'mic' : 'mic_off';
|
||||
const videoIcon = videoTracks && videoTracks[0] && videoTracks[0].enabled && videoTracks[0].readyState == 'live' ? 'videocam' : 'videocam_off';
|
||||
const peerTitle = truncateString(this.props.title, MAX_PEER_TITLE_LENGTH);
|
||||
const pulseAnimation = this.state.waitingForPeer ? ' pulse' : '';
|
||||
|
||||
let remoteActive = false;
|
||||
if (this.remoteRef.current && this.remoteRef.current.srcObject && this.state.remoteVideoLive) {
|
||||
const rstream = this.remoteRef.current.srcObject;
|
||||
if (rstream.getVideoTracks().length > 0) {
|
||||
const t = rstream.getVideoTracks()[0];
|
||||
remoteActive = t.enabled && t.readyState == 'live';
|
||||
}
|
||||
}
|
||||
|
||||
const minimizedClass = this.props.minimized ? 'minimized' : null;
|
||||
const fullScreen = this.props.minimized ? 'fullscreen' : 'fullscreen_exit';
|
||||
|
||||
return (
|
||||
<div id="video-container" className={minimizedClass} ref={this.containerRef}>
|
||||
<div id="video-container-panel">
|
||||
<div className="call-party self" disabled={this.state.audioOnly || this.props.minimized}>
|
||||
<video ref={this.localRef} autoPlay muted playsInline />
|
||||
<div className="caller-name inactive">
|
||||
<FormattedMessage id="calls_you_label"
|
||||
defaultMessage="You" description="Shown over the local video screen" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="call-party peer" disabled={!remoteActive}>
|
||||
{remoteActive ?
|
||||
<>
|
||||
<video ref={this.remoteRef} autoPlay playsInline />
|
||||
<div className="caller-name inactive">{peerTitle}</div>
|
||||
</> :
|
||||
<>
|
||||
<audio ref={this.remoteRef} autoPlay />
|
||||
<div className={`caller-card${pulseAnimation} draggable`}>
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.props.avatar}
|
||||
topic={this.props.topic}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
<div className="caller-name">{peerTitle}</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button className="full-screen" onClick={this.props.onToggleMinimize}>
|
||||
<i className="material-icons">{fullScreen}</i>
|
||||
</button>
|
||||
<div id="controls" className={minimizedClass}>
|
||||
<button className="danger" onClick={this.handleCloseClick}>
|
||||
<i className="material-icons">call_end</i>
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleToggleCameraClick} disabled={disabled}>
|
||||
<i className="material-icons">{videoIcon}</i>
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleToggleMicClick} disabled={disabled}>
|
||||
<i className="material-icons">{audioIcon}</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(CallPanel);
|
||||
@@ -0,0 +1,48 @@
|
||||
// The counter of unread messages in the topic.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { secondsToTime } from '../lib/strformat.js';
|
||||
|
||||
// Props:
|
||||
// incoming: true | false.
|
||||
// callState: 'busy' | 'declined' | 'disconnected' | 'missed'.
|
||||
// duration: <number> (call duration in milliseconds).
|
||||
export default class CallStatus extends React.PureComponent {
|
||||
render() {
|
||||
const isCallDropped = ['busy', 'declined', 'disconnected', 'missed'].includes(this.props.callState);
|
||||
const icon2 = this.props.incoming ?
|
||||
(isCallDropped ? 'call_missed' : 'call_received') :
|
||||
(isCallDropped ? 'call_missed_outgoing' : 'call_made');
|
||||
let duration;
|
||||
if (isCallDropped) {
|
||||
switch (this.props.callState) {
|
||||
case 'busy':
|
||||
duration = <FormattedMessage id="call_busy" defaultMessage="busy" description="Label for busy call line" />;
|
||||
break;
|
||||
case 'declined':
|
||||
duration = <FormattedMessage id="call_declined" defaultMessage="declined" description="Label for declined call" />;
|
||||
break;
|
||||
case 'missed':
|
||||
duration = this.props.incoming ?
|
||||
<FormattedMessage id="call_missed" defaultMessage="missed" description="Label for missed incoming call" /> :
|
||||
<FormattedMessage id="call_cancelled" defaultMessage="cancelled" description="Label for cancelled outgoing call" />;
|
||||
break;
|
||||
default:
|
||||
duration = <FormattedMessage id="call_disconnected" defaultMessage="disconnected" description="Label for disconnected call" />;
|
||||
break;
|
||||
}
|
||||
} else if (['accepted', 'started'].includes(this.props.callState) && !this.props.duration) {
|
||||
duration = <FormattedMessage id="call_in_progress" defaultMessage="in progress" description="Label for call in progress" />
|
||||
} else {
|
||||
duration = <span>{secondsToTime(this.props.duration / 1000)}</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="composed-material">
|
||||
<i className="material-icons">call</i><i className="material-icons second">{icon2}</i>
|
||||
</div> {duration}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
// Single message, sent or received.
|
||||
// Single chat message bubble, sent or received.
|
||||
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
import { Drafty, Tinode } from 'tinode-sdk';
|
||||
|
||||
import Attachment from './attachment.jsx';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import ReceivedMarker from './received-marker.jsx'
|
||||
|
||||
import { fullFormatter, quoteFormatter } from '../lib/formatters.js';
|
||||
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
|
||||
import { fullFormatter } from '../lib/formatters.js';
|
||||
import { sanitizeUrl } from '../lib/utils.js';
|
||||
|
||||
class BaseChatMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
@@ -22,28 +23,30 @@ class BaseChatMessage extends React.PureComponent {
|
||||
props.uploader.onProgress = this.handleProgress.bind(this);
|
||||
}
|
||||
|
||||
this.handleImagePreview = this.handleImagePreview.bind(this);
|
||||
this.handleExpandImage = this.handleExpandImage.bind(this);
|
||||
this.handlePlayVideo = this.handlePlayVideo.bind(this);
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
|
||||
this.formatterContext = {
|
||||
getFormatter: (tp) => { return tp == 'QQ' ? quoteFormatter : null; },
|
||||
formatMessage: props.intl.formatMessage.bind(props.intl),
|
||||
viewportWidth: props.viewportWidth,
|
||||
authorizeURL: props.tinode.authorizeURL.bind(props.tinode),
|
||||
onImagePreview: this.handleImagePreview,
|
||||
|
||||
onImagePreview: this.handleExpandImage,
|
||||
onVideoPreview: this.handlePlayVideo,
|
||||
onFormButtonClick: this.handleFormButtonClick,
|
||||
onQuoteClick: this.handleQuoteClick
|
||||
};
|
||||
}
|
||||
|
||||
handleImagePreview(e) {
|
||||
handleExpandImage(e) {
|
||||
e.preventDefault();
|
||||
this.props.onImagePreview({
|
||||
this.props.onExpandMedia({
|
||||
url: e.target.src,
|
||||
filename: e.target.title,
|
||||
filename: e.target.dataset.name,
|
||||
width: e.target.dataset.width,
|
||||
height: e.target.dataset.height,
|
||||
size: e.target.dataset.size,
|
||||
@@ -51,6 +54,21 @@ class BaseChatMessage extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
handlePlayVideo(e) {
|
||||
e.preventDefault();
|
||||
this.props.onExpandMedia({
|
||||
video: true,
|
||||
url: e.target.dataset.src,
|
||||
preview: e.target.src,
|
||||
filename: e.target.dataset.name,
|
||||
width: e.target.dataset.width,
|
||||
height: e.target.dataset.height,
|
||||
duration: e.target.dataset.duration,
|
||||
size: e.target.dataset.size,
|
||||
type: e.target.dataset.mime
|
||||
});
|
||||
}
|
||||
|
||||
handleFormButtonClick(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
@@ -75,22 +93,43 @@ class BaseChatMessage extends React.PureComponent {
|
||||
if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||
menuItems.push('menu_item_send_retry');
|
||||
}
|
||||
if (this.props.userIsWriter &&
|
||||
this.props.received > Tinode.MESSAGE_STATUS_FAILED &&
|
||||
this.props.received < Tinode.MESSAGE_STATUS_DEL_RANGE) {
|
||||
menuItems.push('menu_item_reply');
|
||||
if (this.props.received > Tinode.MESSAGE_STATUS_FATAL) {
|
||||
if (this.props.userIsWriter) {
|
||||
menuItems.push('menu_item_reply');
|
||||
if (!this.props.response) {
|
||||
let immutable = false;
|
||||
Drafty.entities(this.props.content, (_0, _1, tp) => {
|
||||
immutable = ['AU', 'EX', 'FM', 'IM', 'VC', 'VD'].includes(tp);
|
||||
return immutable;
|
||||
});
|
||||
if (!immutable) {
|
||||
Drafty.styles(this.props.content, tp => {
|
||||
immutable = ['QQ'].includes(tp);
|
||||
return immutable;
|
||||
});
|
||||
}
|
||||
if (!immutable) {
|
||||
menuItems.push('menu_item_edit');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.props.userIsAdmin) {
|
||||
menuItems.push(this.props.pinned ? 'menu_item_unpin' : 'menu_item_pin');
|
||||
}
|
||||
}
|
||||
menuItems.push('menu_item_forward');
|
||||
|
||||
this.props.showContextMenu({
|
||||
seq: this.props.seq,
|
||||
replace: this.props.edited ? parseInt(this.props.edited.split(':')[1]) : 0,
|
||||
content: this.props.content,
|
||||
userFrom: this.props.userFrom,
|
||||
userName: this.props.userName,
|
||||
forwarded: this.props.forwarded,
|
||||
y: e.pageY,
|
||||
x: e.pageX,
|
||||
pickReply: this.props.pickReply
|
||||
pickReply: this.props.pickReply,
|
||||
editMessage: this.props.editMessage,
|
||||
timestamp: this.props.timestamp,
|
||||
}, menuItems);
|
||||
}
|
||||
|
||||
@@ -112,19 +151,19 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const sideClass = this.props.deleted ? 'center' :
|
||||
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
|
||||
const sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
|
||||
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
|
||||
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
|
||||
const fullDisplay = (this.props.isGroup && this.props.response && !this.props.deleted &&
|
||||
const avatar = this.props.userAvatar || true;
|
||||
let textSizeClass = 'message-content';
|
||||
const fullDisplay = (this.props.isGroup && this.props.response &&
|
||||
(this.props.sequence == 'single' || this.props.sequence == 'last'));
|
||||
|
||||
let content = this.props.content;
|
||||
const attachments = [];
|
||||
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
|
||||
Drafty.attachments(content, (att, i) => {
|
||||
if (att.mime == 'application/json') {
|
||||
// Don't show json objects as attachments.
|
||||
if (Drafty.isFormResponseType(att.mime)) {
|
||||
// Don't show json drafty form response objects as attachments.
|
||||
// They are not meant for users.
|
||||
return;
|
||||
}
|
||||
@@ -142,13 +181,14 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}, this);
|
||||
const tree = Drafty.format(content, fullFormatter, this.formatterContext);
|
||||
content = React.createElement(React.Fragment, null, tree);
|
||||
} 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') {
|
||||
} else if (typeof content == 'string') {
|
||||
// Make font bigger for emoji-only messages.
|
||||
// Must use 'new RegExp(...)' because React does not like /{1-5}/.
|
||||
if (new RegExp('^\\p{RGI_Emoji}{1,5}$', 'v').test(content || '')) {
|
||||
// Content consists of 1-5 emoji characters. Count how many and use it to increase the font size.
|
||||
textSizeClass += ' emoji-' + (content || '').match(/\p{RGI_Emoji}/vg).length;
|
||||
}
|
||||
} else {
|
||||
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
defaultMessage="invalid content" description="Shown when the message is unreadable" /></i></>
|
||||
@@ -170,23 +210,23 @@ class BaseChatMessage extends React.PureComponent {
|
||||
<div>
|
||||
<div className={bubbleClass}>
|
||||
<div className="content-meta">
|
||||
<div className="message-content">
|
||||
<div className={textSizeClass}>
|
||||
{content}
|
||||
{attachments}
|
||||
</div>
|
||||
{this.props.timestamp ?
|
||||
<ReceivedMarker
|
||||
edited={this.props.edited}
|
||||
timestamp={this.props.timestamp}
|
||||
received={this.props.received} />
|
||||
: null}
|
||||
</div>
|
||||
{this.props.deleted || !this.props.showContextMenu ?
|
||||
null :
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
<a href="#" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span>
|
||||
</span> : null
|
||||
}
|
||||
</div>
|
||||
{fullDisplay ?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
/* BEGIN CheckBox: styled checkbox */
|
||||
/* CheckBox: styled three-state checkbox, either clickable or static */
|
||||
export default class CheckBox extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -13,19 +13,38 @@ export default class CheckBox extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
this.props.onChange ? (
|
||||
this.props.checked === true ?
|
||||
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
|
||||
this.props.checked === false ?
|
||||
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i> :
|
||||
<i className="material-icons lt-blue">indeterminate_check_box</i>
|
||||
) : (
|
||||
this.props.checked ?
|
||||
<i className="material-icons">check_box</i> :
|
||||
<i className="material-icons">check_box_outline_blank</i>
|
||||
)
|
||||
);
|
||||
let classList = ['material-icons'];
|
||||
let iconName;
|
||||
if (Array.isArray(this.props.className)) {
|
||||
classList.push(...this.props.className);
|
||||
} else if (this.props.className) {
|
||||
classList.push(this.props.className);
|
||||
}
|
||||
if (this.props.onChange) {
|
||||
if (this.props.checked) {
|
||||
classList.push('blue', 'clickable');
|
||||
iconName = 'check_box';
|
||||
} else if (this.props.checked === false) {
|
||||
classList.push('blue', 'clickable');
|
||||
iconName = 'check_box_outline_blank';
|
||||
} else {
|
||||
classList.push('lt-blue');
|
||||
iconName = 'indeterminate_check_box';
|
||||
}
|
||||
} else {
|
||||
if (this.props.checked) {
|
||||
iconName = 'check_box';
|
||||
} else {
|
||||
iconName = 'check_box_outline_blank';
|
||||
}
|
||||
}
|
||||
let attrs = {
|
||||
className: classList.join(' '),
|
||||
id: this.props.id,
|
||||
};
|
||||
if (this.props.onChange) {
|
||||
attrs.onClick = this.handleChange;
|
||||
}
|
||||
return React.createElement('i', attrs, iconName);
|
||||
}
|
||||
}
|
||||
/* END CheckBox */
|
||||
|
||||
@@ -44,7 +44,7 @@ export default class ChipInput extends React.Component {
|
||||
static indexChips(chips) {
|
||||
const index = {};
|
||||
let count = 0;
|
||||
chips.map((item) => {
|
||||
chips.forEach(item => {
|
||||
index[item.user] = count;
|
||||
count ++;
|
||||
});
|
||||
@@ -55,7 +55,7 @@ export default class ChipInput extends React.Component {
|
||||
static sortChips(chips, keep) {
|
||||
const required = [];
|
||||
const normal = [];
|
||||
chips.map((item) => {
|
||||
chips.forEach(item => {
|
||||
if (keep && keep.includes(item.user)) {
|
||||
required.push(item);
|
||||
} else {
|
||||
@@ -115,7 +115,7 @@ export default class ChipInput extends React.Component {
|
||||
const chips = [];
|
||||
let count = 0;
|
||||
const staticMembers = this.props.staticMembers || [];
|
||||
this.state.sortedChips.map((item) => {
|
||||
this.state.sortedChips.forEach(item => {
|
||||
chips.push(
|
||||
<Chip
|
||||
tinode={this.props.tinode}
|
||||
|
||||