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,8 +62,9 @@ More translations are welcome. See [instructions](https://github.com/tinode/chat
|
||||
* End-to-End encryption.
|
||||
* Emoji support is weak.
|
||||
* Mentions, hashtags.
|
||||
* Replying or forwarding messages.
|
||||
* Previews not generated for videos, audio, links or docs.
|
||||
* Previews not generated for links and docs.
|
||||
* Location and contact sharing.
|
||||
* Recording video messages.
|
||||
|
||||
## Other
|
||||
|
||||
@@ -56,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
|
||||
@@ -79,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-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 |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 620 B |
|
Before Width: | Height: | Size: 2.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@8/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8/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@8.9.1/firebase-app.js"></script>
|
||||
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@8.9.1/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,30 +1,31 @@
|
||||
{
|
||||
"name": "tinode-webapp",
|
||||
"description": "Tinode messenger for the web",
|
||||
"version": "0.17.10",
|
||||
"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",
|
||||
"build:i18n": "node scripts/pack-messages.js",
|
||||
"build:i18n": "node scripts/i18n.js",
|
||||
"i18n:missing": "node scripts/i18n.js missing",
|
||||
"i18n:obsolete": "node scripts/i18n.js obsolete",
|
||||
"vers": "node scripts/gen-version.js",
|
||||
"extract": "formatjs extract src/**/*.jsx --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
||||
"compile": "formatjs compile"
|
||||
"extract": "formatjs extract \"src/**/*.{js,jsx}\" --out-file src/i18n/ex/base-en.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
|
||||
"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",
|
||||
@@ -34,7 +35,6 @@
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"service-worker.js",
|
||||
"firebase-init.js",
|
||||
"version.js"
|
||||
],
|
||||
"keywords": [
|
||||
@@ -46,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": "^8.9.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intl": "^5.20.2",
|
||||
"tinode-sdk": "^0.17.10"
|
||||
"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": "^8.3.1",
|
||||
"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).
|
||||
@@ -0,0 +1,127 @@
|
||||
// 1. Updates translations files with new messages, mark no longer needed translations as obsolete.
|
||||
// 2. Takes all translations and groups them into a single file.
|
||||
// 3. When called with parameters 'missing' or 'obsolete' writes missing and obsolete translations to console.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const globSync = require('glob').sync;
|
||||
|
||||
const EXTRACTED_STRINGS = './src/i18n/ex/base-en.json';
|
||||
const BASE_LANG = 'en';
|
||||
const LANG_PATTERN = './src/i18n/*.json';
|
||||
const LANG_PATTERN_ONE = './src/i18n/%s.json';
|
||||
const OUTPUT_PATTERN = './src/i18n.min/%s.json';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getLocaleFromFileName(filename) {
|
||||
const parts = filename.split(path.sep);
|
||||
return parts[parts.length - 1].split('.json')[0];
|
||||
}
|
||||
|
||||
function printf(str) {
|
||||
const args = [].slice.call(arguments, 1);
|
||||
let i = 0;
|
||||
return str.replace(/%s/g, () => args[i++]);
|
||||
}
|
||||
|
||||
// Called with one or two parameters?
|
||||
if (args[0] == 'missing' || args[0] == 'obsolete') {
|
||||
// Extract missing or obsolete translations as requested.
|
||||
// Read either all or just one translation.
|
||||
const pattern = args[1] ? printf(LANG_PATTERN_ONE, args[1]) : LANG_PATTERN;
|
||||
const extracted = globSync(pattern)
|
||||
.map((filename) => {
|
||||
const locale = getLocaleFromFileName(filename);
|
||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
|
||||
const dest = {};
|
||||
// Copy messages while skipping missing and obsolete ones.
|
||||
for (let key in translated) {
|
||||
if ((args[0] == 'missing' && translated[key].missing) ||
|
||||
(args[0] == 'obsolete' && translated[key].obsolete)) {
|
||||
dest[key] = translated[key];
|
||||
}
|
||||
}
|
||||
|
||||
return { [locale]: dest };
|
||||
})
|
||||
.reduce((collection, descriptors) => {
|
||||
return { ...collection, ...descriptors };
|
||||
}, {});
|
||||
|
||||
console.log(JSON.stringify(extracted, null, 2)+"\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// The formatjs/cli saves extracted messages into a file {'message_id': {defaultMessage: 'default message text', description: '...'}, ...}.
|
||||
// Rewrite them into a single file en.json {'message_id': 'default message text', }.
|
||||
|
||||
// Read master-set of messages in default language.
|
||||
const baseMessages = JSON.parse(fs.readFileSync(EXTRACTED_STRINGS, 'utf8'));
|
||||
|
||||
// Find messages present in baseMessages but missing in translations and vice versa.
|
||||
// Mark missing or obsolete messages as such.
|
||||
globSync(LANG_PATTERN)
|
||||
.map((filename) => {
|
||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
const locale = getLocaleFromFileName(filename);
|
||||
|
||||
// Refresh existing entries with possible new descriptions and default messages, add missing.
|
||||
Object.entries(baseMessages).forEach((ent) => {
|
||||
const [key, value] = ent;
|
||||
let translation = translated[key] ? translated[key].translation : "";
|
||||
if (!translation && locale == BASE_LANG) {
|
||||
translation = value.defaultMessage;
|
||||
}
|
||||
|
||||
translated[key] = {
|
||||
translation: translation,
|
||||
defaultMessage: value.defaultMessage,
|
||||
description: value.description,
|
||||
missing: !translation,
|
||||
obsolete: false
|
||||
};
|
||||
});
|
||||
|
||||
// Find no longer needed messages.
|
||||
Object.entries(translated).forEach((ent) => {
|
||||
const [key, value] = ent;
|
||||
if (!baseMessages[key] && translated[key]) {
|
||||
// Obsolete, no longer missing.
|
||||
translated[key].missing = !translated[key].translation;
|
||||
translated[key].obsolete = true;
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
||||
});
|
||||
|
||||
// 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)
|
||||
.map((filename) => {
|
||||
const locale = getLocaleFromFileName(filename);
|
||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
|
||||
const dest = {};
|
||||
// Copy messages while skipping missing and obsolete ones.
|
||||
for (let key in translated) {
|
||||
if (!translated[key].missing && !translated[key].obsolete) {
|
||||
dest[key] = translated[key].translation;
|
||||
}
|
||||
}
|
||||
|
||||
return { [locale]: dest };
|
||||
})
|
||||
.reduce((collection, descriptors) => {
|
||||
return { ...collection, ...descriptors };
|
||||
}, {});
|
||||
|
||||
// Write minified translation files, one per language.
|
||||
for (let lang in messages) {
|
||||
fs.writeFileSync(printf(OUTPUT_PATTERN, lang), JSON.stringify(messages[lang]));
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// 1. Takes file with messages extracted via the React Intl Babel plugin and converts it into a file containing
|
||||
// `id: message` pairs in "en" locale.
|
||||
//
|
||||
// 2. Takes all translations and groups them into a single file.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const globSync = require('glob').sync;
|
||||
const mkdirpSync = require('mkdirp').sync;
|
||||
|
||||
const EXTRACTED_STRINGS = './src/i18n/ex/base-en.json';
|
||||
const DEFAULT_TRANSLATION = 'en.json';
|
||||
const LANG_DIR = './src/i18n/';
|
||||
const LANG_PATTERN = './src/i18n/*.json';
|
||||
const FINAL_FILE = './src/messages.json';
|
||||
const TRANSLATIONS = './src/translations.js'
|
||||
|
||||
const MISSING_MARKER = ':!![MISSING]!!:';
|
||||
const OBSOLETE_MARKER = ':!![OBSOLETE]!!:';
|
||||
|
||||
// The formatjs/cli saves extracted messages into a file {'message_id': {defaultMessage: 'default message text', description: '...'}, ...}.
|
||||
// Rewrite them into a single file en.json {'message_id': 'default message text', }.
|
||||
|
||||
// Read master-set of messages in default language.
|
||||
const baseMessages = JSON.parse(fs.readFileSync(EXTRACTED_STRINGS, 'utf8'));
|
||||
// Convert.
|
||||
const en = {};
|
||||
for (const [id, msg] of Object.entries(baseMessages)) {
|
||||
en[id] = msg.defaultMessage;
|
||||
}
|
||||
|
||||
// Write extracted strings into default language file.
|
||||
let filename = path.join(LANG_DIR, DEFAULT_TRANSLATION);
|
||||
mkdirpSync(path.dirname(filename));
|
||||
fs.writeFileSync(filename, JSON.stringify(en, null, 2)+"\n");
|
||||
|
||||
// Find messages present in en.json but missing in translations and vice versa.
|
||||
// Mark missing or obsolete messages as such in translation files.
|
||||
globSync(LANG_PATTERN)
|
||||
.map((filename) => {
|
||||
const parts = filename.split('/');
|
||||
if (parts[parts.length-1] != DEFAULT_TRANSLATION) {
|
||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
// Find missing messages.
|
||||
Object.entries(baseMessages).forEach((ent) => {
|
||||
if (!translated.hasOwnProperty(ent[0])) {
|
||||
// Missing translation.
|
||||
translated[ent[0]] = MISSING_MARKER + ent[1];
|
||||
} else if (translated[ent[0]].indexOf(OBSOLETE_MARKER) === 0) {
|
||||
// No longer obsolete. Remove obsolete marker.
|
||||
translated[ent[0]] = translated[ent[0]].substring(0, OBSOLETE_MARKER.length);
|
||||
}
|
||||
});
|
||||
// Find no longer needed messages.
|
||||
Object.entries(translated).forEach((ent) => {
|
||||
if (!baseMessages.hasOwnProperty(ent[0]) && translated.hasOwnProperty(ent[0])) {
|
||||
let val = translated[ent[0]];
|
||||
if (val.indexOf(MISSING_MARKER) === 0) {
|
||||
// No longer missing.
|
||||
val = val.substring(0, MISSING_MARKER.length);
|
||||
}
|
||||
if (val.indexOf(OBSOLETE_MARKER) !== 0) {
|
||||
val = OBSOLETE_MARKER + val;
|
||||
}
|
||||
translated[ent[0]] = val;
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
|
||||
}
|
||||
});
|
||||
|
||||
// List of all available translations.
|
||||
const languageList = {};
|
||||
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
|
||||
// while removing messages marked as missing or obsolete.
|
||||
|
||||
// Read all translations.
|
||||
const messages = globSync(LANG_PATTERN)
|
||||
.map((filename) => {
|
||||
const parts = filename.split('/');
|
||||
const locale = parts[parts.length - 1].split('.json')[0];
|
||||
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
languageList[locale] = locale;
|
||||
// Strip messages prefixed with MISSING or OBSOLETE markers.
|
||||
for (let id in translated) {
|
||||
// process.stdout.write("-- handling translation " + id + "; "+ String(translated[id]) + "; in " + String(filename) + "; --\n");
|
||||
if (translated[id].indexOf(OBSOLETE_MARKER) == 0 ||
|
||||
translated[id].indexOf(MISSING_MARKER) == 0) {
|
||||
delete translated[id];
|
||||
}
|
||||
}
|
||||
|
||||
return { [locale]: translated };
|
||||
})
|
||||
.reduce((collection, descriptors) => {
|
||||
return { ...collection, ...descriptors };
|
||||
}, {});
|
||||
|
||||
// Write file with all translations combined.
|
||||
mkdirpSync(path.dirname(FINAL_FILE));
|
||||
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages, null, 2)+"\n");
|
||||
@@ -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,75 +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') {
|
||||
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 || '',
|
||||
icon: '/img/logo96.png',
|
||||
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'.
|
||||
@@ -132,10 +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 cachedResponse = await cache.match(event.request);
|
||||
|
||||
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) });
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
@@ -144,7 +180,7 @@ self.addEventListener('fetch', event => {
|
||||
if (!response || response.status != 200 || response.type != 'basic') {
|
||||
return response;
|
||||
}
|
||||
if (event.request.url && (event.request.url.startsWith('http://') || event.request.url.startsWith('https://'))) {
|
||||
if (reqUrl.protocol == 'http:' || reqUrl.protocol == 'https:') {
|
||||
await cache.put(event.request, response.clone());
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -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;
|
||||
@@ -45,13 +43,19 @@ export const MEDIA_BREAKPOINT = 640;
|
||||
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
|
||||
export const REM_SIZE = 13;
|
||||
|
||||
// Size of the avatar image: When an avatar image is uploaded, it's resized to
|
||||
// a square of this size.
|
||||
export const AVATAR_SIZE = 128;
|
||||
// Size of the avatar image: when image dimensions are greater or the image is not square, it's resized to
|
||||
// a square of this size or less.
|
||||
export const AVATAR_SIZE = 384;
|
||||
|
||||
// Maximum size of an avatar in bytes for sending in-band. Bigger avatars will be sent out of band (as uploads).
|
||||
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;
|
||||
|
||||
@@ -61,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.
|
||||
@@ -75,21 +79,81 @@ 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"
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
|
||||
// Maximum length of user name, topic title, and private comment.
|
||||
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;
|
||||
|
||||
// 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 = 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';
|
||||
|
||||
@@ -19,22 +19,46 @@
|
||||
"defaultMessage": "Leave Conversation",
|
||||
"description": "Flat button [Leave Conversation]"
|
||||
},
|
||||
"action_more": {
|
||||
"defaultMessage": "More",
|
||||
"description": "Action for showing more content"
|
||||
},
|
||||
"action_report_chat": {
|
||||
"defaultMessage": "Report Conversation",
|
||||
"description": "Flat button [Report Group]"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"badge_danger": {
|
||||
"defaultMessage": "Untrustworthy",
|
||||
"description": "Suspicious or untrustworthy account or topic badge"
|
||||
},
|
||||
"badge_owner": {
|
||||
"defaultMessage": "owner",
|
||||
"description": "Badge for indicating the owner"
|
||||
},
|
||||
"badge_staff": {
|
||||
"defaultMessage": "Staff-managed",
|
||||
"description": "Explanation of a staff-managed account or topic badge"
|
||||
},
|
||||
"badge_verified": {
|
||||
"defaultMessage": "Verified/official",
|
||||
"description": "Explanation of a verified account or topic badge"
|
||||
},
|
||||
"badge_you": {
|
||||
"defaultMessage": "you",
|
||||
"description": "Badge for indicating the current user"
|
||||
@@ -47,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)"
|
||||
@@ -87,6 +107,10 @@
|
||||
"defaultMessage": "Reset",
|
||||
"description": "Button [Reset]"
|
||||
},
|
||||
"button_security": {
|
||||
"defaultMessage": "Security",
|
||||
"description": "Navigaton button for security panel."
|
||||
},
|
||||
"button_send_request": {
|
||||
"defaultMessage": "Send request",
|
||||
"description": "Button [Send request]"
|
||||
@@ -107,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"
|
||||
@@ -143,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"
|
||||
@@ -151,15 +231,27 @@
|
||||
"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"
|
||||
},
|
||||
"delete_account_arning": {
|
||||
"delete_account_warning": {
|
||||
"defaultMessage": "Are you sure you want to delete your account? It cannot be undone.",
|
||||
"description": "Warning message when deleting an account"
|
||||
},
|
||||
@@ -167,9 +259,9 @@
|
||||
"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"
|
||||
},
|
||||
"download_action": {
|
||||
"defaultMessage": "download",
|
||||
@@ -177,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",
|
||||
@@ -200,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"
|
||||
@@ -215,6 +327,13 @@
|
||||
"defaultMessage": "Forgot password?",
|
||||
"description": "Link to Reset password form"
|
||||
},
|
||||
"forward_to": {
|
||||
"defaultMessage": "Forward to"
|
||||
},
|
||||
"forward_to_search_placeholder": {
|
||||
"defaultMessage": "Search contacts",
|
||||
"description": "Contact search prompt when forwarding a message."
|
||||
},
|
||||
"full_name_prompt": {
|
||||
"defaultMessage": "Full name, e.g. John Doe",
|
||||
"description": "Input placeholder for person's full name"
|
||||
@@ -231,34 +350,82 @@
|
||||
"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 message is unreadable"
|
||||
"description": "Shown when the message is unreadable"
|
||||
},
|
||||
"invalid_security_token": {
|
||||
"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)"
|
||||
},
|
||||
"label_default_access": {
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Section title"
|
||||
},
|
||||
"label_default_access_mode": {
|
||||
"defaultMessage": "Default access mode:",
|
||||
"description": "Label for default access mode"
|
||||
},
|
||||
"label_description": {
|
||||
"defaultMessage": "Description",
|
||||
"description": "Label for editing topic description"
|
||||
},
|
||||
"label_file_name": {
|
||||
"defaultMessage": "File name:",
|
||||
"description": "Label for a file name"
|
||||
@@ -271,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"
|
||||
@@ -279,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"
|
||||
@@ -304,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:"
|
||||
},
|
||||
@@ -325,12 +508,28 @@
|
||||
"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"
|
||||
},
|
||||
"label_unarchive_topic": {
|
||||
"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": {
|
||||
@@ -354,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": {
|
||||
@@ -373,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"
|
||||
@@ -405,10 +616,18 @@
|
||||
"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]"
|
||||
},
|
||||
"menu_item_forward": {
|
||||
"defaultMessage": "Forward",
|
||||
"description": "Forward message"
|
||||
},
|
||||
"menu_item_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Show extended topic information"
|
||||
@@ -421,6 +640,10 @@
|
||||
"defaultMessage": "Mute",
|
||||
"description": "Turn off notifications"
|
||||
},
|
||||
"menu_item_reply": {
|
||||
"defaultMessage": "Reply",
|
||||
"description": "Reply to message"
|
||||
},
|
||||
"menu_item_restore_topic": {
|
||||
"defaultMessage": "Restore",
|
||||
"description": "Restore topic from archive"
|
||||
@@ -437,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"
|
||||
@@ -453,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"
|
||||
@@ -465,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."
|
||||
@@ -481,14 +736,46 @@
|
||||
"defaultMessage": "online now",
|
||||
"description": "Indicator that the user or topic is currently online"
|
||||
},
|
||||
"panel_title_crop": {
|
||||
"defaultMessage": "Drag to Adjust",
|
||||
"description": "Title for AvatarCropView."
|
||||
},
|
||||
"panel_title_general": {
|
||||
"defaultMessage": "General",
|
||||
"description": "Title for TopicCommon."
|
||||
},
|
||||
"panel_title_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Title for InfoView"
|
||||
},
|
||||
"panel_title_members": {
|
||||
"defaultMessage": "Members",
|
||||
"description": "Title for managing group members view."
|
||||
},
|
||||
"panel_title_security": {
|
||||
"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"
|
||||
@@ -529,10 +816,30 @@
|
||||
"defaultMessage": "Write ({val})",
|
||||
"description": "Name of W permission"
|
||||
},
|
||||
"permissions_anonymous": {
|
||||
"defaultMessage": "Anonymous",
|
||||
"description": "Title for editing default anonymous permissions"
|
||||
},
|
||||
"permissions_authenticated": {
|
||||
"defaultMessage": "Authenticated",
|
||||
"description": "Title for editing default authenticated permissions"
|
||||
},
|
||||
"permissions_user": {
|
||||
"defaultMessage": "User's Permissions",
|
||||
"description": "Title for editing user's permissions"
|
||||
},
|
||||
"phone_dative": {
|
||||
"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'"
|
||||
@@ -565,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."
|
||||
@@ -574,9 +885,37 @@
|
||||
"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."
|
||||
@@ -585,17 +924,13 @@
|
||||
"defaultMessage": "Notifications",
|
||||
"description": "Sidepanel title for AccNotificationsView."
|
||||
},
|
||||
"sidepanel_title_acc_security": {
|
||||
"defaultMessage": "Security",
|
||||
"description": "Sidepanel title for AccSecurityView."
|
||||
},
|
||||
"sidepanel_title_acc_support": {
|
||||
"defaultMessage": "Support",
|
||||
"description": "Sidepanel title for AccSupportView."
|
||||
},
|
||||
"sidepanel_title_account_settings": {
|
||||
"defaultMessage": "Account Settings",
|
||||
"description": "Sidepanel title for EditAccountView."
|
||||
"description": "Sidepanel title for AccountSettingsView."
|
||||
},
|
||||
"sidepanel_title_archive": {
|
||||
"defaultMessage": "Archived Chats",
|
||||
@@ -633,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"
|
||||
@@ -653,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]"
|
||||
@@ -661,10 +1004,6 @@
|
||||
"defaultMessage": "Group Members",
|
||||
"description": "Section title"
|
||||
},
|
||||
"title_info": {
|
||||
"defaultMessage": "Info",
|
||||
"description": "Title for InfoView"
|
||||
},
|
||||
"title_manage_tags": {
|
||||
"defaultMessage": "Manage",
|
||||
"description": "Section title for the list of tags"
|
||||
@@ -678,7 +1017,7 @@
|
||||
"description": "Section title"
|
||||
},
|
||||
"title_tag_manager": {
|
||||
"defaultMessage": "Tags (user discovery)",
|
||||
"defaultMessage": "Tags (search & discovery)",
|
||||
"description": "Section title for TagManager"
|
||||
},
|
||||
"topic_block_warning": {
|
||||
@@ -701,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."
|
||||
@@ -712,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,20 +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 messages =
|
||||
allMessages[language] ||
|
||||
allMessages[baseLanguage] ||
|
||||
allMessages.en;
|
||||
const htmlLang = messageLoader[normalized] ? language : messageLoader[baseLanguage] ? baseLanguage : 'en';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
const html = document.getElementsByTagName('html')[0];
|
||||
// Set lang attribute of the HTML element: <html lang="XX">
|
||||
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,19 +1,19 @@
|
||||
// File and image helper functions.
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
|
||||
import { bytesToHumanSize } from './strformat.js'
|
||||
|
||||
// Supported image MIME types and corresponding file extensions.
|
||||
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
|
||||
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
|
||||
|
||||
// Make a data: URL from public.photo
|
||||
export function makeImageDataUrl(photo) {
|
||||
if (photo) {
|
||||
// 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;
|
||||
}
|
||||
@@ -77,99 +77,145 @@ export function fileNameForMime(fname, mime) {
|
||||
}
|
||||
|
||||
// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.
|
||||
// On success calls onSuccess callback with the scaled image as Blob.
|
||||
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare, onSuccess, onError) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onerror = function(err) {
|
||||
onError("Image format unrecognized");
|
||||
}
|
||||
img.onload = async function() {
|
||||
// Once the image is loaded, the URL is no longer needed.
|
||||
URL.revokeObjectURL(img.src);
|
||||
|
||||
// Calculate the desired image dimensions.
|
||||
const dim = fitImageSize(this.width, this.height, maxWidth, maxHeight, forceSquare);
|
||||
if (!dim) {
|
||||
onError("Invalid image");
|
||||
return;
|
||||
// Returns a promise which is resolven on success or rejected on failure.
|
||||
export function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onerror = function(err) {
|
||||
reject(new Error("Image format unrecognized"));
|
||||
}
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = dim.dstWidth;
|
||||
canvas.height = dim.dstHeight;
|
||||
let ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
0, 0, dim.dstWidth, dim.dstHeight);
|
||||
img.onload = async function() {
|
||||
// Once the image is loaded, the URL is no longer needed.
|
||||
URL.revokeObjectURL(img.src);
|
||||
|
||||
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
|
||||
// Generate blob to check size of the image.
|
||||
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
if (!blob) {
|
||||
onError("Unsupported image format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the image is not too large. Shrink the image keeping the aspect ratio.
|
||||
// Do nothing if maxsize is <= 0.
|
||||
while (maxSize > 0 && blob.length > maxSize) {
|
||||
dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;
|
||||
dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;
|
||||
// Calculate the desired image dimensions.
|
||||
const dim = fitImageSize(img.width, img.height, maxWidth, maxHeight, forceSquare);
|
||||
if (!dim) {
|
||||
reject(new Error("Invalid image"));
|
||||
return;
|
||||
}
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = dim.dstWidth;
|
||||
canvas.height = dim.dstHeight;
|
||||
ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
let ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
0, 0, dim.dstWidth, dim.dstHeight);
|
||||
blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
}
|
||||
|
||||
canvas = null;
|
||||
onSuccess(mime, blob, dim.dstWidth, dim.dstHeight, fileNameForMime(fileOrBlob.name, mime));
|
||||
};
|
||||
img.src = URL.createObjectURL(fileOrBlob);
|
||||
const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';
|
||||
// Generate blob to check size of the image.
|
||||
let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
if (!blob) {
|
||||
reject(new Error("Unsupported image format"));
|
||||
return;
|
||||
}
|
||||
// Ensure the image is not too large. Shrink the image keeping the aspect ratio.
|
||||
// Do nothing if maxsize is <= 0.
|
||||
while (maxSize > 0 && blob.length > maxSize) {
|
||||
dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;
|
||||
dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;
|
||||
canvas.width = dim.dstWidth;
|
||||
canvas.height = dim.dstHeight;
|
||||
ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
|
||||
0, 0, dim.dstWidth, dim.dstHeight);
|
||||
blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
|
||||
}
|
||||
|
||||
canvas = null;
|
||||
resolve({mime: mime, blob: blob, width: dim.dstWidth, height: dim.dstHeight, name: fileNameForMime(fileOrBlob.name, mime)});
|
||||
};
|
||||
img.src = URL.createObjectURL(fileOrBlob);
|
||||
});
|
||||
}
|
||||
|
||||
// Convert File to base64 string.
|
||||
export function fileToBase64(file, onSuccess) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function() {
|
||||
onSuccess(file.type, reader.result.split(',')[1], file.name);
|
||||
// Scale and crop image according to specified dimensions.
|
||||
// The coordinates are in unscaled image pixels, i.e. cut the rectangle first then scale it.
|
||||
// returns a promise.
|
||||
export function imageCrop(mime, objURL, left, top, width, height, scale) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.onerror = _ => {
|
||||
reject(new Error("Image format unrecognized"));
|
||||
};
|
||||
img.onload = _ => {
|
||||
// Once the image is loaded, the URL is no longer needed.
|
||||
URL.revokeObjectURL(img.src);
|
||||
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
let ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(img, left, top, width, height, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
mime = SUPPORTED_IMAGE_FORMATS.includes(mime) ? mime : 'image/jpeg';
|
||||
// Generate blob to check size of the image.
|
||||
canvas.toBlob((blob) => {
|
||||
// Allow GC.
|
||||
canvas = null;
|
||||
if (blob) {
|
||||
resolve({mime: mime, blob: blob, width: width, height: height});
|
||||
} else {
|
||||
reject(new Error("Unsupported image format"));
|
||||
}
|
||||
}, mime);
|
||||
};
|
||||
img.src = objURL;
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Convert Blob to base64 string.
|
||||
export function blobToBase64(blob, onSuccess) {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', function() {
|
||||
onSuccess(blob.type, reader.result.split(',')[1]);
|
||||
// Convert file to base64 string.
|
||||
export function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = _ => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = _ => {
|
||||
resolve({mime: file.type, bits: reader.result.split(',')[1], name: file.name});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Convert Blob to base64 string. Returns a promise resolved with the base64 string and mime.
|
||||
export function blobToBase64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = _ => {
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.onload = _ => {
|
||||
resolve({mime: blob.type, bits: reader.result.split(',')[1]});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
|
||||
// File pasted from the clipboard. It's either an inline image or a file attachment.
|
||||
// FIXME: handle large files out of band.
|
||||
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
|
||||
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
||||
for (var i in items) {
|
||||
var item = items[i];
|
||||
const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
|
||||
if (!items || !items.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i in items) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
var file = item.getAsFile();
|
||||
const file = item.getAsFile();
|
||||
if (!file) {
|
||||
console.log("Failed to get file object from pasted file item", item.kind, item.type);
|
||||
console.error("Failed to get file object from pasted file item", item.kind, item.type);
|
||||
onError("Failed to get file object from pasted file item");
|
||||
continue;
|
||||
}
|
||||
if (file.type && file.type.split('/')[0] == 'image') {
|
||||
// Handle inline image
|
||||
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
|
||||
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError);
|
||||
} else {
|
||||
imageFileToBase64(file, onImageSuccess, onError);
|
||||
}
|
||||
onImageSuccess(file);
|
||||
} else {
|
||||
// Handle file attachment
|
||||
fileToBase64(file, onAttachmentSuccess, onError)
|
||||
onAttachmentSuccess(file);
|
||||
}
|
||||
// Indicate that the pasted data contains a file.
|
||||
return true;
|
||||
@@ -181,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;
|
||||
}
|
||||
|
||||
@@ -205,9 +251,57 @@ export function base64ReEncode(str) {
|
||||
try {
|
||||
str = btoa(atob(str));
|
||||
} catch(err) {
|
||||
console.log("Failed to base64 re-encode string.", err);
|
||||
console.error("Failed to base64 re-encode string.", err);
|
||||
str = null;
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// Convert a base64 encoded string with the provided mime type into a Blob.
|
||||
export function base64ToBlob(str, mime) {
|
||||
if (!str) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Make blob.
|
||||
const bin = atob(str);
|
||||
const length = bin.length;
|
||||
const buf = new ArrayBuffer(length);
|
||||
const arr = new Uint8Array(buf);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = bin.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([buf], { type: mime });
|
||||
} catch(err) {
|
||||
console.error("Failed to convert base64 to blob: ", err);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import React from 'react';
|
||||
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 { 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, 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'
|
||||
},
|
||||
drafty_attachment: {
|
||||
id: 'drafty_attachment',
|
||||
defaultMessage: 'Attachment',
|
||||
description: 'Comment for attachment in Drafty'
|
||||
},
|
||||
drafty_image: {
|
||||
id: 'drafty_image',
|
||||
defaultMessage: 'Picture',
|
||||
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'
|
||||
}
|
||||
});
|
||||
|
||||
// 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';
|
||||
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: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
|
||||
attr.style = {
|
||||
width: dim.dstWidth + 'px',
|
||||
height: dim.dstHeight + 'px',
|
||||
// Looks like a Chrome bug: broken image does not respect 'width' and 'height'.
|
||||
minWidth: dim.dstWidth + 'px',
|
||||
minHeight: dim.dstHeight + 'px'
|
||||
};
|
||||
if (!Drafty.isProcessing(data)) {
|
||||
attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));
|
||||
attr.alt = data.name;
|
||||
if (attr.src) {
|
||||
if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {
|
||||
// Allow previews for large enough images.
|
||||
attr.onClick = this.onImagePreview;
|
||||
attr.className += ' image-clickable';
|
||||
}
|
||||
attr.loading = 'lazy';
|
||||
} else {
|
||||
attr.src = null;
|
||||
}
|
||||
} else {
|
||||
// Use custom element instead of <img> or <video>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Use custom element instead of <img> or <video>.
|
||||
el = UploadingImage;
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// Converts Drafty object into a one-line preview. 'this' is set by the caller.
|
||||
// 'this' must contain:
|
||||
// formatMessage: this.props.intl.formatMessage
|
||||
// messages: formatjs messages defined with defineMessages.
|
||||
export function previewFormatter(style, data, values, key) {
|
||||
if (!style) {
|
||||
// Unformatted.
|
||||
return values;
|
||||
}
|
||||
|
||||
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;
|
||||
values = [' '];
|
||||
break;
|
||||
case 'HL':
|
||||
// Make highlight less prominent in preview.
|
||||
attr.className = 'highlight preview';
|
||||
break;
|
||||
case 'LN':
|
||||
case 'MN':
|
||||
// Disable links in previews.
|
||||
el = 'span';
|
||||
break;
|
||||
case 'IM':
|
||||
// Replace image with '[icon] Image'.
|
||||
el = React.Fragment;
|
||||
values = [<i key="im" className="material-icons">photo</i>, ' ', this.formatMessage(messages.drafty_image)];
|
||||
break;
|
||||
case 'BN':
|
||||
el = 'span';
|
||||
attr.className = 'flat-button faux';
|
||||
break;
|
||||
case 'FM':
|
||||
el = React.Fragment;
|
||||
values = [<i key="fm" className="material-icons">dashboard</i>,
|
||||
this.formatMessage(messages.drafty_form)].concat(' ', values || []);
|
||||
break;
|
||||
case 'RW':
|
||||
el = React.Fragment;
|
||||
break;
|
||||
case 'EX':
|
||||
if (data) {
|
||||
if (Drafty.isFormResponseType(data.mime)) {
|
||||
// Ignore JSON attachments: they are form response payloads.
|
||||
return null;
|
||||
}
|
||||
// Clear payload.
|
||||
delete data.val;
|
||||
delete data.ref;
|
||||
}
|
||||
el = React.Fragment;
|
||||
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) {
|
||||
// Unknown element.
|
||||
el = React.Fragment;
|
||||
values = [<i key="x0" className="material-icons gray">extension</i>, ' ', this.formatMessage(messages.drafty_unknown)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!el) {
|
||||
return values;
|
||||
}
|
||||
return React.createElement(el, attr, values);
|
||||
};
|
||||
|
||||
// Converts Drafty object into a quoted reply; 'this' is set by the caller.
|
||||
function inlineImageAttr(attr, data) {
|
||||
attr.style = {
|
||||
width: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
height: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
maxWidth: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
maxHeight: IMAGE_THUMBNAIL_DIM + 'px',
|
||||
}
|
||||
attr.className = 'inline-image';
|
||||
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)
|
||||
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];
|
||||
el = React.Fragment;
|
||||
// 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'
|
||||
if (data) {
|
||||
attr.className += ' ' + idToColorClass(data.val, false, true);
|
||||
}
|
||||
break;
|
||||
case 'EX':
|
||||
let fname;
|
||||
if (data) {
|
||||
if (Drafty.isFormResponseType(data.mime)) {
|
||||
// Ignore JSON attachments: they are form response payloads.
|
||||
return null;
|
||||
}
|
||||
fname = data.name;
|
||||
// Clear payload.
|
||||
delete data.val;
|
||||
delete data.ref;
|
||||
}
|
||||
el = React.Fragment;
|
||||
values = [<i key="ex" className="material-icons">attachment</i>,
|
||||
shortenFileName(fname, 16) || this.formatMessage(messages.drafty_attachment)];
|
||||
break;
|
||||
}
|
||||
return React.createElement(el, attr, values);
|
||||
}
|
||||
return previewFormatter.call(this, style, data, values, key);
|
||||
}
|
||||
|
||||
// Create image thumbnail suitable for inclusion in a quote.
|
||||
function quoteImageOrVideo(data, isVideo) {
|
||||
let promise;
|
||||
let bits, ref, mime;
|
||||
if (isVideo) {
|
||||
bits = data.preview;
|
||||
mime = data.premime || 'image/jpeg';
|
||||
ref = data.preref;
|
||||
} else {
|
||||
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 => {
|
||||
// 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 => {
|
||||
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 => {
|
||||
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;
|
||||
// Rethrow.
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Create a preview of a reply.
|
||||
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);
|
||||
}
|
||||
return quoteFormatter.call(this, style, data, values, key);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
// 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 = [];
|
||||
if (parts[0]) {
|
||||
path = parts[0].substr(1).split('/');
|
||||
path = parts[0].replace('#', '').split('/');
|
||||
}
|
||||
if (parts[1]) {
|
||||
parts[1].split('&').forEach(function(part) {
|
||||
let 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,28 +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 = HashNavigation.parseUrlHash(hash);
|
||||
if (infopanel) {
|
||||
parsed.params.info = infopanel;
|
||||
} else {
|
||||
delete parsed.params.info;
|
||||
}
|
||||
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.
|
||||
@@ -37,9 +65,18 @@ export function bytesToHumanSize(bytes) {
|
||||
return count.toFixed(round) + ' ' + sizes[bucket];
|
||||
}
|
||||
|
||||
// Shorten a file name to be under maxLength by clipping out the middle.
|
||||
export function shortenFileName(filename, maxLength) {
|
||||
if (typeof filename != 'string') {
|
||||
return filename;
|
||||
}
|
||||
return filename.length > maxLength ?
|
||||
filename.slice(0, maxLength/2 - 1) + '…' + filename.slice(1 - maxLength/2) : filename;
|
||||
}
|
||||
|
||||
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
|
||||
// as Java's String#hash().
|
||||
export function stringHash(value) {
|
||||
function stringToColorHash(value) {
|
||||
let hash = 0;
|
||||
value = '' + value;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
@@ -48,3 +85,41 @@ export function stringHash(value) {
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
// User avatar letter color id.
|
||||
export function letterTileColorId(userId) {
|
||||
return Math.abs(stringToColorHash(userId)) % 16;
|
||||
}
|
||||
|
||||
// Converts user or topic ID to a CSS color class. Ideally it should produce the same color value as Java version.
|
||||
// params:
|
||||
// - {string} id: user or topic ID
|
||||
// - {boolean} light: light or dark version of the color.
|
||||
// - {boolean} fg: foreground (text) or background color.
|
||||
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) {
|
||||
@@ -20,9 +20,10 @@ export function updateFavicon(count) {
|
||||
}
|
||||
|
||||
// Create theCard which represents user's or topic's "public" info.
|
||||
export function theCard(fn, imageUrl, imageMimeType) {
|
||||
export function theCard(fn, imageUrl, imageMimeType, note) {
|
||||
let card = null;
|
||||
fn = fn && fn.trim();
|
||||
note = note && note.trim();
|
||||
|
||||
if (fn) {
|
||||
card = {
|
||||
@@ -30,6 +31,11 @@ export function theCard(fn, imageUrl, imageMimeType) {
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof note == 'string') {
|
||||
card = card || {};
|
||||
card.note = note ? note : Tinode.DEL_CHAR;
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
card = card || {};
|
||||
let mimeType = imageMimeType;
|
||||
@@ -38,10 +44,12 @@ export function theCard(fn, imageUrl, imageMimeType) {
|
||||
if (matches) {
|
||||
mimeType = matches[1];
|
||||
card.photo = {
|
||||
data: imageUrl.substring(imageUrl.indexOf(',') + 1)
|
||||
data: imageUrl.substring(imageUrl.indexOf(',') + 1),
|
||||
ref: Tinode.DEL_CHAR
|
||||
};
|
||||
} else {
|
||||
card.photo = {
|
||||
data: Tinode.DEL_CHAR,
|
||||
ref: imageUrl
|
||||
};
|
||||
}
|
||||
@@ -81,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;
|
||||
}
|
||||
@@ -89,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;
|
||||
@@ -100,21 +108,26 @@ 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
|
||||
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
|
||||
// If apikey and/or token is defined, it's appended to relative URL.
|
||||
export function sanitizeUrl(url, allowedSchemes) {
|
||||
if (!url) {
|
||||
return null;
|
||||
if (typeof url != 'string') {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 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 '//'.
|
||||
@@ -137,11 +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.
|
||||
// The relative URL is appended with the API key and/or auth token.
|
||||
// In case of data: scheme, the URL must start with a 'data:image/XXXX;base64,'.
|
||||
export function sanitizeImageUrl(url) {
|
||||
// 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;
|
||||
}
|
||||
@@ -151,21 +163,58 @@ 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:
|
||||
return { name: 'warning', color: 'amber' }; // yellow icon /!\
|
||||
case Tinode.MESSAGE_STATUS_FATAL:
|
||||
return { name: 'warning', color: 'danger-color' }; // yellow icon /!\
|
||||
case Tinode.MESSAGE_STATUS_SENT:
|
||||
return { name: 'done' }; // checkmark
|
||||
case Tinode.MESSAGE_STATUS_RECEIVED:
|
||||
@@ -175,3 +224,27 @@ export function deliveryMarker(received) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 = 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() {
|
||||
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.17.10";
|
||||
export const PACKAGE_VERSION = "0.25.0-beta1";
|
||||
|
||||
@@ -2,188 +2,145 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import InPlaceEdit from '../widgets/in-place-edit.jsx';
|
||||
import TagManager from '../widgets/tag-manager.jsx';
|
||||
|
||||
import { MAX_TITLE_LENGTH } from '../config.js';
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { arrayEqual, asEmail, asPhone, theCard } from '../lib/utils.js';
|
||||
|
||||
export default class AccGeneralView extends React.Component {
|
||||
export default class AccGeneralView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
this.state = {
|
||||
fullName: me.public ? me.public.fn : undefined,
|
||||
avatar: makeImageDataUrl(me.public ? me.public.photo : null),
|
||||
tags: me.tags(),
|
||||
credentials: me.getCredentials() || [],
|
||||
addCredActive: false,
|
||||
addCredInvalid: false,
|
||||
newCred: '',
|
||||
previousOnTags: me.onTagsUpdated
|
||||
colorSchema: props.colorSchema || 'light dark',
|
||||
textSize: (props.textSize * 10) || '100',
|
||||
sendOnEnter: props.sendOnEnter || 'plain',
|
||||
};
|
||||
|
||||
this.tnNewTags = this.tnNewTags.bind(this);
|
||||
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
|
||||
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleCredChange = this.handleCredChange.bind(this);
|
||||
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
|
||||
this.handleCredEntered = this.handleCredEntered.bind(this);
|
||||
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
|
||||
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);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
me.onCredsUpdated = this.tnCredsUpdated;
|
||||
me.onTagsUpdated = this.tnNewTags;
|
||||
handleColorSchemaSelected(e) {
|
||||
this.setState({colorSchema: e.currentTarget.value});
|
||||
this.props.onChangeColorSchema(e.currentTarget.value);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
me.onTagsUpdated = this.state.previousOnTags;
|
||||
me.onCredsUpdated = undefined;
|
||||
handleTextSizeChanged(e) {
|
||||
// In %%.
|
||||
this.setState({textSize: e.currentTarget.value});
|
||||
// Convert %% to pt, 100% = 10pt.
|
||||
this.props.onTextSizeChanged((e.currentTarget.value / 10) | 0);
|
||||
}
|
||||
|
||||
tnNewTags(tags) {
|
||||
this.setState({tags: tags});
|
||||
}
|
||||
|
||||
tnCredsUpdated(creds) {
|
||||
this.setState({credentials: creds || []});
|
||||
}
|
||||
|
||||
handleFullNameUpdate(fn) {
|
||||
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
|
||||
if (fn) {
|
||||
this.setState({fullName: fn});
|
||||
this.props.onUpdateAccount(undefined, theCard(fn, null));
|
||||
}
|
||||
}
|
||||
|
||||
handleImageChanged(img) {
|
||||
this.setState({avatar: img});
|
||||
this.props.onUpdateAccount(undefined, theCard(null, img || Tinode.DEL_CHAR));
|
||||
}
|
||||
|
||||
handleCredChange(e) {
|
||||
this.setState({newCred: e.target.value, addCredInvalid: false});
|
||||
}
|
||||
|
||||
handleCredKeyDown(e) {
|
||||
if (e.keyCode === 27) {
|
||||
// Escape pressed
|
||||
this.setState({newCred: '', addCredActive: false});
|
||||
} else if (e.keyCode === 13) {
|
||||
// Enter pressed
|
||||
this.handleCredEntered(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleCredEntered(e) {
|
||||
let value = this.state.newCred.trim();
|
||||
if (!value) {
|
||||
this.setState({addCredActive: false, addCredInvalid: false});
|
||||
return;
|
||||
}
|
||||
|
||||
let val = asPhone(value);
|
||||
let method;
|
||||
if (val) {
|
||||
method = 'tel';
|
||||
} else {
|
||||
val = asEmail(value);
|
||||
if (val) {
|
||||
method = 'email';
|
||||
}
|
||||
}
|
||||
if (method) {
|
||||
this.props.onCredAdd(method, val);
|
||||
this.setState({addCredActive: false, newCred: ''});
|
||||
} else {
|
||||
this.setState({addCredInvalid: true});
|
||||
}
|
||||
}
|
||||
|
||||
handleTagsUpdated(tags) {
|
||||
// Check if tags have actually changed.
|
||||
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
|
||||
return;
|
||||
}
|
||||
this.props.onUpdateTags(tags);
|
||||
handleSendOnEnterSelected(e) {
|
||||
this.setState({sendOnEnter: e.currentTarget.value});
|
||||
this.props.onSendOnEnterChanged(e.currentTarget.value);
|
||||
}
|
||||
|
||||
render() {
|
||||
const credentials = [];
|
||||
this.state.credentials.map((cred) => {
|
||||
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
|
||||
<span > {!cred.done ?
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
|
||||
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
|
||||
description="Validate credentail call to action" />
|
||||
</a>
|
||||
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
|
||||
className="material-icons gray">delete_outline</i></a></span></div>);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<div 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">
|
||||
<label className="small"><FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||
description="Label for full name editing" /></label>
|
||||
<div><FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
|
||||
description="Input placeholder for person's full name">{
|
||||
(full_name_placeholder) => <InPlaceEdit
|
||||
placeholder={full_name_placeholder}
|
||||
value={this.state.fullName}
|
||||
onFinished={this.handleFullNameUpdate} />
|
||||
}</FormattedMessage></div>
|
||||
<div 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>
|
||||
<AvatarUpload
|
||||
avatar={this.state.avatar}
|
||||
uid={this.props.myUserId}
|
||||
title={this.state.fullName}
|
||||
onImageChanged={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
|
||||
description="Section title for TagManager">{
|
||||
(title_tag_manager) => <TagManager
|
||||
title={title_tag_manager}
|
||||
activated={false}
|
||||
tags={this.state.tags}
|
||||
tinode={this.props.tinode}
|
||||
onSubmit={this.handleTagsUpdated} />
|
||||
}</FormattedMessage>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
|
||||
description="Label for user contacts" />
|
||||
</label>
|
||||
<div className="quoted">
|
||||
{credentials}
|
||||
{this.state.addCredActive ?
|
||||
<input type="text" value={this.state.value}
|
||||
className={this.state.addCredInvalid ? 'invalid' : null}
|
||||
placeholder="Phone number or email" required="required" autoFocus
|
||||
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
|
||||
: null}
|
||||
<div>
|
||||
<a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
|
||||
<i className="material-icons">add</i>
|
||||
<FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
|
||||
</a></div>
|
||||
<div 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>
|
||||
</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';
|
||||
|
||||
@@ -12,7 +13,7 @@ const messages = defineMessages({
|
||||
description: 'Title for delete account warning'
|
||||
},
|
||||
delete_account_warning: {
|
||||
id: 'delete_account_arning',
|
||||
id: 'delete_account_warning',
|
||||
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
|
||||
description: 'Warning message when deleting an account'
|
||||
}
|
||||
@@ -42,11 +43,12 @@ 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) {
|
||||
this.setState({password: pwd});
|
||||
this.props.onUpdateAccount(pwd);
|
||||
this.props.onUpdatePassword(pwd);
|
||||
}
|
||||
|
||||
handleLaunchPermissionsEditor(which) {
|
||||
@@ -63,20 +65,24 @@ class AccSecurityView extends React.Component {
|
||||
handlePermissionsChanged(perm) {
|
||||
let defacs = {};
|
||||
defacs[this.state.showPermissionEditorFor] = perm;
|
||||
this.props.onUpdateAccount(undefined, undefined, defacs);
|
||||
this.props.onUpdateAccountDesc('me', undefined, undefined, defacs);
|
||||
|
||||
let newState = {showPermissionEditorFor: undefined};
|
||||
newState[this.state.showPermissionEditorFor] = perm;
|
||||
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"
|
||||
@@ -85,7 +91,7 @@ class AccSecurityView extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>{this.state.showPermissionEditorFor ?
|
||||
<>{this.state.showPermissionEditorFor ?
|
||||
<PermissionsEditor
|
||||
mode={this.state.editedPermissions}
|
||||
skip="O"
|
||||
@@ -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">
|
||||
@@ -147,7 +168,7 @@ class AccSecurityView extends React.Component {
|
||||
</>
|
||||
: null }
|
||||
</div>
|
||||
}</React.Fragment>
|
||||
}</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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 truncateString(str, length);
|
||||
}
|
||||
|
||||
export default class AccountSettingsView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
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),
|
||||
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');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
<center>
|
||||
<AvatarUpload
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.state.avatar}
|
||||
readOnly={true}
|
||||
uid={this.props.myUserId}
|
||||
title={this.state.fullName} />
|
||||
</center>
|
||||
<div className="group">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||
description="Label for full name editing" />
|
||||
</label>
|
||||
<div className="large ellipsized">{this.state.fullName}</div>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
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>
|
||||
{this.state.description ?
|
||||
<div className="group">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_description" defaultMessage="Description"
|
||||
description="Label for editing topic description" />
|
||||
</label>
|
||||
<div className="quoted">{this.state.description}</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<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');}}>
|
||||
<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');}}>
|
||||
<i className="material-icons">contact_support</i> <FormattedMessage id="sidepanel_title_acc_support"
|
||||
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -89,6 +94,7 @@ export default class ContactsView extends React.Component {
|
||||
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
|
||||
description="HTML message shown in ContactList when no contacts are found">{
|
||||
(no_contacts) => <ContactList
|
||||
tinode={this.props.tinode}
|
||||
connected={this.props.connected}
|
||||
contacts={this.state.contactList}
|
||||
emptyListMessage={no_contacts}
|
||||
|
||||
@@ -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"
|
||||
@@ -92,7 +180,9 @@ export default class CreateAccountView extends React.PureComponent {
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
onImageChanged={this.handleImageChanged}
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.state.imageUrl}
|
||||
onImageUpdated={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
@@ -102,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} />
|
||||
@@ -118,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,66 +0,0 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
|
||||
import { makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
|
||||
export default class EditAccountView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const me = this.props.tinode.getMeTopic();
|
||||
this.state = {
|
||||
fullName: me.public ? me.public.fn : undefined,
|
||||
avatar: makeImageDataUrl(me.public ? me.public.photo : null)
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<label className="small">
|
||||
<FormattedMessage id="label_your_name" defaultMessage="Your name"
|
||||
description="Label for full name editing" />
|
||||
</label>
|
||||
<div className="large">{this.state.fullName}</div>
|
||||
<div>
|
||||
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
|
||||
description="Label for user address (ID)" /></label>
|
||||
<tt>{this.props.myUserId}</tt>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
avatar={this.state.avatar}
|
||||
readOnly={!this.state.owner}
|
||||
uid={this.props.myUserId}
|
||||
title={this.state.fullName} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('general');}}>
|
||||
<i className="material-icons">edit</i>
|
||||
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('notif');}}>
|
||||
<i className="material-icons">notifications</i> <FormattedMessage id="sidepanel_title_acc_notifications"
|
||||
defaultMessage="Notifications" description="Sidepanel title for AccNotificationsView." />
|
||||
</a>
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('security');}}>
|
||||
<i className="material-icons">security</i> <FormattedMessage id="sidepanel_title_acc_security"
|
||||
defaultMessage="Security" description="Sidepanel title for AccSecurityView." />
|
||||
</a>
|
||||
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('support');}}>
|
||||
<i className="material-icons">contact_support</i> <FormattedMessage id="sidepanel_title_acc_support"
|
||||
defaultMessage="Support" description="Sidepanel title for AccSupportView." />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -16,7 +16,7 @@ export default class LogoView extends React.PureComponent {
|
||||
<img id="logo" alt="logo" src="img/logo.svg" />
|
||||
<h2>Tinode Web</h2>
|
||||
</a>
|
||||
<p><FormattedMessage id="label_client" defaultMessage="Client:"
|
||||
<p><FormattedMessage id="label_client" defaultMessage="Client:"
|
||||
description="Label for a client version" /> {version}</p>
|
||||
<p><FormattedMessage id="label_server" defaultMessage="Server:"
|
||||
description="Label for a server version" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
|
||||
|
||||
@@ -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: {
|
||||
@@ -22,7 +22,12 @@ const messages = defineMessages({
|
||||
id: "search_no_results",
|
||||
defaultMessage: "Search returned no results",
|
||||
description: "Text shown in contacts view when query returned no results."
|
||||
}
|
||||
},
|
||||
search_placeholder: {
|
||||
id: "search_placeholder",
|
||||
defaultMessage: "List like alice@example.com, +17025550003...",
|
||||
description: "Placeholder in contacts search field"
|
||||
},
|
||||
});
|
||||
|
||||
class NewTopicView extends React.Component {
|
||||
@@ -30,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);
|
||||
}
|
||||
@@ -52,20 +57,41 @@ 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(sel) {
|
||||
if (this.state.tabSelected === 'p2p') {
|
||||
handleSearchResultSelected(topicName) {
|
||||
if (this.state.tabSelected == 'find') {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
this.props.onCreateTopic(sel);
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
}
|
||||
|
||||
handleNewGroupSubmit(name, dataUrl, priv, tags, isChannel) {
|
||||
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
|
||||
this.props.onCreateTopic(undefined, theCard(name, dataUrl), priv, tags, isChannel);
|
||||
this.props.onCreateTopic(undefined,
|
||||
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
|
||||
}
|
||||
|
||||
handleGroupByID(topicName) {
|
||||
@@ -77,11 +103,12 @@ class NewTopicView extends React.Component {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
|
||||
messages.search_no_results : messages.search_for_contacts);
|
||||
const search_placeholder = formatMessage(messages.search_placeholder);
|
||||
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>
|
||||
@@ -102,23 +129,27 @@ 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
|
||||
tinode={this.props.tinode}
|
||||
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 TopicCommonView from './topic-common-view.jsx';
|
||||
|
||||
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
|
||||
import ContactsView from './contacts-view.jsx';
|
||||
import CreateAccountView from './create-account-view.jsx';
|
||||
import EditAccountView from './edit-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';
|
||||
|
||||
@@ -37,24 +38,34 @@ const messages = defineMessages({
|
||||
},
|
||||
edit: {
|
||||
id: 'sidepanel_title_account_settings',
|
||||
description: 'Sidepanel title for EditAccountView.',
|
||||
description: 'Sidepanel title for AccountSettingsView.',
|
||||
defaultMessage: 'Account Settings'
|
||||
},
|
||||
general: {
|
||||
id: 'sidepanel_title_acc_general',
|
||||
description: 'Sidepanel title for AccGeneralView.',
|
||||
id: 'panel_title_general',
|
||||
description: 'Title for TopicCommon.',
|
||||
defaultMessage: 'General'
|
||||
},
|
||||
security: {
|
||||
id: 'sidepanel_title_acc_security',
|
||||
description: 'Sidepanel title for AccSecurityView.',
|
||||
id: 'panel_title_security',
|
||||
description: 'Title for TopicSecirity and AccSecurity.',
|
||||
defaultMessage: 'Security'
|
||||
},
|
||||
crop: {
|
||||
id: 'panel_title_crop',
|
||||
description: 'Title for AvatarCropView.',
|
||||
defaultMessage: 'Drag to Adjust'
|
||||
},
|
||||
notif: {
|
||||
id: 'sidepanel_title_acc_notifications',
|
||||
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.',
|
||||
@@ -87,7 +98,7 @@ const messages = defineMessages({
|
||||
}
|
||||
});
|
||||
|
||||
class SidepanelView extends React.Component {
|
||||
class SidepanelView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -95,20 +106,22 @@ class SidepanelView extends React.Component {
|
||||
}
|
||||
|
||||
handleNewTopic() {
|
||||
this.props.onBasicNavigate('newtpk');
|
||||
this.props.onNavigate('newtpk');
|
||||
}
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
|
||||
|
||||
let title, avatar;
|
||||
let title, avatar, badges;
|
||||
if (view == 'contacts') {
|
||||
title = this.props.title;
|
||||
avatar = this.props.avatar ? this.props.avatar : true;
|
||||
badges = this.props.trustedBadges;
|
||||
} else {
|
||||
title = formatMessage(messages[view]);
|
||||
avatar = false;
|
||||
badges = null;
|
||||
}
|
||||
|
||||
let onCancel;
|
||||
@@ -117,11 +130,13 @@ class SidepanelView extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
|
||||
<div id="sidepanel">
|
||||
<SideNavbar
|
||||
state={view}
|
||||
title={title}
|
||||
avatar={avatar}
|
||||
tinode={this.props.tinode}
|
||||
trustedBadges={badges}
|
||||
myUserId={this.props.myUserId}
|
||||
onSignUp={this.props.onSignUp}
|
||||
onSettings={this.props.onSettings}
|
||||
@@ -146,54 +161,82 @@ class SidepanelView extends React.Component {
|
||||
onPersistenceChange={this.props.onPersistenceChange} /> :
|
||||
|
||||
view === 'register' ?
|
||||
<CreateAccountView
|
||||
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' ?
|
||||
<EditAccountView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
onBasicNavigate={this.props.onBasicNavigate} /> :
|
||||
<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' ?
|
||||
<AccGeneralView
|
||||
(view === 'general' || view === 'crop') ?
|
||||
<TopicCommonView
|
||||
topic="me"
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
onUpdateAccount={this.props.onUpdateAccount}
|
||||
onUpdateTags={this.props.onUpdateAccountTags}
|
||||
onCredAdd={this.props.onCredAdd}
|
||||
onCredDelete={this.props.onCredDelete}
|
||||
onCredConfirm={this.props.onCredConfirm}
|
||||
onBasicNavigate={this.props.onBasicNavigate}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onUpdateTopicDesc={this.props.onUpdateAccountDesc}
|
||||
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}
|
||||
onUpdateAccount={this.props.onUpdateAccount}
|
||||
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
|
||||
@@ -226,14 +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} /> :
|
||||
<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,37 +80,36 @@ 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="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
|
||||
</button>
|
||||
<button className="secondary" onClick={this.handleCancel}>
|
||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||
</button>
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,68 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Cropper from './cropper.jsx';
|
||||
import { imageCrop } from '../lib/blob-helpers.js';
|
||||
|
||||
export default class AvatarCrop extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1
|
||||
};
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
// Cut out in image coordinates and desired scale factor.
|
||||
handleChange(left, top, width, height, scale) {
|
||||
this.setState({
|
||||
left: left,
|
||||
top: top,
|
||||
width: width,
|
||||
height: height,
|
||||
scale: scale
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
imageCrop(this.props.mime, this.props.avatar,
|
||||
this.state.left, this.state.top, this.state.width, this.state.height,
|
||||
this.state.scale)
|
||||
.then(img => {
|
||||
this.props.onSubmit(img.mime, img.blob, img.width, img.height);
|
||||
})
|
||||
.catch(err => {
|
||||
this.props.onError(err);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="panel-form">
|
||||
<div className="panel-form-row">
|
||||
<Cropper
|
||||
source={this.props.avatar}
|
||||
onChange={this.handleChange} />
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
{this.props.onCancel ?
|
||||
<button className="secondary" onClick={this.props.onCancel}>
|
||||
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
|
||||
</button>
|
||||
: null}
|
||||
<button className="primary" onClick={this.handleSubmit}>
|
||||
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,42 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import LoadSpinner from './load-spinner.jsx';
|
||||
|
||||
import { AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE } from '../config.js';
|
||||
import { imageScaled, blobToBase64, makeImageDataUrl } from '../lib/blob-helpers.js';
|
||||
import { sanitizeUrlForMime } from '../lib/utils.js';
|
||||
|
||||
import { AVATAR_SIZE } from '../config.js';
|
||||
|
||||
export default class AvatarUpload extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
dataUrl: props.avatar
|
||||
source: props.avatar
|
||||
};
|
||||
|
||||
this.handleFileUpload = this.handleFileUpload.bind(this);
|
||||
this.handleFileReceived = this.handleFileReceived.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.avatar != prevProps.avatar) {
|
||||
this.setState({dataUrl: this.props.avatar})
|
||||
this.setState({source: this.props.avatar});
|
||||
}
|
||||
}
|
||||
|
||||
handleFileUpload(e) {
|
||||
imageScaled(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true,
|
||||
// Image successfully scaled and converted.
|
||||
(mime, blob) => {
|
||||
// Convert blob to base64-encoded bits.
|
||||
blobToBase64(blob, (unused, base64bits) => {
|
||||
const du = makeImageDataUrl({data: base64bits, type: mime});
|
||||
this.setState({dataUrl: du});
|
||||
this.props.onImageChanged(du);
|
||||
});
|
||||
},
|
||||
// Failure
|
||||
(err) => {
|
||||
this.props.onError(err, 'err');
|
||||
});
|
||||
handleFileReceived(e) {
|
||||
const image = e.target.files[0];
|
||||
this.props.onImageUpdated(image.type, URL.createObjectURL(image), image.name);
|
||||
// Clear the value so the same file can be uploaded again.
|
||||
e.target.value = '';
|
||||
}
|
||||
@@ -44,20 +34,21 @@ 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}>
|
||||
{this.props.readOnly || !this.state.dataUrl ?
|
||||
{this.props.readOnly || !this.state.source ?
|
||||
null :
|
||||
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageChanged(null);}}>
|
||||
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageUpdated();}}>
|
||||
<i className="material-icons">clear</i>
|
||||
</a>}
|
||||
{this.state.dataUrl ?
|
||||
<img src={this.state.dataUrl} className="preview" /> :
|
||||
{this.state.source ?
|
||||
<img src={this.props.tinode.authorizeURL(sanitizeUrlForMime(this.state.source, 'image'))} className="preview" /> :
|
||||
this.props.readOnly && this.props.uid ?
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
tinode={this.props.tinode}
|
||||
avatar={true}
|
||||
topic={this.props.uid}
|
||||
title={this.props.title} />
|
||||
@@ -66,11 +57,12 @@ export default class AvatarUpload extends React.Component {
|
||||
<div className="blank">{AVATAR_SIZE}×{AVATAR_SIZE}</div>}
|
||||
{this.props.readOnly ? null :
|
||||
<input type="file" id={randId} className="inputfile hidden"
|
||||
accept="image/*" onChange={this.handleFileUpload} />}
|
||||
accept="image/*" onChange={this.handleFileReceived} />}
|
||||
{this.props.readOnly ? null :
|
||||
<label htmlFor={randId} className="round">
|
||||
<i className="material-icons">file_upload</i>
|
||||
</label>}
|
||||
<LoadSpinner show={this.props.uploading} large={true} clear={true} centered={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const icon_mapping = {'staff': 'verified_user'};
|
||||
|
||||
const messages = defineMessages({
|
||||
badge_verified: {
|
||||
id: 'badge_verified',
|
||||
defaultMessage: 'Verified/official',
|
||||
description: 'Explanation of a verified account or topic badge'
|
||||
},
|
||||
badge_staff: {
|
||||
id: 'badge_staff',
|
||||
defaultMessage: 'Staff-managed',
|
||||
description: 'Explanation of a staff-managed account or topic badge'
|
||||
},
|
||||
badge_danger: {
|
||||
id: 'badge_danger',
|
||||
defaultMessage: 'Untrustworthy',
|
||||
description: 'Suspicious or untrustworthy account or topic badge'
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/* Trusted badges with labels: verified, staff, danger */
|
||||
class BadgeList extends React.PureComponent {
|
||||
render() {
|
||||
const {formatMessage} = this.props.intl;
|
||||
let badges = null;
|
||||
if (this.props.trustedBadges && this.props.trustedBadges.length > 0) {
|
||||
badges = [];
|
||||
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>);
|
||||
});
|
||||
return <>{badges}</>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default injectIntl(BadgeList);
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||