mirror of
https://github.com/thelounge/thelounge.git
synced 2024-06-19 22:15:10 +02:00
Compare commits
4567 commits
Author | SHA1 | Date | |
---|---|---|---|
0d9c184f19 | |||
4de413070d | |||
0955d9df06 | |||
cb4aaf6a97 | |||
45c2fc87ee | |||
29fcc2da05 | |||
12679081c8 | |||
0e48014d5a | |||
4819406af5 | |||
74563effa7 | |||
cbab10f416 | |||
4dfeb899b4 | |||
3259ac596d | |||
3fbbc39cd6 | |||
9ae9482223 | |||
a3953405ed | |||
9086bc648d | |||
da2572fe25 | |||
d9977df315 | |||
cc0aa5e8e5 | |||
02df78b0f2 | |||
18b0e06855 | |||
d5db9c653b | |||
f7926267d9 | |||
8eb398c5cc | |||
36cb75ee99 | |||
1ec67a6605 | |||
8372c5a57e | |||
5567f07a7c | |||
a200bab8bd | |||
91ac363cc6 | |||
6c9d2c36a1 | |||
6241eed8f4 | |||
03151e0ab1 | |||
7f5e0f3ebf | |||
5e444be37b | |||
c8664301ba | |||
1edb5a72c1 | |||
31d987283a | |||
4ceafb653f | |||
f25fee4c6c | |||
96848c1c1b | |||
4b07e05491 | |||
fc9805545b | |||
82e4150cc8 | |||
e61e356f1e | |||
5001d607b1 | |||
8c41356ae9 | |||
e2b56cf16b | |||
92a0affba1 | |||
edb96f683b | |||
5c8951ffc3 | |||
c3fc54e158 | |||
917fdb2a0a | |||
b8400a3a46 | |||
071a5afda6 | |||
5274fdc21a | |||
b8a9fe08ab | |||
a4afa08add | |||
4614c35486 | |||
540144c417 | |||
bb7c3925c6 | |||
9898f38de6 | |||
9f2c82e152 | |||
17ba07db3b | |||
0311e5f836 | |||
4d0474b897 | |||
14b9169899 | |||
50037644c0 | |||
7287c6bcaa | |||
bfca0ca612 | |||
300bd4c84c | |||
42ea66c343 | |||
1565eb8d05 | |||
29750a3e51 | |||
3ea5170e6a | |||
fe4f497fad | |||
c20cd6bda1 | |||
1c4ce5d4a5 | |||
9c4d24d1f7 | |||
35e38d13c4 | |||
bf7eb0e727 | |||
5ee9c2b338 | |||
e15b121080 | |||
98452ccc18 | |||
a8e7022d04 | |||
60486bf5e3 | |||
46f3fd9682 | |||
56215382a3 | |||
9ab9ad0f56 | |||
0660a8772c | |||
f5c691f37b | |||
0067c30273 | |||
843db1727b | |||
e9ef59b641 | |||
fceffd42b9 | |||
b89b0cad53 | |||
c869ea9a73 | |||
9aee3e3e98 | |||
636b5c5b04 | |||
6984e8f25a | |||
e43cbb139c | |||
e57e547b74 | |||
3217536245 | |||
194b4e1a2f | |||
88c8830a17 | |||
7073584f1c | |||
8e6920af1d | |||
7bc184b252 | |||
4d237600d5 | |||
383907c2b8 | |||
f0ee3be6fb | |||
12a0b0b6f9 | |||
d716402da2 | |||
d0b71aba32 | |||
3f0ee6a961 | |||
b67e4699f5 | |||
68ba13ca12 | |||
3eb19135f5 | |||
549c445853 | |||
2466c1b1e4 | |||
f5867c3643 | |||
231c498def | |||
eeaec413d6 | |||
515f894c13 | |||
e8f6ba5b08 | |||
07276bbde4 | |||
9ad92e1860 | |||
7923d4a2cd | |||
9248358169 | |||
6ab52bc9a9 | |||
48213955b9 | |||
682b3b91aa | |||
be3e27aa19 | |||
c09f751552 | |||
fb5864ee00 | |||
3bd5b704c7 | |||
139ce47b73 | |||
45563d9a59 | |||
e2fda1fb84 | |||
a77fbb894f | |||
fe50a90235 | |||
a8be84028c | |||
25e55ce75c | |||
113e9bd2fb | |||
2b146ba3e6 | |||
f95dd29a0d | |||
91dc719c93 | |||
5af893db3a | |||
daabb76781 | |||
393d0a63b7 | |||
037fc479b8 | |||
646bafab99 | |||
d4c77c74f6 | |||
eeefeb229c | |||
29c5323bfd | |||
a12ddc75d8 | |||
dd24cb1300 | |||
ae6bae69ac | |||
b5372e3ed7 | |||
d15998d919 | |||
436bf6a180 | |||
1d2fdd95b0 | |||
eaa70caad7 | |||
aa95032760 | |||
e636121d7a | |||
083abae750 | |||
01cfe3d19d | |||
7f0b721790 | |||
edb1226b47 | |||
b0ca8e51fb | |||
21b1152f53 | |||
74aff7ee5a | |||
14d9ff247d | |||
aec8d0b033 | |||
60ddf17124 | |||
20227b174c | |||
d18182da8b | |||
ea35040b42 | |||
97f553eea8 | |||
6603c1a6e6 | |||
73a529acea | |||
2f40d9dbcc | |||
d1561f8ebc | |||
ec75ff00cb | |||
884a92c74b | |||
77b64c546b | |||
cc59e6b578 | |||
fb1d79f5fa | |||
100ff3c198 | |||
d893feff1c | |||
88a5fef4ea | |||
5b64ecbe68 | |||
5024acd7dc | |||
bbfada251c | |||
8cec292f2c | |||
22ae594cc3 | |||
1c6bec2323 | |||
9105fbc23a | |||
8c54cd50d8 | |||
59de6afd3f | |||
b506966b08 | |||
785ec0a0e2 | |||
250433c875 | |||
d4d5a8e386 | |||
bcca111a4d | |||
b686059c6b | |||
ff77a33663 | |||
d308e74183 | |||
f999db99c7 | |||
76c896aea2 | |||
08413c7b6b | |||
48301b1ca3 | |||
03795a2718 | |||
2985727996 | |||
9f05a75c39 | |||
c0b38d4762 | |||
2878f87879 | |||
447a237fc6 | |||
430a865e9f | |||
816b7686e3 | |||
2e019a2fdb | |||
4f9ca3e192 | |||
57c4d5513c | |||
3e21bfcbea | |||
607b9fc96a | |||
1a1153aed6 | |||
54ff563247 | |||
ed0a47fe2c | |||
3af4ad1076 | |||
79fae26f39 | |||
c6b1913b91 | |||
071ad96d9b | |||
2ef8b37009 | |||
8aa5e33b1d | |||
43a2b397a2 | |||
c43a47afc1 | |||
14575c94cf | |||
303f53fe72 | |||
06f1387f7b | |||
c5326e8795 | |||
355c5d6fa4 | |||
7ac2a6fd77 | |||
c4879fdbba | |||
4255c1cdec | |||
ae9d312b2a | |||
f7c6ba5eb1 | |||
4d60d9c282 | |||
af49ef21ea | |||
7a9ddc01e1 | |||
8f08cf3d0b | |||
e05871fd2f | |||
ede48ab034 | |||
4c6fa550aa | |||
9388960497 | |||
7bce779254 | |||
a7b85db990 | |||
f4ef11de3f | |||
3a63484762 | |||
04b2bf036b | |||
3066f48a69 | |||
ed40c83a2b | |||
f21f665384 | |||
12d9ef34f0 | |||
9ee1cf13a8 | |||
ba1a4206a6 | |||
59cf29ef4a | |||
8e43d8083d | |||
3cd0a75ac2 | |||
21d1dbaad6 | |||
90ad06a29a | |||
0c7cc85184 | |||
3be805bd38 | |||
e25c296901 | |||
4babd17383 | |||
b408843ff1 | |||
0f3487c533 | |||
21ada132b1 | |||
2f162daee1 | |||
3ac9c36d95 | |||
c30da27f95 | |||
30a3ba489a | |||
0dca3954f4 | |||
e8b6434144 | |||
edc6f77c64 | |||
0dd74a93bf | |||
4e954b919c | |||
eb509f7100 | |||
845dabad53 | |||
6b00ccf82b | |||
34a01c2dd1 | |||
320075e376 | |||
d58fb84565 | |||
a049a01aeb | |||
76098d7e76 | |||
a67cee1ee4 | |||
efd24fd12c | |||
bc4c3082b8 | |||
d471a4c959 | |||
4831c20804 | |||
eddcbcc766 | |||
0183d89384 | |||
95e56300db | |||
8e249d46af | |||
50e8d2a890 | |||
7f6059d5b7 | |||
8ca9ee873b | |||
402332340b | |||
4742a07721 | |||
2f8dc01930 | |||
fade6a8d2e | |||
dfed1dd757 | |||
d67277d996 | |||
95aaba43fa | |||
3e7255ff20 | |||
86e376fc03 | |||
899762cddd | |||
063aca948c | |||
25642fbe98 | |||
c2e7390127 | |||
d10a59395c | |||
8fc696620f | |||
c6a202d6ab | |||
7c9ed14909 | |||
bdc1f23107 | |||
e9a09f5447 | |||
d93cd88dd5 | |||
2f04150461 | |||
c816e4053e | |||
4cff2ccabe | |||
26b7fbf2c0 | |||
243cb10e2a | |||
7304acd8e0 | |||
511209a100 | |||
2ce374fe85 | |||
00366967ae | |||
f2c59c23e2 | |||
90d17cacc1 | |||
12c03a868d | |||
b7540b5827 | |||
6f13735a7f | |||
60bb561e49 | |||
e305e23c43 | |||
9d34955836 | |||
a8149c0f1a | |||
21d1eea6b8 | |||
e1ae79cb9c | |||
429efb0c3c | |||
c3e3322a79 | |||
e31c95e32d | |||
f785acb07d | |||
bde5c3d443 | |||
375164ca88 | |||
7f3ac62e0d | |||
ce3ad56ced | |||
efd3b64564 | |||
6b23b87063 | |||
502fb7a705 | |||
c854d27d3d | |||
2803018c5a | |||
0ebc3a574c | |||
958a948456 | |||
52b8a2a78e | |||
661d5cb5b0 | |||
e597e75847 | |||
8b1a4f72fa | |||
502780c5a3 | |||
073a38ef1e | |||
c67df36a29 | |||
d50296385f | |||
068de0c10c | |||
d61ab7e7a0 | |||
2d4143b779 | |||
f55f772659 | |||
982816ff20 | |||
8204c3481a | |||
deeea274da | |||
d34b58811a | |||
dfb4217167 | |||
f8eb0ebafd | |||
fd14b4a172 | |||
1597c2c56e | |||
4c7337b625 | |||
0765d209f2 | |||
7ee4b80a6e | |||
21c8b0d17f | |||
89245455ce | |||
d4bbd9191c | |||
5037383c4c | |||
83e11b0143 | |||
51c9ce078d | |||
8095d9e88a | |||
221884166d | |||
19307d05e7 | |||
dfe288ef16 | |||
b5ea7cceb3 | |||
0ad033fe0a | |||
5a4a39b9d1 | |||
cb17f8d87f | |||
5a803ccd23 | |||
53f6041f42 | |||
dca202427a | |||
6b617f893d | |||
d62dd3e62d | |||
f068fd4290 | |||
bbe81bb2fa | |||
f04a06682d | |||
5e1cbe32f9 | |||
ee8223c200 | |||
cc3302e874 | |||
89ee537364 | |||
e62b169a6a | |||
f6b292107e | |||
bea4545abf | |||
cebc6d069f | |||
0fa203569a | |||
30e9f45fac | |||
117c5fa3fd | |||
621fa92036 | |||
11f7ae98be | |||
a95ab55154 | |||
38bccd3635 | |||
3240997347 | |||
57ed37c1fd | |||
0495761c44 | |||
520646a212 | |||
0cb4791cd0 | |||
740618ca49 | |||
e97216518a | |||
31739b8ac9 | |||
e221e708c1 | |||
c8cd4057bc | |||
d6e1af0e7d | |||
d72d8694bb | |||
80f65c5b72 | |||
bc709af9fe | |||
e7d18a91c0 | |||
ddcee5371a | |||
194b85be4d | |||
f715c833e7 | |||
a15ac88ff2 | |||
4af5fc6f33 | |||
dd05ee3a65 | |||
2e3d9a6265 | |||
c205b89523 | |||
5f7acbf994 | |||
d4cc2dd361 | |||
38f13525e6 | |||
99c48dbcea | |||
9dbb6e5e19 | |||
791205d4f0 | |||
437dd1667d | |||
24bdc46b0a | |||
5a383814f6 | |||
1f39e078f4 | |||
6f64243671 | |||
31b67b7786 | |||
abf8906757 | |||
c8115e22ac | |||
aa7db1e7f7 | |||
da02350725 | |||
c9c8cadb1a | |||
3726a8d00b | |||
605b75c6ed | |||
5e8adafb3e | |||
487d880d32 | |||
7cb8d33122 | |||
bbe103ca6f | |||
ec757c9b69 | |||
7b725ea55c | |||
0d12be138b | |||
7db0d4619d | |||
bdd6e71049 | |||
57b1e51e9f | |||
20ed3e6dc5 | |||
e4840b4d75 | |||
d7bba325a7 | |||
815319810c | |||
37d7de7671 | |||
e362704f6b | |||
48f2b79c37 | |||
3a84290314 | |||
ff886846a8 | |||
b2a363f099 | |||
3796485217 | |||
3202b79990 | |||
a42325d801 | |||
bbc7280c41 | |||
b76058e4cf | |||
ace09d434c | |||
4d9442d9e3 | |||
56bf078e29 | |||
9f7a2e942b | |||
f440b67dbe | |||
ae7020f569 | |||
38fa3bee22 | |||
2e1b2d44f6 | |||
69f3501165 | |||
8a92bc9fb9 | |||
7cf95d3cbd | |||
53f5b8e991 | |||
d145fb3738 | |||
551f85ea51 | |||
66455f2c40 | |||
c12dd6c740 | |||
cb28204517 | |||
e2e050d3c3 | |||
17b174dddb | |||
027c5b4ff7 | |||
1ed4f57afc | |||
53b4d00732 | |||
ba210e853b | |||
bd2a6cc5be | |||
bcd4a060ec | |||
ed3ec6a560 | |||
8edec1a5a8 | |||
9dfb2a3fdb | |||
4be9a282fa | |||
337bfa489b | |||
1e3a7b1250 | |||
3fb18717a7 | |||
76cbec9ac6 | |||
734f5b18d3 | |||
d228a8c4f4 | |||
f07d6b1ea4 | |||
d0fab98c1d | |||
d4d139505f | |||
9528515647 | |||
c0b81902f5 | |||
a86fa168b8 | |||
3e387156f7 | |||
7e0afc90fd | |||
dcce9eba25 | |||
b1aa8528a4 | |||
4489d5c8b8 | |||
1f8881a1d7 | |||
c7e504eeab | |||
4db2d28216 | |||
be498e8f93 | |||
e999171f29 | |||
0d209fce09 | |||
acf520bd9a | |||
26c2562124 | |||
763047889d | |||
e0bbf19d9d | |||
0fce974f2c | |||
cd7916b6d9 | |||
2c79d53c6d | |||
514c6fbf95 | |||
1953e03253 | |||
981de663fb | |||
2c2dd1c76f | |||
ecc0b9183e | |||
4065d5de97 | |||
1c08b6dce6 | |||
0c50c2d274 | |||
96c2d2419b | |||
304d207820 | |||
35d8f4e212 | |||
3c70fab7c6 | |||
0ff9703a28 | |||
dc3a387120 | |||
212212fe70 | |||
9b9b357001 | |||
684d7f2db4 | |||
8040945913 | |||
168f2ba46b | |||
d9f2fed398 | |||
84d779a4d0 | |||
324fb9023e | |||
cda3bb4e7c | |||
117792fb4d | |||
c69588dd10 | |||
361af7f514 | |||
5b76ec45ee | |||
d596c0cee5 | |||
1bb5b74236 | |||
62fd807f78 | |||
5d6746c9c4 | |||
60f4b3a434 | |||
a05dd6c612 | |||
244daea66c | |||
5f78574ecd | |||
e39e9d2f8a | |||
98e38c8947 | |||
40fb2190fa | |||
1160517c2c | |||
f719027566 | |||
cffb838284 | |||
b02001c079 | |||
79e56d1c4b | |||
b54cdf7880 | |||
cb404cd986 | |||
1d5291929c | |||
172cd63739 | |||
368f3f910b | |||
5f7ec9e8da | |||
4419029d2e | |||
af96f7771c | |||
315198ac0b | |||
f4096234d4 | |||
bfdbbce77d | |||
9dbf647f7e | |||
6dfd51bb57 | |||
371ebfb810 | |||
c439e51617 | |||
58110189fe | |||
54d1be6b29 | |||
1199183157 | |||
40a5ee70b6 | |||
3cec329e3b | |||
25d493453e | |||
f3af454c9e | |||
186f8f68cd | |||
59280cfdfd | |||
67503efd21 | |||
7ba977d56a | |||
2a901b3475 | |||
2777cc2db9 | |||
979dfaf3eb | |||
9592563a27 | |||
0381cd11bf | |||
b5e99c0489 | |||
ea619f5463 | |||
3cab39c59b | |||
fd730eeeb1 | |||
a8d438261a | |||
3bb8d2f4b8 | |||
80e0e0fd16 | |||
3da5e8e8ca | |||
411ce5d2f8 | |||
602de668ee | |||
393d4fe591 | |||
a3a9a2cdd9 | |||
044cd2403b | |||
544146d9aa | |||
97f3800785 | |||
8ab486ef0f | |||
cf18d04f06 | |||
5d7e62ed67 | |||
206d554ce1 | |||
578b1947e2 | |||
56d4a6afde | |||
3ba7fb6de4 | |||
21c6abdd1d | |||
80acbc7c06 | |||
1e896a9672 | |||
5d76ed888c | |||
2b634a6ba6 | |||
2693db4274 | |||
1d33e0195a | |||
fcffab1259 | |||
02ccbc1f69 | |||
bb4ab4f168 | |||
8a57f90b65 | |||
9a0ba1da6c | |||
5c614785bf | |||
a48f449c59 | |||
2ab671664e | |||
91a0815bb5 | |||
ebe39b26dc | |||
7b28d3c0f8 | |||
324f3aa30f | |||
e9f0313892 | |||
969d3e4ec1 | |||
7873847a7e | |||
cc0dc6266e | |||
535ac7ca39 | |||
c8cdadeb02 | |||
beb5530c65 | |||
8fcd079204 | |||
0a6c33af57 | |||
162b42d9b0 | |||
03d38812e3 | |||
35fcacb767 | |||
d96704835a | |||
0d839c501e | |||
24316fc304 | |||
11ba27d809 | |||
a59c5d65fb | |||
7fdd363ee8 | |||
75cf4445c4 | |||
18b003db9c | |||
372d74db69 | |||
bbda392c3d | |||
23f6886cc1 | |||
521426bb05 | |||
69c37a535b | |||
98e8640932 | |||
998f8d2beb | |||
058b3155d0 | |||
e0e12c1960 | |||
16177eb9f4 | |||
5e0a12b124 | |||
6439afd5c6 | |||
4dacaa46f3 | |||
426841e6b7 | |||
22801a629e | |||
47b151ab51 | |||
d05cf5fe62 | |||
3e4b22255d | |||
cc97d91ef8 | |||
c5e18e3cdd | |||
79c57ebf38 | |||
d106889127 | |||
b33fd78ed7 | |||
bec25f6243 | |||
646a98270a | |||
5a7781eabc | |||
cbe81968ee | |||
a42a1fc6a2 | |||
a2d23810bf | |||
aa310fe877 | |||
a046bfe8d1 | |||
6b852d14c8 | |||
5a9f3c5f70 | |||
f23cc0712c | |||
7107372a6f | |||
867fff33c0 | |||
e5a6554c9a | |||
38c0c343c3 | |||
53b7c46e69 | |||
e7a8476cfe | |||
a3f0314f6b | |||
3fdc42350e | |||
243f514243 | |||
a93ccd680f | |||
53a7227e2e | |||
8bb2fbbf15 | |||
0fa37a6a05 | |||
d5b6a8521f | |||
c5fcc5d72f | |||
9ec02d1e91 | |||
beb9bcd8d4 | |||
8fc7a6c0df | |||
6182d23758 | |||
c369a764ed | |||
4d310cd545 | |||
fa854fde78 | |||
6f7fd80044 | |||
28c413319f | |||
af236dd280 | |||
58217cffb1 | |||
fc6c916e7c | |||
ad8a315cf9 | |||
42bafe7165 | |||
df5befb60e | |||
db807d0c56 | |||
ab0d9e6200 | |||
adf1b5abec | |||
2c30293ad2 | |||
042cfb7582 | |||
dbf6ff064b | |||
7b1cb88658 | |||
7b298cf439 | |||
a985d763d0 | |||
d097370316 | |||
a3229f1cdf | |||
cadcc4b97c | |||
24a738d521 | |||
b95643e1a6 | |||
3f984fad4b | |||
9b4f55bdb6 | |||
abcad094d1 | |||
0bfcd955e3 | |||
04cf2277d9 | |||
26a38b12ab | |||
bc7a920de5 | |||
78da0eb674 | |||
db8102b058 | |||
4b96682d7f | |||
11aa52687c | |||
bd4e821614 | |||
c5f6b4617f | |||
c66f9c885e | |||
bb41871873 | |||
115d970604 | |||
ef710a2631 | |||
ddff3ac162 | |||
0aabacd549 | |||
0fb6dae8a6 | |||
ee43e7bdf4 | |||
e010fe47cc | |||
89390b3fc5 | |||
c2c66031c0 | |||
846da41b01 | |||
3a6ac4e5ec | |||
1b13905195 | |||
13d4f035df | |||
3fb9c8523a | |||
544594a7ad | |||
e36ae64c83 | |||
be141bea65 | |||
40aaa17c9b | |||
d6a23061fc | |||
de86c144b5 | |||
fe0178c0d2 | |||
49cd90d0e9 | |||
e6856a9e7d | |||
283ef445e5 | |||
08f45eabb2 | |||
db9eb05dfa | |||
df4f78098c | |||
0ccbb90d98 | |||
3a42b5385e | |||
14d76f8023 | |||
a5e9463431 | |||
f213a8973c | |||
9382beb3b1 | |||
c6d7bd4b4a | |||
8dd9bc0e98 | |||
7df94f01a7 | |||
d94d09f4ba | |||
d248f98618 | |||
5cef511469 | |||
c6282b0a50 | |||
1913a3ade6 | |||
386f90614b | |||
d1995a0f7d | |||
aede86bc98 | |||
a496ba8cfc | |||
d600a10f48 | |||
500034ff5d | |||
e4069f8ce9 | |||
4f6659897f | |||
5329483a40 | |||
dc0e233fe0 | |||
6b074a6660 | |||
57bce195de | |||
34086369db | |||
8ce947130b | |||
70fcf6f3ee | |||
f0a3611d1e | |||
df6226c7ca | |||
8dfa7305b3 | |||
850b49d802 | |||
8a14b75a47 | |||
c94ace5843 | |||
19c7a513f1 | |||
fbad88f9da | |||
2f29089bbf | |||
afe136fee8 | |||
9474cd96d3 | |||
c782ca5b93 | |||
e6fc726c91 | |||
7c17662fea | |||
f99e4eef77 | |||
c974ecb14a | |||
ab66c3f487 | |||
32ec420763 | |||
7fef41131a | |||
de12699fa6 | |||
d09f6f6144 | |||
27195dd34a | |||
b29850b2d0 | |||
02c0290ee3 | |||
43a70df1b1 | |||
247f20c8ef | |||
6fc72624aa | |||
3d1834cc5e | |||
eb056c4997 | |||
9aadf1a739 | |||
03377c6ced | |||
877e4acf7d | |||
aa84e13656 | |||
0e7a5f5c9b | |||
8fa8eed1e5 | |||
b2d5cdd4fc | |||
4529118fd9 | |||
651a7ac2e9 | |||
51b0ec1e98 | |||
ee16d98a94 | |||
2bbad443c0 | |||
a4f4d23693 | |||
a76e75f609 | |||
69986b3ee5 | |||
c2e8eaf9df | |||
41831d18b1 | |||
800fc95278 | |||
3e9262a345 | |||
a9fb563c01 | |||
e7a8258ac0 | |||
0322c043e3 | |||
e790a72e59 | |||
6ca3bae73e | |||
c89b2bb0d6 | |||
1c004cbd17 | |||
02357ab9de | |||
d4bf0e365f | |||
61ebd65367 | |||
e622662c16 | |||
7ee0732f56 | |||
75926432d0 | |||
3fde2aa7b9 | |||
27b3e50a64 | |||
b9540636de | |||
eef782fd2c | |||
5b602c72dc | |||
570890f2f9 | |||
ea5c95ac94 | |||
b74b692391 | |||
ac842108f3 | |||
12ceb10c75 | |||
037f09a22f | |||
f66ee9473a | |||
2194f91a55 | |||
df115333ba | |||
4307d2da9d | |||
c89dcca449 | |||
1df4dfad4a | |||
8fb6f291f8 | |||
fedaada5a9 | |||
381b6904e6 | |||
86e570efb2 | |||
1e38262d69 | |||
9e13694b21 | |||
7bf4f68ff8 | |||
19e7017d31 | |||
b398a0696b | |||
14ed73ed9b | |||
b97b145df1 | |||
c29ae50392 | |||
3557bf00fd | |||
67e4a4bbb2 | |||
f63f1abb7c | |||
1ef7d5ed49 | |||
928436a9ce | |||
5861ffadf2 | |||
2d88ae7503 | |||
82c83c5f18 | |||
19d6b7d98f | |||
d588ecea58 | |||
b6782da837 | |||
8bf55527ed | |||
2a11c07ba9 | |||
5720c98869 | |||
7fce28ad90 | |||
8c6460b58a | |||
70937d29e0 | |||
f1fc7a8968 | |||
40954c9a3a | |||
7d6f98d974 | |||
d658b7dfd5 | |||
87299bb893 | |||
0dba477eb3 | |||
9a5d80cecc | |||
89165d798b | |||
ec65fd17af | |||
5a1963647e | |||
d6cace3959 | |||
9502b6adf0 | |||
a8a2bd7755 | |||
a0cfa4900e | |||
3e26611e9f | |||
aaaf498ada | |||
bf12d7f4c3 | |||
07e4663e02 | |||
1de351794d | |||
a7f4008ec8 | |||
9e77dc3cca | |||
1ec728c2b0 | |||
ccea8a35f2 | |||
d63a85a15c | |||
2f434be75d | |||
a2c1d1175b | |||
181a198994 | |||
63a420ac21 | |||
3e5933bfd3 | |||
0ac1fcb471 | |||
531ea920e0 | |||
cf4a776a93 | |||
4f7dd37303 | |||
c0258b847e | |||
af65f11b68 | |||
79beff1d8a | |||
0b7c76ef49 | |||
970208b470 | |||
21c496d534 | |||
b7c5f2031c | |||
b1115475bf | |||
f979c72ca7 | |||
5e6b5f7400 | |||
4becb152bb | |||
761d482572 | |||
189f7d84ba | |||
020323ca45 | |||
d0d3a205b9 | |||
d01d39deda | |||
70f45ab7f4 | |||
d7c641ffc7 | |||
c21ccad823 | |||
5fcfcf4f23 | |||
5f3133a609 | |||
6a0708b676 | |||
fae9d75d6d | |||
67d9317f20 | |||
c5f9ef3e3d | |||
9d7888814c | |||
bba2f21f5e | |||
f8448c2521 | |||
bc862698ea | |||
cf64de66c9 | |||
dbe1427e7a | |||
b6bd869d5f | |||
a6ee6efb6a | |||
6ae3821e12 | |||
9e278dc812 | |||
2323afc0d3 | |||
2ef16d5f9b | |||
b605bf3a95 | |||
91a377b015 | |||
2544b19525 | |||
34abca6af7 | |||
111201f212 | |||
cb2a7d02ba | |||
a3e4d4c99d | |||
32d39410da | |||
7ddfc63327 | |||
21d872519f | |||
8fe9add310 | |||
0d9571e43e | |||
47624efd24 | |||
ff52ee58e4 | |||
f635292dfe | |||
99def3c0ef | |||
91a32d9d51 | |||
9e8033e36e | |||
eebfb7a6c5 | |||
8ec54169f3 | |||
6f58a875de | |||
b7035d3cae | |||
7effc2f873 | |||
2b1d04938a | |||
fa0396a764 | |||
d2d3880f23 | |||
cf842e8ebf | |||
f50a40dfb4 | |||
e3f0cd4fc3 | |||
8f102f7316 | |||
b904b4875c | |||
c3f96472c2 | |||
72b7906949 | |||
612b84ceb7 | |||
a4f0add6e1 | |||
3ac2d2c22a | |||
fd9863d919 | |||
b3ad1b1419 | |||
d29f2fb251 | |||
1918485d8c | |||
eeac12fe49 | |||
78426087d1 | |||
1eafb231af | |||
154ac3a8fa | |||
4888f538d7 | |||
d23f7af439 | |||
bbd9bbeb46 | |||
75ec058910 | |||
05feff3d28 | |||
02da351c3c | |||
c7b78779e5 | |||
bc087ab74d | |||
6315befa32 | |||
984bb76c5f | |||
1299e1e4c9 | |||
f3236538a0 | |||
b4d02c3c56 | |||
3194777b98 | |||
f593af9b12 | |||
889d2f8482 | |||
7ea79d10a2 | |||
cd1171b4c1 | |||
97a2805a8a | |||
a119e5c4a0 | |||
ebcbabde95 | |||
a863c2c553 | |||
253d820225 | |||
f1c04be695 | |||
7d418d6cbb | |||
e7b6fdf0c3 | |||
56d5c77c76 | |||
336504c306 | |||
c54e7f9f47 | |||
a740074fd9 | |||
1de0294642 | |||
9443f4e848 | |||
f4cbcee1b0 | |||
56883f655a | |||
24b53c9ff6 | |||
230f26156a | |||
42715720b1 | |||
31a7228509 | |||
6322132921 | |||
70bff5616d | |||
9bb0b02261 | |||
ff25f43eeb | |||
b8322d6aa7 | |||
23bbdb08aa | |||
ac9bb8442b | |||
eadd225363 | |||
a7c9cf5baa | |||
5de26dbee9 | |||
a9f5f72218 | |||
bd4c4414dd | |||
1453c75c3f | |||
1800c2a892 | |||
7747114b2e | |||
48be6771b2 | |||
abb8566ce7 | |||
bc7bf9870c | |||
c3d8855ec3 | |||
dbc829b5f8 | |||
c4ff314a12 | |||
147d3b2a9d | |||
0e2e26ef88 | |||
a439102403 | |||
e1157642a8 | |||
91333d9009 | |||
9f6c374d36 | |||
aef952678a | |||
b73755b0c4 | |||
67378c7a48 | |||
10d58f2f19 | |||
ca23475620 | |||
5aaac56a1a | |||
6941b3cb7f | |||
b5b5d5e7d4 | |||
39a8bed4ca | |||
033565bfc5 | |||
b507b340a6 | |||
ae276a69eb | |||
5310c90a83 | |||
512fc5ca04 | |||
13a7a4b5c1 | |||
fb9e3e6a53 | |||
06d6dbe3a3 | |||
8263b17861 | |||
24d4276a7c | |||
8d8183eabb | |||
801c7a07c0 | |||
61d8884bef | |||
5d017b09b8 | |||
9a1fb0c0a0 | |||
88644314ce | |||
4ba458b9ea | |||
28c740ab67 | |||
2591ae9e8e | |||
0f3c292098 | |||
16646e1586 | |||
8978be2fd7 | |||
183226e190 | |||
185fcfef33 | |||
7a4ee6db27 | |||
ea11e5cfd9 | |||
ae7426b6ff | |||
f72d29b391 | |||
9a7ae60392 | |||
fc61500a29 | |||
bcdd548238 | |||
2e9d375f36 | |||
f436dfdd41 | |||
480a2576c3 | |||
f0253075d8 | |||
96a983b310 | |||
53bd9c2f68 | |||
ad6569cf06 | |||
4ac25d4bc5 | |||
878ac0d192 | |||
a0d10989ad | |||
36844f948c | |||
2b0afcacf2 | |||
beb9fbd940 | |||
0642ae58ce | |||
635b8b3eef | |||
bcd2e7cb08 | |||
91b48e061d | |||
89edc6aa30 | |||
be78a5809a | |||
ce6f188acc | |||
b8eaae3a50 | |||
9105a3db06 | |||
18a10a9efb | |||
e772c4eab5 | |||
177d4d78ba | |||
fce71f4a7c | |||
6ee71779d1 | |||
8a281bacd8 | |||
f8f692af05 | |||
3900e9dd81 | |||
58553d7691 | |||
f3d2dc1678 | |||
05ff8530cc | |||
0fcaa46095 | |||
1754c77517 | |||
999095b7df | |||
d39a6dd012 | |||
bc4f9b5f51 | |||
8e00e26054 | |||
c1607bd8e7 | |||
4ce2efe86b | |||
99bb58a7a7 | |||
49189d5649 | |||
74181d0783 | |||
b885673341 | |||
013e55a9a6 | |||
2e31325de6 | |||
0ad907982d | |||
2156c6ba97 | |||
e7d0ad93f9 | |||
a5bb486012 | |||
a3490af5a3 | |||
484ec95f24 | |||
d8495bdc54 | |||
2891c1e89c | |||
c8eee85b28 | |||
f0d985637b | |||
dc288c4c66 | |||
d810c3aec9 | |||
5f06cfe483 | |||
9db3b009f3 | |||
de6aa7df90 | |||
b72e49c902 | |||
63f412aab1 | |||
b756de003e | |||
aa96a2ad31 | |||
37cbc1562c | |||
5b555835e2 | |||
119afbdf2f | |||
31f814d66d | |||
d584dd7e11 | |||
ee2e2608a3 | |||
7e321d399c | |||
86d84b70a1 | |||
c5596f658e | |||
16ade38851 | |||
87d902d028 | |||
600313fded | |||
4641cb4b8c | |||
599c4ce769 | |||
b14a8a267a | |||
55e99b299a | |||
20e47aaadb | |||
55767d733d | |||
56dfa5ef40 | |||
39e70670b5 | |||
beac893dd0 | |||
6e655d457e | |||
a7db950a52 | |||
f4528e6f00 | |||
c35412625e | |||
4c3594b832 | |||
52bf7b116e | |||
6de6f8185e | |||
487a438f02 | |||
4bf4b7baf0 | |||
9c2607df89 | |||
05e806d762 | |||
68618da7f1 | |||
eb171c01f5 | |||
881b3eda19 | |||
a46c9e8403 | |||
9da36b9966 | |||
c2e6b13504 | |||
42d568ad2c | |||
4b29cdeb0c | |||
a3c204f978 | |||
0f1e7d5036 | |||
a6f70696f3 | |||
4c177b8d02 | |||
ecda9e225e | |||
a9d2b30d96 | |||
424bc4f7df | |||
52002c3e22 | |||
80b0e8ad12 | |||
b000a594f4 | |||
0ec242738f | |||
5e935189a3 | |||
0035910765 | |||
9e6b6c582f | |||
b7562362c1 | |||
9926c83ba7 | |||
6c0acfcfb7 | |||
babadbd955 | |||
64aa510abf | |||
0b38a88147 | |||
b3fa46ad10 | |||
5e30d3698d | |||
04de1ebc30 | |||
ef473b0f53 | |||
0e62103010 | |||
a4ef328d8d | |||
e5596d9d81 | |||
e47e54b934 | |||
b8de7e68b5 | |||
464c54b2cb | |||
6b46a55def | |||
37f4e0ff93 | |||
9277907c43 | |||
960862df27 | |||
1376177a09 | |||
d2994d501f | |||
1ea9d6c2ac | |||
0a6f8a76ec | |||
c57b42c22b | |||
f93061de29 | |||
06a15181c3 | |||
d11b704f22 | |||
48f96e9ae0 | |||
47b254a29e | |||
7ba2807b01 | |||
6121a3ab0b | |||
d8ab40d8ee | |||
8d119630eb | |||
5233fb2dbb | |||
234938ed4b | |||
3630ab8519 | |||
c463d1ddd3 | |||
44a8925b8c | |||
7216b8124b | |||
eb7f9ab298 | |||
6f04216af5 | |||
b79c91ff1e | |||
d2e4f56219 | |||
ee0002fe6a | |||
ab8593d3cd | |||
8f15548770 | |||
d99d56fe81 | |||
365613f0ee | |||
bec6665044 | |||
8976fa163e | |||
db866f9823 | |||
568427ca98 | |||
d9985e7318 | |||
9b9db35e3c | |||
77279675ec | |||
2127153b73 | |||
66b6517855 | |||
c62d3c2f15 | |||
abd414beb9 | |||
58003e1f59 | |||
63fd0def6c | |||
8a515a8a70 | |||
e20b1a55c3 | |||
b48adc434d | |||
f213875d48 | |||
7401174523 | |||
641cd951cb | |||
b734a8b983 | |||
a8ffe7768e | |||
9500edc89f | |||
381cfb7099 | |||
e0d5f4c2ff | |||
0134276f01 | |||
258db10ea9 | |||
3ca9fd2e80 | |||
9db1d0f7c8 | |||
5a0e0b6718 | |||
44de1dd03f | |||
2cf2c6d0e6 | |||
94978b334c | |||
96099694d6 | |||
3f0afed3c9 | |||
c6054c3f40 | |||
22e7217e06 | |||
1c13ff7922 | |||
6c9d6d04de | |||
bf3f593004 | |||
8e25fa8a3b | |||
4aec23a6fc | |||
7fdb70d451 | |||
d95f1fa5b6 | |||
989ecdb2f6 | |||
469fe577f2 | |||
1fb78d7218 | |||
9e76fe2a76 | |||
054760d49f | |||
f5884957a5 | |||
606c62dc70 | |||
0b5cbceffd | |||
dbdf98537c | |||
f12a13916b | |||
fbf6f48d7a | |||
f7f92c5f39 | |||
86abe1e2df | |||
1bdeae2b76 | |||
f8642dd2a5 | |||
1b2894bf99 | |||
61305cd27c | |||
0f44c51b00 | |||
d0697d39d7 | |||
0639fdb410 | |||
705261cdb5 | |||
b5a7bc6be6 | |||
1ccd910e14 | |||
25b870fcd1 | |||
4ca97bc955 | |||
1b9040deed | |||
87c9abe9da | |||
4d5f15b32e | |||
342b97f68b | |||
6aabd9bacb | |||
15100c853c | |||
55f3f9ef13 | |||
77c2fc0ea7 | |||
60b398c6d8 | |||
6bedf78019 | |||
c17e0a0813 | |||
6422136d50 | |||
6fcfcb6219 | |||
99eab4ddcb | |||
7afafdd25e | |||
66cdec0075 | |||
8b71e6a18e | |||
803fe930f8 | |||
34436f9a72 | |||
965b50a341 | |||
e4c01f7c2f | |||
d8682126f3 | |||
2f3f2c4d90 | |||
6c20a59993 | |||
f92a442330 | |||
d607111c86 | |||
245b44ab02 | |||
62171e13b3 | |||
b176d26302 | |||
10cba8d9b0 | |||
b890e7e976 | |||
bbe6b34371 | |||
2451f222e8 | |||
a9bad593b0 | |||
63540e102b | |||
4e6bd9e943 | |||
0dd0d8fb12 | |||
e8ba4f4fb9 | |||
5b68fb5054 | |||
8b04979eac | |||
510b859df9 | |||
f1a11d3a0b | |||
08f77528d9 | |||
184936fa38 | |||
4fbca2b219 | |||
309b9027be | |||
b025647ad9 | |||
cdd28ba2cb | |||
5b7abd6e02 | |||
aa0df1d6b4 | |||
1a7135c5e0 | |||
de6d6906f8 | |||
5657d9c221 | |||
26bf0850d7 | |||
36f4284e07 | |||
4d3fd1c8f2 | |||
b9e7e401ca | |||
6b9d2baf97 | |||
d5ac13f91c | |||
3f928d8742 | |||
efc421c0a6 | |||
0bdac63953 | |||
304e8bf5b0 | |||
fd04a528f6 | |||
8842242fb4 | |||
6dac3d122a | |||
bcd7e7cfff | |||
d7513b43dc | |||
ba4ec355e1 | |||
fe17324fef | |||
7dfbf215db | |||
4bfd599393 | |||
10e31a1ce0 | |||
46f9f6a6a3 | |||
fef4b8b93a | |||
8a2631f503 | |||
858fe0185a | |||
f1e6ada2d0 | |||
4682a83827 | |||
e83a1ac7d8 | |||
f0967ddf5f | |||
144c4b7ce9 | |||
69bac8f517 | |||
f1a0ccbe13 | |||
fa57814678 | |||
fbdd888c3d | |||
2e49175840 | |||
6164862af5 | |||
b5f5775cfc | |||
41e3762e57 | |||
299a9324f6 | |||
df5cb3081e | |||
85bc4df1e2 | |||
14f6032316 | |||
23ac0fef32 | |||
a2349f96cb | |||
1c190d1adb | |||
5b34395587 | |||
2266350f23 | |||
79cbe63067 | |||
e73575a342 | |||
5c64eaf41e | |||
b93cae2e01 | |||
00cdb6e808 | |||
e0cd9cbcdf | |||
5fe0710724 | |||
c4ddf6d93e | |||
7b507e5248 | |||
1870145674 | |||
ff4fd0a13d | |||
98aef9b6ad | |||
bf0a8c4e4d | |||
ba3e0dae79 | |||
5c8c854d18 | |||
5921bb1ee1 | |||
c6f77f0668 | |||
27e08baf25 | |||
05c69dfb6d | |||
e9db7e5f82 | |||
8ba286a308 | |||
7ef88523ca | |||
42ee21bfb8 | |||
0c246f0bbe | |||
84107ab516 | |||
66302d25e0 | |||
093ef2ff55 | |||
a8e7cfd2cd | |||
d288cbb625 | |||
242b068cdd | |||
0a9157b935 | |||
34939d9961 | |||
6109bf3faa | |||
be7f2c3c84 | |||
b52ae5414e | |||
53934bcbca | |||
9e84328748 | |||
9d3a5d4d86 | |||
29d188f927 | |||
6301a0012a | |||
9568316cd0 | |||
1ce6821585 | |||
f056cce641 | |||
5a3f17b647 | |||
af1da708a3 | |||
08871ef75a | |||
78bf87f29c | |||
8d17bbd6a2 | |||
b1f5ba87cf | |||
280018e052 | |||
99175bef82 | |||
6e0ab062c5 | |||
17588560e6 | |||
813b49d7b1 | |||
30595ed23f | |||
c055a07f45 | |||
a12a24adbe | |||
56cc6d0b68 | |||
e4a6aa3160 | |||
10932abb87 | |||
b18cb15f7d | |||
dbfa5c5746 | |||
55e5c69958 | |||
d2932ccea8 | |||
769585e72d | |||
fe031c8b12 | |||
27986f5811 | |||
8696f03e8d | |||
d79b6e6c2f | |||
6c0fc8dbb4 | |||
b808c89322 | |||
7effbd35a7 | |||
2a02f96017 | |||
40ea6e85ce | |||
a5b9773eac | |||
92b2f6220d | |||
5db6714718 | |||
5f7f56d8ef | |||
0f90f6b7c2 | |||
8f2eebf3e4 | |||
c71c9135ab | |||
e1e8dae02b | |||
fd9ed3335f | |||
c7338e9e11 | |||
44f25324ff | |||
e9458f0a65 | |||
146b6d02f4 | |||
67aba10e34 | |||
0ac698e0bb | |||
25b65b39db | |||
7c5f4c404d | |||
18bfd32704 | |||
74e8c7e51c | |||
c12e7bcebb | |||
356a896fe2 | |||
bc6017aed7 | |||
126bf1794e | |||
7a8bb0376c | |||
03dd00284c | |||
749e7f4469 | |||
7a350ac69a | |||
c04beb8b08 | |||
f1eee6c9b2 | |||
d2f0590c73 | |||
72a954b865 | |||
60ca8850d9 | |||
456cdb2f54 | |||
d9f8f45169 | |||
8cb49ae56a | |||
b16d023657 | |||
cd6821a196 | |||
117dd0faed | |||
0ee52e47e4 | |||
5da8d089c3 | |||
6cc2471f4e | |||
a414563eae | |||
5611f98a4e | |||
03d5fab794 | |||
0d7b980f90 | |||
6091514630 | |||
2365c9489e | |||
f269ac3bee | |||
def56dc694 | |||
c1920eb566 | |||
a9f97ddf22 | |||
4a345eb6d9 | |||
c108c20c91 | |||
86341f063c | |||
f1d806a80f | |||
e2c74a1014 | |||
0ba11d8a0e | |||
c6b568c165 | |||
f3b383ce63 | |||
408eb75a88 | |||
f2bf1fa90a | |||
f0b0c53536 | |||
dcf08ecac6 | |||
8fb8b94650 | |||
a8dd85d21e | |||
6a920fd4eb | |||
61369b3e5a | |||
98708a2ebd | |||
5b55ac7d02 | |||
52ce1aebbd | |||
dc93bc0f1e | |||
935b193a64 | |||
5b4a5fd4b1 | |||
309be48906 | |||
317f4fb991 | |||
f806c32c49 | |||
6731e584da | |||
58a558247b | |||
578f5fa1c9 | |||
ebbe798c16 | |||
0486f43f9f | |||
bfffc8d0df | |||
ead372e6e6 | |||
a59af9b941 | |||
05af830a15 | |||
f00c71c81b | |||
1495ce3772 | |||
0e9fdf9e08 | |||
b592657f7d | |||
0e8b9fdd5c | |||
c23c786c58 | |||
e8ed36bfd6 | |||
6f7444dfe3 | |||
d99b6d0a17 | |||
e3a2fa7dd1 | |||
7fbba14b69 | |||
059cedcf7a | |||
8840a80209 | |||
eda437f40d | |||
a0f684c0d9 | |||
bc3e6292b6 | |||
a91f5dfb49 | |||
dccdd4869c | |||
9ac1257e76 | |||
d550c6e11c | |||
0582303f3b | |||
1ede6c8463 | |||
24e41327a3 | |||
bbf92f1aa0 | |||
15d7f2f224 | |||
51360711c9 | |||
87244fb4d5 | |||
0e3d7bb5bd | |||
53a34a0509 | |||
7eaf6fb58f | |||
f5103ac4b4 | |||
2bc78e24a2 | |||
74cc1722ea | |||
58545353f7 | |||
fd6bc3ecb6 | |||
2a84d8239b | |||
c022377c49 | |||
e9cbea9569 | |||
8d227ee37e | |||
2d983b94eb | |||
371f676fd5 | |||
f7391f252b | |||
636c4a6204 | |||
3426ee31c0 | |||
cf0a222cf9 | |||
bfbb6627d0 | |||
a9f0b1d5ff | |||
1501566824 | |||
4ad7dc1ad3 | |||
bdfbfdd475 | |||
21bbfffb21 | |||
320832dfd9 | |||
45d7b0531a | |||
6032bd16a5 | |||
21c8e7cd62 | |||
3224c988b8 | |||
e64f53ad33 | |||
07ea17b180 | |||
278595df1f | |||
e60a8e8bff | |||
f2ea562d16 | |||
0bd676355e | |||
c260e1a82f | |||
4ef5c66fd6 | |||
3dae767937 | |||
db4b292a38 | |||
d90a81240f | |||
ad39229867 | |||
6199c3defc | |||
f945c29cda | |||
cf0a4999e9 | |||
674d9cfbd8 | |||
dd2b15b7af | |||
5d1eb385e6 | |||
801f56b168 | |||
7425033fdb | |||
36b105021b | |||
6b46097479 | |||
f6a432da32 | |||
69840cd8c1 | |||
661d4a9ba4 | |||
1d8bd7acb5 | |||
09ddbd156c | |||
320b3ea98f | |||
6d342b9847 | |||
f0dfb909dd | |||
45f2576e96 | |||
8b7fb33627 | |||
e923696bb0 | |||
c19cbd7ffd | |||
446f99f62a | |||
b089b92b1e | |||
2ee30abd56 | |||
eb0094618e | |||
bbbaf128bb | |||
c0b8f6f86a | |||
c813d6ee2a | |||
85400ed9c2 | |||
5f2651a252 | |||
fa68d74f9e | |||
c790d9fadf | |||
d6923d0c6d | |||
10b1cedbb6 | |||
12cdf280fc | |||
9784423808 | |||
e74c35687e | |||
a3be259567 | |||
c2ed3fae56 | |||
c70d0fb224 | |||
9051861f4d | |||
049e9a1680 | |||
57ba119edb | |||
83f3fe772a | |||
ec85372132 | |||
90ec37ce82 | |||
9b9c547e8c | |||
dca6543070 | |||
0c49f025b4 | |||
2a6c57abaa | |||
de76a86757 | |||
49dc6ffd8f | |||
0ac9601a3a | |||
e76d5d2ef9 | |||
d0444d7d7f | |||
f00dfc7524 | |||
21bbe7d4c3 | |||
85907f54ba | |||
9147772cb2 | |||
0cb8dc73bb | |||
b2cc8d9531 | |||
fcf7488e1e | |||
a71472a427 | |||
111c3665f9 | |||
7584f47c7d | |||
17365d9967 | |||
54a1e11f50 | |||
033f565c0e | |||
a4490bf1d6 | |||
91e0349486 | |||
f2309c7c89 | |||
5a0f1c1f4e | |||
3a6b075745 | |||
2044bc88dd | |||
d5ebdc943c | |||
cbaf4db339 | |||
16f8304c4e | |||
6a15fd95f0 | |||
dd9efad23c | |||
1adbbdda2a | |||
347802a4b6 | |||
94bdff4fa0 | |||
0c7db6dffe | |||
897f238c38 | |||
5c0a7722a4 | |||
d232ef1557 | |||
916da73108 | |||
80c6cfbd7c | |||
25da9dd63e | |||
703848919c | |||
a2a2aff2bc | |||
a1f183f216 | |||
fc1c9568e2 | |||
b164e95290 | |||
8972242863 | |||
6b8fea8afc | |||
c26de4cf6a | |||
743ae987ec | |||
2b5a13a043 | |||
aba2487126 | |||
742cd8d4bf | |||
2f635069e0 | |||
3c43a2bfd3 | |||
c4d6afe3d6 | |||
c8b22b2df3 | |||
8fa42c5c48 | |||
2049a16d64 | |||
e845e17a63 | |||
c6dca616e6 | |||
c393dd1a11 | |||
f76ad57c63 | |||
b74cc4387a | |||
431221c21e | |||
737afc759b | |||
7355c91839 | |||
af0d48de72 | |||
4f6565c24a | |||
5c4b402341 | |||
af777106bf | |||
70a795dced | |||
2d8417cd8b | |||
cd36555b63 | |||
ef500f12a1 | |||
055ba5caff | |||
b95f89c4c2 | |||
2b602ca333 | |||
ee92de0ff7 | |||
b5f2e7f0cc | |||
e0ec340de8 | |||
b994ecd1f1 | |||
addd4124bf | |||
7fd48d8155 | |||
467ebab31f | |||
e73bf1e9a7 | |||
5b17a2fbe4 | |||
111beb5f12 | |||
2ef3e3e5b4 | |||
6c10a2a6cf | |||
08635beb61 | |||
5a3ad194e8 | |||
c4a3108dc0 | |||
0da059118d | |||
69cb891b1a | |||
e71360ad39 | |||
3f7889e534 | |||
70d9d8d226 | |||
71f54f6a5d | |||
09e12affe8 | |||
bdb0a2efca | |||
98f75a5a1c | |||
bd2a6be257 | |||
72b0edabf9 | |||
9fe218a625 | |||
f614ebd712 | |||
1a53635d1a | |||
83648c0571 | |||
8e1ce206e1 | |||
3c0754e6df | |||
1b47d0fe90 | |||
b90db81025 | |||
2b60730532 | |||
c64a8728b5 | |||
3cbf67bacb | |||
f51bb4ea65 | |||
f0d37d7e08 | |||
2c280685ef | |||
67a84fe2f1 | |||
6a75ab3f27 | |||
b8ae278fba | |||
7a34c661cf | |||
20d40d2a32 | |||
aaedaffa83 | |||
59dd897099 | |||
586ceacd2a | |||
3471413a00 | |||
1410256e42 | |||
74fd296d61 | |||
36002757be | |||
a78a7fbc92 | |||
b26b73e994 | |||
1f2e69a550 | |||
3d9f185494 | |||
d84a2dbecc | |||
b550591262 | |||
17b2d2fc32 | |||
a13bcb8e93 | |||
f87bb85f37 | |||
38dd077bdf | |||
295ed871c7 | |||
86f3baae90 | |||
7ff508ca4e | |||
d3ccf17953 | |||
f98a70d58d | |||
de25fdbf87 | |||
89e14d6ddc | |||
1822a67ef1 | |||
0f3a088404 | |||
561cb5cfa8 | |||
874385814d | |||
61f86b1557 | |||
19d8178606 | |||
95cc9a47fb | |||
8a224809dd | |||
6f8364b1dd | |||
901d96c8cc | |||
3ed54a3e11 | |||
39213bc4e7 | |||
c62305039e | |||
b7b3717e3b | |||
14c0e4071a | |||
0ede916b78 | |||
14c2cf6b0b | |||
79e0558b73 | |||
48713428b7 | |||
d5224a9d01 | |||
1de39524f7 | |||
064d36a6cc | |||
adef07f6d8 | |||
aaf2a563c8 | |||
75eb812f05 | |||
959ec5b598 | |||
a35675ddc1 | |||
5b3399f95a | |||
e58a895293 | |||
ddebb22afe | |||
fb250682a1 | |||
eb971a7d23 | |||
1f2ca91d89 | |||
e09599aeae | |||
32e86dc699 | |||
97cfd1a2bc | |||
372f9f7ce4 | |||
6c57339668 | |||
a0c2495c42 | |||
fe4e0343a4 | |||
d8a6b137fe | |||
64efa0cf7b | |||
4d55614db1 | |||
27a06b533c | |||
479d2eadf0 | |||
59e37cf73d | |||
46e6b7282e | |||
e26abb07fb | |||
0e5d64e027 | |||
298aa4c664 | |||
8f46f101b8 | |||
c89aea3c1e | |||
2aa5ed44ad | |||
0c97a8e48e | |||
c72fce75de | |||
49fb6cc049 | |||
5ba5505ba4 | |||
3df6e00b70 | |||
dbec8330ce | |||
8f7bee8dd3 | |||
8379a46c53 | |||
41b78f1ae2 | |||
19d69ba4c3 | |||
41e5090fb0 | |||
5595b17060 | |||
7988a0d006 | |||
63c638e9ad | |||
d237647f8a | |||
c47dd965c9 | |||
d4198e4360 | |||
c4a637ab49 | |||
28949fb5e2 | |||
2273c913ac | |||
00e59000fd | |||
ee91217d98 | |||
93cb395b75 | |||
d020875556 | |||
2447c00a61 | |||
6d4ee6e76a | |||
0daf985dff | |||
4e17067a07 | |||
f22cfe0547 | |||
ddfadcd326 | |||
5dfd1b8809 | |||
ed8f4c6c57 | |||
4d34a837a5 | |||
1ca16816c2 | |||
ebfecc3e9d | |||
51147f35b2 | |||
8aa8768dcc | |||
e3e0495a9f | |||
8c3c426998 | |||
ee4de6a871 | |||
7687c90edc | |||
5027b9bf47 | |||
ba517bbac9 | |||
44d207f5f5 | |||
e66f47904e | |||
b0264f0725 | |||
dedc70d3ff | |||
13f728e2c0 | |||
76ff5dfef5 | |||
742cf433d9 | |||
2dfebda8a1 | |||
ebd894e915 | |||
bc3b1875cd | |||
b0969d9856 | |||
4f1fe2b6f1 | |||
a39a49f711 | |||
5fead0d909 | |||
b0cff121ec | |||
4c684ecad4 | |||
9c6c03b23a | |||
b3a13f1aa5 | |||
493f9b1b6c | |||
8c19613bce | |||
037fa6d114 | |||
c39f0d01e6 | |||
deb5d2d090 | |||
390a0b8e83 | |||
75f7666548 | |||
48060e9743 | |||
a552a36a4a | |||
577b1a5952 | |||
a26976918b | |||
e7f1cff44d | |||
f163e20a93 | |||
e3abd5db48 | |||
59ad41aea4 | |||
667f203476 | |||
7d53aa145a | |||
086237a06a | |||
048a272a61 | |||
b115f07c35 | |||
3bfd84ad5d | |||
28f6971a27 | |||
7827d16571 | |||
a81a1e6c44 | |||
71841bf721 | |||
5d13e4c97d | |||
d6abc96a30 | |||
a24c03a35c | |||
fb8290399f | |||
d90b3a5f0f | |||
36508faa2e | |||
f8bc2ec285 | |||
4dd0f71843 | |||
a2d636ff2d | |||
a69bb80500 | |||
d4e89127d0 | |||
f293bdbdc3 | |||
faf684576c | |||
2b4ee0d6c6 | |||
13e4af3600 | |||
bfa97390be | |||
b96e5cc042 | |||
a658d768d2 | |||
fe0dc97197 | |||
eba043d0b3 | |||
7e27f2d058 | |||
600115b8d1 | |||
d09a35b129 | |||
c4236e0e12 | |||
cee3a50ddc | |||
eb1d9079a3 | |||
526cc126a0 | |||
34b0985587 | |||
edc3e498e6 | |||
0052390c4c | |||
c728bcb95e | |||
860ea0b3f6 | |||
959f2a7786 | |||
9a07a8f96c | |||
bb604f2a2b | |||
5d873d42a3 | |||
3b1c3f9c13 | |||
0e6252ef4d | |||
b89aaf1030 | |||
8745910452 | |||
0567c38a4c | |||
07da692c2b | |||
a564d2f8cb | |||
9eff3b51d7 | |||
c546279d89 | |||
25dc6b52b4 | |||
5a94727d79 | |||
3f52e15444 | |||
f62f92b36f | |||
70772ec92d | |||
5e4b8605b2 | |||
ecb0bb7590 | |||
d902a0ad67 | |||
9f76b83fa7 | |||
d35b0fdb34 | |||
f6b6a9138c | |||
70ac60857c | |||
8bb7da8a55 | |||
5ccd6b76c0 | |||
6f95e3769d | |||
05510c0694 | |||
58ad80c3bb | |||
54c8e5bfc2 | |||
c083b2ce19 | |||
0939adad0a | |||
ccb1595ed3 | |||
d9844720f9 | |||
554c602230 | |||
04e1e004da | |||
16d070c19e | |||
9e8e138359 | |||
846ab3ece3 | |||
62ed80010f | |||
aadfede911 | |||
7615f18536 | |||
cc1620b5c6 | |||
c4095c28b1 | |||
c244d1b9ed | |||
57aa286ab4 | |||
4fffb4af2b | |||
0a0626cc5f | |||
3be0285467 | |||
b864674e84 | |||
4b7fdc85cc | |||
2d88116e5a | |||
4d665b6a5e | |||
7169622d01 | |||
2b91bf0374 | |||
3c9ba130a0 | |||
9ad785c44e | |||
0f72b57d36 | |||
d7dcdd1cd4 | |||
4753d58c0b | |||
68925a532d | |||
f4ce7ecd1e | |||
aa6475e83d | |||
217cfb4701 | |||
e69e448396 | |||
49652fc40a | |||
e241d31c15 | |||
fc9e20c09d | |||
c6b19d5144 | |||
b4eb538903 | |||
51fb42c379 | |||
d1e7392b97 | |||
91f944f5f5 | |||
c2c94025f3 | |||
8b904fa136 | |||
858f8425fd | |||
96efaed07a | |||
1f27c4fad4 | |||
ac2165bdca | |||
72bebd8681 | |||
85025a6840 | |||
ecb4dd9675 | |||
b8948856f3 | |||
65713e5509 | |||
f4cf33da4b | |||
c8819e9a13 | |||
7bb588be5d | |||
59d2d6fec8 | |||
914ad9f11a | |||
580d83858c | |||
5345d84d1f | |||
db126dc75c | |||
e0879ed075 | |||
9c7e27ada4 | |||
d4a729d316 | |||
1650383846 | |||
b3c9a57d0c | |||
734699de5b | |||
ea8d734fc2 | |||
2cdfe5ee7a | |||
ad984fa377 | |||
805a86c67e | |||
5f9a706fe0 | |||
932d9c809b | |||
e92082de57 | |||
ed911ddba4 | |||
8514503fc3 | |||
5041e82980 | |||
efa0aeb2c6 | |||
526a689e14 | |||
b7f445ade3 | |||
998bdd49aa | |||
fc979c17f7 | |||
2500602d3b | |||
ba356ae34c | |||
e246c06c32 | |||
fc532be5df | |||
d7ff13a41d | |||
20816d509d | |||
f40e35515f | |||
a7bdc99d47 | |||
e536730dc1 | |||
9ef5c6c67e | |||
33de4fb4f4 | |||
cc7b4e4817 | |||
2fbdbead55 | |||
133e7bf710 | |||
48eeb11391 | |||
7e5c2672b2 | |||
06d3178633 | |||
19d7333dce | |||
9f1b9fa310 | |||
ef6c884441 | |||
1922314827 | |||
4371cd6db1 | |||
2af6b87369 | |||
fe0c9a22da | |||
9003566d5c | |||
f5c3111445 | |||
3ff71701a4 | |||
a1c307ee8d | |||
0ff5a7df67 | |||
03233c3f4d | |||
803de9a877 | |||
ab8d819193 | |||
2b1197880d | |||
2741421401 | |||
701be850b6 | |||
e04bfe39bf | |||
48767d6030 | |||
54a4085b68 | |||
6b70f2a99f | |||
01347787b7 | |||
ee8228ced2 | |||
8b38cbcb52 | |||
442389c544 | |||
eac4fb73f2 | |||
7f481634f0 | |||
10948b3bb7 | |||
bf2c6a6bcf | |||
ac23215115 | |||
b2e5be33d6 | |||
820a67802d | |||
a3c77266e0 | |||
295b3a4251 | |||
d3a98a523f | |||
257ce5d0a8 | |||
776b20cf4f | |||
f1594e8a00 | |||
011fd99503 | |||
5f13d3308f | |||
f20ec1b9c3 | |||
13eaed3eb9 | |||
6da7aa4cb9 | |||
db1144749e | |||
20ae2e0bed | |||
d48d619cb1 | |||
6660041376 | |||
7595880517 | |||
9e6a3569d5 | |||
3a6df0a36f | |||
2ff8d09a44 | |||
48ae5a4cdd | |||
85ffaa1ec7 | |||
983c6602a8 | |||
807856dbef | |||
492ab82255 | |||
633b1d506c | |||
7a08d8ac3f | |||
d5ac3a7040 | |||
1fcdc51861 | |||
63ebf1d301 | |||
38a949bdab | |||
5f19eea6a2 | |||
6cc8341543 | |||
85acfdcf92 | |||
2ce93e82c7 | |||
a56e6de51f | |||
95a3ec42c2 | |||
bac95325dc | |||
4765f06940 | |||
1b38e6cc02 | |||
4cbe72f635 | |||
0d3fc7b4fb | |||
db651276ba | |||
fb118b43ca | |||
e34c966ba8 | |||
edc316cf04 | |||
5f1aa1b24a | |||
126ad16bc0 | |||
f0d0511b5f | |||
2196e15799 | |||
db1bc95e78 | |||
1425130436 | |||
15e1a0af7a | |||
0df403433d | |||
6a0229ff43 | |||
921307a914 | |||
14de647a1b | |||
f093f29302 | |||
b52fe1a782 | |||
56306b995c | |||
851c6ebac8 | |||
2561c71a96 | |||
ef6684b77b | |||
01bc09d01e | |||
7886e831bb | |||
d999c22df7 | |||
eac14ba58b | |||
7c823c90a5 | |||
d8f080ede6 | |||
001b643d88 | |||
521b62be24 | |||
b95c8236bd | |||
9949fa9963 | |||
d36f60bb4f | |||
17e01746ba | |||
a4f829e0a5 | |||
be1cfae166 | |||
3b3b2f4304 | |||
25d5ffee78 | |||
0c53d784aa | |||
64791d1109 | |||
84d30f9842 | |||
0317c59bbc | |||
06648da93e | |||
515b64c596 | |||
dd30b37fa9 | |||
67a7215e63 | |||
1dfdbbcc78 | |||
edf7f001ae | |||
a4ebafabcd | |||
a6d8dfcc8a | |||
5abbae1c75 | |||
29de64e36e | |||
94d6718c3b | |||
c731b57911 | |||
deaf0514c7 | |||
8e628584f1 | |||
f8f47d7c68 | |||
aaff970571 | |||
e910ab5194 | |||
4e2a94583a | |||
cdcd7df425 | |||
e26687bbd3 | |||
c9d857dbb4 | |||
d5e1146776 | |||
e71bd3a9a3 | |||
702aa6fdfa | |||
39d8a6a7de | |||
a9e774b13b | |||
559e464216 | |||
ee0f0e8727 | |||
779ef07442 | |||
5b73f32023 | |||
4555cf7dcf | |||
3ebc4db256 | |||
786bcc5691 | |||
6b65d63a29 | |||
faed0b0439 | |||
67be3d66df | |||
39c998fa34 | |||
46033ee29f | |||
721c0d4374 | |||
0967fa26cf | |||
8687a513de | |||
806650dcf9 | |||
fe68f2a1ee | |||
21cb4dca1e | |||
0eddb9153d | |||
4c35b80b88 | |||
af976c52c3 | |||
ff8b706cc6 | |||
34e48d6040 | |||
8ce56a6da6 | |||
7fe5267d1d | |||
5e856730b6 | |||
702decab5c | |||
81aea4994f | |||
1247d29fcf | |||
85aad8c9ba | |||
bd3bb08805 | |||
67adbc328a | |||
0979fffd08 | |||
74b17ca5cb | |||
8bda748096 | |||
fe5539e3c4 | |||
d78dcee47f | |||
f0114835ce | |||
3f0f851de1 | |||
9a7cd95b97 | |||
d37dbde757 | |||
93f182b98a | |||
9546deff31 | |||
85ba9d933a | |||
e66c992148 | |||
cb52a6c1cd | |||
59b1b1d216 | |||
d6fc926f73 | |||
dc4a42c7da | |||
aa0918a6ff | |||
0c3b8e2a6c | |||
05b3a26dda | |||
d9f75ff73c | |||
04cb4f49b6 | |||
dae84af2ce | |||
63fc320ad1 | |||
4892161059 | |||
e2d9385000 | |||
ecfc5cc28b | |||
db97c9bbdd | |||
fa39f57d61 | |||
d08c1d0ede | |||
bfcb9d7bd9 | |||
45648a08ad | |||
2d8a28fa36 | |||
2fba91bc06 | |||
99c6e65d31 | |||
95370622cc | |||
d9bffd718e | |||
b7ebec9ade | |||
2d5317ca0c | |||
4f60797f9d | |||
f80635ca1f | |||
54acbe0335 | |||
dba768203f | |||
f235e6a64d | |||
738b8a7e8f | |||
c29b3b5c4b | |||
93ff33140f | |||
451cd3657a | |||
830fdda91a | |||
ce49049cd7 | |||
b0f4ad57f0 | |||
892478d2db | |||
1a84b3c0cf | |||
5d479f3e62 | |||
0bc36ad486 | |||
d63c212b6a | |||
4cb1fb48c7 | |||
59f440346c | |||
3e0711ca2d | |||
66f8b0a123 | |||
88ca219cc9 | |||
57f13aac0d | |||
1235d8a699 | |||
ec11b62b08 | |||
42ab78d4fb | |||
33e9fb58d3 | |||
2c10729225 | |||
7c3b0e81b4 | |||
fc99a7f9e1 | |||
a173040537 | |||
eb1fc22715 | |||
01dab06b0a | |||
49f484df89 | |||
1ebb71f3a7 | |||
0f06ed1f56 | |||
1d2ee012cd | |||
a3bc59c36f | |||
6a0b53c71f | |||
6e8c8da5b0 | |||
bf034a187c | |||
dc5fd9e78d | |||
18271f8c32 | |||
8ed2866ed1 | |||
5941a009e0 | |||
066a08eee0 | |||
dde3465cfa | |||
56248116a2 | |||
f9459ee1c3 | |||
5723be00c7 | |||
c574234b99 | |||
c0708fd955 | |||
e3dfedf8b5 | |||
9026f73533 | |||
a23f0a76e9 | |||
a58c12a09e | |||
231622c7a3 | |||
3153830005 | |||
88df9148dc | |||
ab27a4e39f | |||
74fe337afe | |||
a8a634983e | |||
0f1ae61a38 | |||
4b6ef5d978 | |||
2893dcc6f1 | |||
892737d1d8 | |||
20f99863d6 | |||
f3323e3583 | |||
2b7c4021ad | |||
310c5a843d | |||
b61bef80e5 | |||
94f54cb5b4 | |||
c1f7a9ef46 | |||
00510e07ad | |||
0525f123a3 | |||
8c4f04206b | |||
cfc6a6adc8 | |||
3ef844b6be | |||
1362592afb | |||
9d3f5efbd5 | |||
2c85f61e62 | |||
05afb2dbd4 | |||
04dc1feaba | |||
92098286e7 | |||
68de8cdd22 | |||
50fbf93d64 | |||
b0571c5a36 | |||
bb2a1d6a6f | |||
7e7ac8229b | |||
12cf5ed070 | |||
f84e4199e9 | |||
bee160d623 | |||
4937a79384 | |||
53d2e697d0 | |||
fb2206028c | |||
f7ebff0b67 | |||
b06eda8471 | |||
527c1564ae | |||
eb812fafef | |||
23a5be7dcc | |||
8f9e6931a8 | |||
fc2b8d94e1 | |||
cff4f1ce2c | |||
036a2ffb4a | |||
fe52719b9d | |||
0d7870ba25 | |||
e44318358b | |||
e0258d4734 | |||
7df26dc373 | |||
0084d3d04b | |||
a33108bd49 | |||
b7958be526 | |||
43315da408 | |||
fe4d92a4cd | |||
de9459dd83 | |||
bb28ecaff7 | |||
a4d52f7dee | |||
a6c0fa8926 | |||
71332f59d9 | |||
8c10436630 | |||
b6b767ee95 | |||
cac2ef65b7 | |||
9cb2ca3383 | |||
5d560c99b8 | |||
4ba5c911ab | |||
d8b49f0fa5 | |||
44c4476037 | |||
a05502f5c1 | |||
3453ed353c | |||
f3d79785bc | |||
16e38f92d8 | |||
79ba243053 | |||
0c72e8d018 | |||
16c4894bf9 | |||
b6c7f45298 | |||
7c1efb18d1 | |||
2e0100d31c | |||
b2d9dfe3a5 | |||
e792419c1c | |||
a121bec4e3 | |||
3c0f1408fa | |||
4e95aebe4a | |||
6aa8da4099 | |||
6029fe7c71 | |||
722cc23b10 | |||
1c5d81e38f | |||
df01874b24 | |||
212f4be3c3 | |||
be3260dcde | |||
67effc59dc | |||
f84d764822 | |||
f8b5983f2c | |||
848186d14b | |||
08931d7b3f | |||
d9f36ce505 | |||
6f70ce9e15 | |||
6f2a919d13 | |||
3f8fc0ef1b | |||
0c0b59b3fb | |||
f4bec8298b | |||
d0421a2541 | |||
988115b468 | |||
5b8b6b714a | |||
6b8f3a6f73 | |||
e173b84fdb | |||
c5e0840ceb | |||
881e8593ed | |||
0812ffc2e7 | |||
b99495334e | |||
f8a39a0ea8 | |||
a9eced888f | |||
c8f229f708 | |||
4d9c01deea | |||
b79ee2c180 | |||
80077b33b8 | |||
3a681bc33c | |||
b684bc65aa | |||
27b8333ebb | |||
ca598098ce | |||
457653717c | |||
1e40b1170b | |||
94f89dc89f | |||
c151ad69fa | |||
9769f023c5 | |||
ebe7546dd9 | |||
29c11e3174 | |||
0a1d67bf5d | |||
e96bb1e20a | |||
b0a3daf8a5 | |||
1c5c226a0a | |||
ea1fa25fdb | |||
e9182c1454 | |||
0c4e35b309 | |||
3df4661126 | |||
e172a36581 | |||
8dee92bd04 | |||
404de0ff5b | |||
a21affedb5 | |||
98d33a8d6a | |||
1966a64a48 | |||
c9c9748959 | |||
781f6aac22 | |||
aa1fb6a5cd | |||
dadccb7665 | |||
8b98c2c93c | |||
d8d1d3aee0 | |||
4faf7bfd3c | |||
0fbf301e0f | |||
16e4be12a8 | |||
e1b31d4b8e | |||
4084ff2e93 | |||
494f03e747 | |||
804c9ad11a | |||
2c17cddf0e | |||
5786230aef | |||
98644bfc9e | |||
a0d3fbadf4 | |||
01c3f6556d | |||
09853c4c8b | |||
3b60dcdd07 | |||
5d988e14f6 | |||
01981e30f8 | |||
61ad93b9d6 | |||
38d3fc3f58 | |||
e9ee768f39 | |||
392a747d88 | |||
ae72d5828c | |||
2657332919 | |||
013129da1f | |||
c8568b5429 | |||
3f3a22aa1e | |||
a26fcb45b4 | |||
199df6b729 | |||
32f17d50b4 | |||
f3cf69796d | |||
b066dc301e | |||
40bf9ee8ba | |||
b02c3b6c17 | |||
19693bc9b7 | |||
987cc6d3b4 | |||
2b653a30d6 | |||
a4ec875c87 | |||
71b3acb152 | |||
c13840e029 | |||
c33326e25e | |||
89355e50c3 | |||
759e69ed07 | |||
42717e3dec | |||
5f0f745d10 | |||
7ba14fe4e3 | |||
99cda335ab | |||
00b84d31f5 | |||
6d1d2e006a | |||
4d400b6ace | |||
e5b3c518e2 | |||
05303e4cf1 | |||
7d7005c8af | |||
893d59e7c4 | |||
4a0f319e91 | |||
3c4a9efe7e | |||
def494533b | |||
5ba0e33fb9 | |||
f1994352bd | |||
caf728a2a7 | |||
446ad6a5f4 | |||
7b3f7d1c59 | |||
b3e86dbab4 | |||
395be41728 | |||
74edfcaa04 | |||
d1185da9eb | |||
cd76512619 | |||
dd686b563d | |||
cc895e67ee | |||
7394e6b9f1 | |||
7b15c53ed4 | |||
f526aa8b8b | |||
4c103b467b | |||
b6e07a43f5 | |||
47b9924f26 | |||
4f3dbc4b8f | |||
ba2522ecda | |||
c3a1c77447 | |||
5c69fe104d | |||
f45f9a83ee | |||
32776f0642 | |||
8dff4a9478 | |||
26dc37033c | |||
e2c65fd0de | |||
5d4400ef90 | |||
9d8f02ce99 | |||
a8cad55fda | |||
140c14959c | |||
7c8441a93b | |||
839b07fdc2 | |||
1d4ab8fff1 | |||
6877199515 | |||
bd57c6d620 | |||
fea6fff13a | |||
0c5faf5879 | |||
e91f2aa024 | |||
2c5549a567 | |||
a21e0e34cd | |||
450f3f891a | |||
853f67ab40 | |||
f2cbd1efed | |||
cebfa6ac84 | |||
104692007f | |||
ae56191b9f | |||
d55eb28aea | |||
5452d26c17 | |||
a10ac4e7da | |||
ad0f638487 | |||
bc69ef5f0b | |||
c168e15db8 | |||
c70b4d4c80 | |||
0e7880a049 | |||
1bfaef1985 | |||
987f48ae13 | |||
0ba6200bb7 | |||
2303a02839 | |||
d00c70f804 | |||
86ddce974d | |||
c42fc55c6f | |||
cd94b5d655 | |||
8580592a91 | |||
04d86a3550 | |||
a899b1b46d | |||
36b6fdcc88 | |||
efdf11dcae | |||
7e5e031ea8 | |||
4b5252d285 | |||
f4b4cfdee2 | |||
c9f5e06ee4 | |||
a15b10ca45 | |||
5d8a581201 | |||
8133805dec | |||
a0f42af0d8 | |||
bb0450cb31 | |||
9926157683 | |||
8e130f0259 | |||
9249464445 | |||
797731262a | |||
d9206c1087 | |||
77947b46c8 | |||
b88a186d05 | |||
6a82114b62 | |||
3d43b96d5a | |||
8931570c02 | |||
80a12d98b4 | |||
e92f5d573b | |||
a8c777c797 | |||
825e3beba6 | |||
499beb0257 | |||
dbe95fcc13 | |||
b982623aaa | |||
a5625ba203 | |||
a138237155 | |||
ae692b1f2f | |||
e3ff385ae0 | |||
d83dcc35e2 | |||
1cd28a5ccf | |||
2bb8287519 | |||
ebfc6fa724 | |||
ebda927bb1 | |||
5792bff49d | |||
c6262a36e6 | |||
c369f0fdb7 | |||
c84eee22f2 | |||
771739cf94 | |||
739d44b561 | |||
b963fe3cf0 | |||
d97356e65a | |||
48187a0260 | |||
c431ac6306 | |||
060097c118 | |||
30bdfe9d3f | |||
0654a4373f | |||
2ab3518c52 | |||
207ab28b92 | |||
595915fefd | |||
5f5b5fef3d | |||
db803a8548 | |||
96569e71a3 | |||
121dd35c3b | |||
ee0413de4b | |||
2d2c836a34 | |||
aa1446c19d | |||
f9967a92c7 | |||
a54a726e93 | |||
0a774758b9 | |||
084e01cf46 | |||
f9d255b678 | |||
2e3b95b9ed | |||
dbe6df1ab6 | |||
1831e2e63e | |||
f0390dae63 | |||
eff6dcb514 | |||
aea779cfdf | |||
8e64670b4e | |||
9ab5b9d791 | |||
6116edaa06 | |||
0730825185 | |||
dee76adc0c | |||
e0e48925b5 | |||
1e09ccdb90 | |||
c20af6329b | |||
a229138ca6 | |||
3300276c27 | |||
7b209e5d31 | |||
e28e13bd10 | |||
9290264fa5 | |||
ebb63f2742 | |||
09fa3e5c86 | |||
0e930c9356 | |||
25840dfef4 | |||
e931866aeb | |||
1d2a11729f | |||
0b269423aa | |||
631fd6138c | |||
d4a23ffc98 | |||
7e332b817d | |||
18bca3bce1 | |||
a30e1e8b61 | |||
4f3e4a0865 | |||
77dc3086a0 | |||
0f0c9f2e65 | |||
51384403fe | |||
03a355f109 | |||
ffd51a627b | |||
c19fecb240 | |||
48f10ec52a | |||
e4209714ee | |||
95ac570c7b | |||
034c0947f6 | |||
8884c90fcf | |||
21b3e4b67c | |||
dfcabb51f0 | |||
211ebbfe5f | |||
d33021773c | |||
f3864f8295 | |||
fbce1ec5c0 | |||
1c16c329c0 | |||
244ab1378e | |||
4ff96cb28e | |||
adc6fa8409 | |||
39071aff50 | |||
c785bfa261 | |||
7840b56690 | |||
eaab413409 | |||
a994830bd8 | |||
4809884182 | |||
0f4e29d60d | |||
f14490cdb5 | |||
ff8ecdeef7 | |||
b06dc8022b | |||
92311e2089 | |||
3d82378cbd | |||
cf4d2bdfe6 | |||
9003768d56 | |||
314a6346c6 | |||
1ea8b576b3 | |||
f6d02dd816 | |||
7c024864b2 | |||
682207ffe6 | |||
b8dde41482 | |||
85d87a3b37 | |||
e4451fb207 | |||
4fe13ae858 | |||
ce87521b37 | |||
9b8e677803 | |||
182944bb30 | |||
f54cf50a0b | |||
bd5cbba03e | |||
b3c55bc200 | |||
17fbf10909 | |||
99e69c55aa | |||
7579bba079 | |||
9a2d845edd | |||
579118eb72 | |||
8c142811ef | |||
89bbbc0e27 | |||
8bf731fedd | |||
b0c18e06b9 | |||
113e77b1d7 | |||
ac59d99d8e | |||
7052e4721b | |||
eb7f311d94 | |||
d785af9c05 | |||
8ef1bc63e8 | |||
cf79ba3b80 | |||
76544a192a | |||
46c649c7ff | |||
19812197c7 | |||
0bf4e3fab3 | |||
cfa420de2e | |||
76bc057937 | |||
e2e1781612 | |||
e58ad69a38 | |||
f37e0ffde3 | |||
3e623e8b77 | |||
b27e92f11d | |||
03c4fc58a3 | |||
ba397c158a | |||
7ebdb84a30 | |||
eec6ce80ae | |||
3c3546c3d1 | |||
4ae7d5b0ef | |||
d260ee05da | |||
c870545b46 | |||
02a343624e | |||
f49bf19023 | |||
b81de36403 | |||
221b7145f1 | |||
6e1a73eea2 | |||
69788a6b9b | |||
35e13cad62 | |||
2b10cf203b | |||
c3ba9234ac | |||
2e4b543ca9 | |||
e452ba5e84 | |||
84ae91c525 | |||
17115fa74d | |||
6937e6e772 | |||
e80b058550 | |||
95a0045a0d | |||
589e846b73 | |||
bd8fdea618 | |||
6fb41b44d4 | |||
3457e2cfde | |||
13d21ce002 | |||
c9f407c224 | |||
2f777627b9 | |||
185fb9c71c | |||
eb648195de | |||
9031999033 | |||
80b5fa20fe | |||
347faa8674 | |||
cb59681cd9 | |||
e86b754390 | |||
07052d2870 | |||
d0f6c70df5 | |||
2a1e711f42 | |||
aa39f31fa9 | |||
41c1b3275e | |||
a9aac1648c | |||
fedbb0b819 | |||
4b75b42597 | |||
c30684aad1 | |||
ffeb2322f4 | |||
ab083f9eb6 | |||
1b657973a4 | |||
ed10e14b47 | |||
0995f95296 | |||
ce1e12b151 | |||
263d2ca133 | |||
b4c47b09b6 | |||
a07c80322d | |||
80b95b1fa6 | |||
c7f2a373f1 | |||
71912dad44 | |||
7da036bf34 | |||
87107c7ad8 | |||
5cd82b1a40 | |||
6eb2af1bd8 | |||
a1df89d956 | |||
74b7d22a44 | |||
f832f8e65b | |||
8e2b64d8b1 | |||
99181b9e15 | |||
bf89191c6b | |||
439924532d | |||
99056d3c2d | |||
2ad32b1be0 | |||
5fdbef39a4 | |||
63aaa9e1c8 | |||
bdcac810a1 | |||
6ec00bb159 | |||
3298ebe0e3 | |||
2482fdc7d0 | |||
3a40d17bbc | |||
0df3f8b731 | |||
1981da9667 | |||
47abb00f19 | |||
9f76532fa6 | |||
5efde5b2aa | |||
f02949ef0a | |||
62e24a9c2b | |||
bfd47de523 | |||
5055d7d481 | |||
0e4eb4e269 | |||
9e69aed06b | |||
f39e013917 | |||
27c694e6ed | |||
956dbac94c | |||
335ceb8013 | |||
1fcd81229c | |||
8635f2f3d8 | |||
64c3c5b446 | |||
6a4a45711b | |||
6d183325da | |||
2c852de7ff | |||
e9370c984e | |||
82ed742c27 | |||
3a3e89ccdd | |||
500472a358 | |||
4038236e8a | |||
dedaa1f337 | |||
e97db8e244 | |||
4f0a22482b | |||
4c9bb98a9b | |||
1c046ddac8 | |||
9cd9d4c7d2 | |||
4b3374c749 | |||
c67909a511 | |||
ca63aac056 | |||
a56dbd79ac | |||
057599819d | |||
520865c558 | |||
61a097f2f3 | |||
106092c630 | |||
e033010841 | |||
53436ef97d | |||
a2d20ecd42 | |||
c7082db602 | |||
eb522e9208 | |||
dd61e6e09a | |||
943e25cb3e | |||
d2b2cf308c | |||
a7d79c8a7f | |||
fe047264fb | |||
e23fe19504 | |||
d5a3241887 | |||
c26b703f66 | |||
70188bff72 | |||
9e51d12acd | |||
e04c100fe6 | |||
b1f110c1a3 | |||
a3c8212669 | |||
bddb31b8b2 | |||
e91a55fd99 | |||
f01bf0cc09 | |||
4ec6649b39 | |||
419f193348 | |||
d90b9445b2 | |||
ade556c300 | |||
7c96e1ff03 | |||
414cd390b6 | |||
e321c7eb99 | |||
927fa92518 | |||
7c4fce55ff | |||
9434f87d66 | |||
6e7eb04ee8 | |||
5269106198 | |||
97ae79a6a3 | |||
b0095488f9 | |||
89fe386611 | |||
0a6649e938 | |||
9980419bc6 | |||
af7e7e8281 | |||
a7817cf94b | |||
c018bbfaf4 | |||
568ae78fad | |||
a6aff32655 | |||
ed7ddf43fe | |||
1aa14b1f98 | |||
4b7d2ebe78 | |||
8b4d5b2981 | |||
76aacdfaf1 | |||
acaee577e7 | |||
b337a60027 | |||
be9db8c4b0 | |||
e8c5c5580e | |||
1a962a774b | |||
ccfcec2361 | |||
238eaa70df | |||
087b266408 | |||
c92b067a5a | |||
ea9da4b217 | |||
bc1a43e5d0 | |||
b6e9facf4b | |||
cb9ffc3a99 | |||
911b35793e | |||
132c47ac21 | |||
b3b2e2a4ab | |||
18f96934f2 | |||
d5aa00b753 | |||
5b40a6fb58 | |||
3ed7d96ea3 | |||
3b9bc48593 | |||
57b30e1b79 | |||
511df7cef2 | |||
8d5d9617ae | |||
6013977dea | |||
5a258796df | |||
56cc8fbe32 | |||
b8f0a4601c | |||
bb6a9657a0 | |||
bebc9db629 | |||
2c69a28ffd | |||
cb1127de74 | |||
90f8b9a817 | |||
c5f8bb20bc | |||
1d8a0e639f | |||
de028e5dd8 | |||
010ddab78e | |||
f1c84c1ede | |||
9686c0383e | |||
338b1d2642 | |||
a6a2336a6f | |||
68eeb76975 | |||
f23a27ce9d | |||
39992db5d0 | |||
f25207aa85 | |||
454d71888b | |||
0636a6f839 | |||
f028ac2ea0 | |||
7affe78e5a | |||
9f41abf246 | |||
58d617b70d | |||
9a5188930a | |||
6d6b99576f | |||
ace9adce19 | |||
33368b9ee2 | |||
1d7ceda9d4 | |||
f350ca11ca | |||
962cc973c3 | |||
a9d2f317eb | |||
b9bd90ffe5 | |||
b6fb9ef5da | |||
4db81e1f28 | |||
b6c2b201eb | |||
206802a55f | |||
7946603c54 | |||
81841bd005 | |||
f79c54adb0 | |||
01e7b88037 | |||
78f0e544db | |||
63b78ed7c4 | |||
79801af219 | |||
d087c726e0 | |||
f3b67b773b | |||
fb2cf54c38 | |||
dc6e29476a | |||
e321c90f2a | |||
8b4b8fbe9e | |||
d303246d24 | |||
aceffed61f | |||
dfcec914a6 | |||
e976719d91 | |||
9a0cb9f7bf | |||
41b1373c6c | |||
e57c7be2f2 | |||
1386d0ce9c | |||
92cd08ea55 | |||
2352ea5f92 | |||
91660f9e9e | |||
9487afdf24 | |||
29d634d9f7 | |||
c21a3d3bd9 | |||
1436985d30 | |||
1831134bca | |||
8d5feed487 | |||
02d8db74c7 | |||
509a2c9358 | |||
e3b4a8e0b5 | |||
7c00da2d33 | |||
1ebe3864f9 | |||
c06b96e90b | |||
48c76efe27 | |||
33b52611b4 | |||
1270c312d0 | |||
bad5dac3ab | |||
d7a37cfdc0 | |||
ecc1fdd799 | |||
88ce0cee99 | |||
3b9b5e26f4 | |||
7c0e2af0f3 | |||
d26db390c3 | |||
2162f4a55a | |||
da2f9c4092 | |||
2054c5cf82 | |||
9035acd05d | |||
c62665f47b | |||
4a7be7f954 | |||
6884b7462a | |||
42ab1d1402 | |||
c1ea46b430 | |||
ed06040b8e | |||
05482e952b | |||
557d4c4ddd | |||
d326435fe7 | |||
62cb252933 | |||
67a28d1e94 | |||
658dc5a3a2 | |||
45235ba7aa | |||
b93827278f | |||
79f1685d6a | |||
2745931e9e | |||
b037d597ed | |||
7f4aa36c7d | |||
6faedf7d16 | |||
16dc241cad | |||
41bd5504a0 | |||
2ba7b563fe | |||
24d94f4f29 | |||
30fd693a67 | |||
7cfd4afb48 | |||
22d2a0dc2b | |||
7306e30a60 | |||
9f02d4f956 | |||
c081fb378d | |||
66c7159521 | |||
e97c9ff265 | |||
ee8db6da8c | |||
95a2580b6e | |||
e3a000ead3 | |||
6e3421cd64 | |||
81b7f8abc3 | |||
81b5d6abdc | |||
42a02452cb | |||
cd1454e48d | |||
4c9573f8d8 | |||
593b1cb450 | |||
72fe898e60 | |||
b538360c5e | |||
a4f889d134 | |||
e0ceaf4af2 | |||
69fa473c91 | |||
c0436cde5e | |||
76deb4b2f9 | |||
d3b7a205b4 | |||
35c328b557 | |||
d0605d33b8 | |||
d6a150c5d6 | |||
bec241e7fa | |||
52ed6f77f4 | |||
05ddf16343 | |||
b57f2e9ad6 | |||
562a2cbed4 | |||
503abe985f | |||
2bb627e7f2 | |||
2156d6c41f | |||
8fa8526698 | |||
8435153de0 | |||
0e053f2f47 | |||
a39f68b4e3 | |||
dce3fb229f | |||
a27c33e696 | |||
6d2bde023f | |||
9d5de22d94 | |||
ce212e001c | |||
7c12883dc1 | |||
bb88e49dc5 | |||
5acb1d4fd5 | |||
f2ee299004 | |||
bae398235e | |||
11413a9288 | |||
621ab2ac07 | |||
9fb871c2f7 | |||
b3c282f663 | |||
aabe0824a7 | |||
8c06bd3a75 | |||
5ddb988996 | |||
8bb2185961 | |||
81913bca66 | |||
db6ff5debf | |||
4d45c5d686 | |||
0519c8a857 | |||
e14d911195 | |||
b3fcea866c | |||
6bb0f40fe5 | |||
40ec21a117 | |||
7830bfb096 | |||
932be73b42 | |||
295fe2bd09 | |||
e619579b1b | |||
113e30d03d | |||
96507f5fe7 | |||
dc27f5f241 | |||
9e62119145 | |||
94acacd21d | |||
219443ac1f | |||
e3ab1e064a | |||
7ae52d197a | |||
cea5d5ef2c | |||
0569ee2269 | |||
0490fc40e9 | |||
c803154dbb | |||
0a35000cb8 | |||
f8bb8f9d6f | |||
e950090383 | |||
a4b59fceef | |||
4d5fa57e22 | |||
9bcec3bea5 | |||
d83594daa4 | |||
5f190fc165 | |||
f3d45d6eb7 | |||
d67a4244b2 | |||
d3725937ef | |||
82a570bf20 | |||
31059131a9 | |||
2cda9ced8c | |||
61ce0e2f0b | |||
dbd6bee487 | |||
5ce3b27cdd | |||
017a338802 | |||
fad30dd80f | |||
50661b5947 | |||
0e785cf51d | |||
a2f78d0aeb | |||
05729b3803 | |||
8d5d6f0285 | |||
f53448c844 | |||
786c3d1e0b | |||
e129401747 | |||
b1dcbd3811 | |||
c9ba896d9e | |||
70a3aefd2f | |||
13e6f37eda | |||
039d1220cb | |||
174c6c744c | |||
8a71d1df70 | |||
40d2fdf5bc | |||
91fe10d25f | |||
7b1401e89e | |||
8b5147393a | |||
7ffaf48f67 | |||
f98708f3cd | |||
619b054b95 | |||
22743982da | |||
edc10cdf65 | |||
51b574d9c7 | |||
9c267d95ae | |||
92b5412d12 | |||
051eb83b02 | |||
9dc9f164cd | |||
14172fca0a | |||
da01d7a313 | |||
8e6de0d2e6 | |||
247bc507ea | |||
bf50a23df8 | |||
87d8905b65 | |||
0aa04ac042 | |||
cc7da34b44 | |||
3d28321068 | |||
cf7088ea79 | |||
2a74ace48f | |||
927da2816a | |||
5889547161 | |||
a131695388 | |||
59ff700a69 | |||
c2abc75e47 | |||
35562a9f4a | |||
c66fdcc527 | |||
7a5708714b | |||
ddca4255b7 | |||
7b926f7c32 | |||
cc4ab38356 | |||
db8aeaa827 | |||
486be09274 | |||
2b89500010 | |||
8c09be9db3 | |||
77d70d6d91 | |||
2f2830c6ce | |||
d8dcd0bd47 | |||
1acc93d566 | |||
6fa9ce3504 | |||
41253a82be | |||
e8c02d5ad2 | |||
9bee3eca5a | |||
78eae2be88 | |||
265876786c | |||
5b2de64af8 | |||
33a3aa8882 | |||
f6665da2ed | |||
e90a0da31d | |||
b8db7a2f82 | |||
fbe8a4151c | |||
f69571847a | |||
09d6dfc604 | |||
cff6d2fd9c | |||
8e48c6d768 | |||
8b1e7c0f72 | |||
8d5de36987 | |||
dc8aa04887 | |||
c999e6a8bf | |||
80d6cc78c3 | |||
ecb817b866 | |||
69e3728f5e | |||
7c1619554d | |||
61bd7f1f20 | |||
482bdf51f6 | |||
8fa4a2833f | |||
390d7c22ca | |||
91dcb7564b | |||
ee4c8d78d3 | |||
5209b12d44 | |||
8f5182b379 | |||
fd5652ed60 | |||
056a38caeb | |||
3f222c38ee | |||
cf356e3312 | |||
1ea7e490b9 | |||
01f52752ca | |||
58de64232c | |||
8fdf41a827 | |||
08e3f54a89 | |||
4b84adb834 | |||
4038240127 | |||
41f82f4510 | |||
9f79ae2f19 | |||
35ffe9d5b9 | |||
bb26da2c3a | |||
e8b3836050 | |||
3bf24047dd | |||
74e5d2c930 | |||
71dad07480 | |||
9bb8d4f385 | |||
3a11cd2dda | |||
3eca924a7c | |||
b39a4a67e2 | |||
6187b3bd0b | |||
da35ca3559 | |||
7f94a47a50 | |||
d41ca1a1f9 | |||
f58e171a69 | |||
ff98f31be2 | |||
f249747642 | |||
c8313af571 | |||
40ac64f2e4 | |||
95e6fb3a49 | |||
45b87ea905 | |||
b6b0e70046 | |||
9447b4894d | |||
f5b70092b2 | |||
28df906957 | |||
0f6fd4dac5 | |||
1f604a8556 | |||
11bd2dfa4c | |||
cdebd532ce | |||
d5f0a2481f | |||
94c7a09f55 | |||
21c4dea8a9 | |||
93fcd57b2d | |||
2bd82fec6e | |||
8c7d7163c9 | |||
7bb8089095 | |||
904664743f | |||
69c1b0a55a | |||
8873ac3524 | |||
5cd805fc44 | |||
19ac4f72aa | |||
0a8f83c2fe | |||
752eca4eef | |||
251ed60f1c | |||
c5b4f4e9c4 | |||
184f6bae09 | |||
d63f0ccc50 | |||
b5390f1ef3 | |||
c919eba4cd | |||
238e68fab9 | |||
e0fcf97777 | |||
4eb599e786 | |||
45a68d21e8 | |||
3a89935c29 | |||
a9044fadae | |||
dc6ca963f5 | |||
671dad4ed6 | |||
f9e522cc0e | |||
4109480b27 | |||
9b697b414a | |||
ada6439ae4 | |||
fc582d5351 | |||
14e982e2d8 | |||
e5fc54a3c0 | |||
0d82f8159c | |||
9126bac89b | |||
8cda22056f | |||
84c106858a | |||
a24d932102 | |||
90e55bd759 | |||
51f5cda4ff | |||
f1b88e5216 | |||
82c9c3c26a | |||
06819dc4b9 | |||
db22669050 | |||
87a995fe0b | |||
1ebbacdb2a | |||
d1a995471e | |||
46abaeb279 | |||
6089372ac7 | |||
397b5e8f47 | |||
b80a1f11f0 | |||
061fa615ce | |||
6639452c87 | |||
935db3cdd1 | |||
f35e718b2e | |||
742ba4cfdb | |||
54e502c020 | |||
8a1c2a1596 | |||
5e438b7fab | |||
7e8610bbac | |||
1d6c6dd52b | |||
dfa19bac18 | |||
1de4404bb2 | |||
4b19f79cb8 | |||
10642b8f5a | |||
f2acae4ce1 | |||
c2ce56292c | |||
d00cf72614 | |||
3e029c4e8a | |||
2379c523be | |||
bbba3df6d3 | |||
a7474dd5f0 | |||
b986025e1a | |||
5091939aa4 | |||
62e2cbe66b | |||
c95b09cbac | |||
b8d60ddaa6 | |||
bdccfd82f1 | |||
a9413dc277 | |||
5827b151ee | |||
d9abe9224e | |||
b744ff6b1e | |||
a124e7bdb8 | |||
08682d2448 | |||
36aef26ce9 | |||
9d278a1fe2 | |||
e2602b28ad | |||
0be8de738a | |||
511e612e16 | |||
c46eca1873 | |||
9a808b58e9 | |||
a637cb0632 | |||
eefe0279a1 | |||
7485850606 | |||
74cbfe973a | |||
a07b372c71 | |||
1d797af794 | |||
472d618033 | |||
6bbe569dac | |||
2cf5a0fd96 | |||
9d8d8eefa6 | |||
254e39df18 | |||
24531538fd | |||
012ca805c1 | |||
d178ac9749 | |||
f814d7369c | |||
8137ece450 | |||
bff38efd50 | |||
a97aa59689 | |||
ad3688e61d | |||
0e50cb4f06 | |||
6201df072e | |||
a81cef397c | |||
dfee1a0fb0 | |||
8e211405b1 | |||
4f018ba23b | |||
acfd493d9e | |||
1f96342590 | |||
62381a0731 | |||
65bd014a21 | |||
e11f97a2bb | |||
cf18905775 | |||
25e26a5b28 | |||
a0a2e91928 | |||
be2b81c478 | |||
4d17cfb715 | |||
5dced897d8 | |||
2640027bd8 | |||
b54862022e | |||
9b6e6efce2 | |||
063ef1a98e | |||
a267add7a4 | |||
32ccfd50af | |||
eff7618f79 | |||
7a3008aec6 | |||
ea00587c00 | |||
0d48c596c8 | |||
12cd1b2614 | |||
e420a210b9 | |||
4e2523060b | |||
663b9daef6 | |||
af11b864ce | |||
a3f88ffa5b | |||
d94da2aa09 | |||
7ea32d8a71 | |||
49babf773e | |||
e3808068af | |||
50b4fa3441 | |||
3b2173c694 | |||
825c1b8986 | |||
011bf80038 | |||
b7428c2b20 | |||
3cd8ce6514 | |||
9c4c1d6d51 | |||
b7072c8955 | |||
d2987d037c | |||
213586be95 | |||
a1a45f8c67 | |||
9d5159477c | |||
84ac5f55fe | |||
e0135d0832 | |||
e5954ec0d8 | |||
56de773614 | |||
838af985e1 | |||
59e8e1aa7e | |||
8a7212a04f | |||
730c266ea8 | |||
5f9c4878b9 | |||
0a1a4fc68a | |||
0a3ded2957 | |||
de1e6091bf | |||
36797f3594 | |||
3248f0db1e | |||
c0d712c53d | |||
01cc10dc43 | |||
4a9a58e19c | |||
cd55e7bb2a | |||
5d748a59cc | |||
8653b2a382 | |||
fe49b005da | |||
f4e0cde4e6 | |||
4d94ab4218 | |||
b0f32dc29b | |||
a6ed4f3652 | |||
a71fc83401 | |||
cd1763cbf8 | |||
b336e4f202 | |||
a663993d0b | |||
2d13eaaf6b | |||
fa66c3f23b | |||
4ee4d015cb | |||
35841b3369 | |||
c0ff2fe2b2 | |||
e5059c73ed | |||
3f26cee4d4 | |||
4824a2b535 | |||
6ee06583a9 | |||
8f7a141711 | |||
ecaf269fe1 | |||
850bfdd722 | |||
f1ad8fa8f2 | |||
a35154185a | |||
03b5e2c5db | |||
52ca445636 | |||
8fffa6b03f | |||
d0d71b309e | |||
b7ed3066c1 | |||
7037a58ca9 | |||
6f8e26bdbf | |||
b4693fb768 | |||
20abf92e4b | |||
5344768e93 | |||
0855d51e10 | |||
ab29a7ba74 | |||
24b9b93c3e | |||
4994c3bca8 | |||
20d36a0502 | |||
d0284dc1b9 | |||
000d56a96d | |||
2de022235f | |||
df006845ae | |||
e30984a13d | |||
d185a78af7 | |||
899ca6737b | |||
d4b028556a | |||
a221bf9d40 | |||
468427bfdb | |||
07fcccbda5 | |||
929047b6a5 | |||
0de90daa64 | |||
19cce592c8 | |||
0277ac1a73 | |||
a37d6478e4 | |||
67a73fb79d | |||
979eb5c6ca | |||
d39224c594 | |||
490c94b33a | |||
4890d6d477 | |||
de70e0ab0a | |||
944b8d9f34 | |||
3c110a7d30 | |||
d5e1211e28 | |||
3c1c5e0085 | |||
aa7c7c16e5 | |||
99ad9e3c24 | |||
d2e47c5d86 | |||
25dc2848ca | |||
87553a4531 | |||
a7fdea723f | |||
ce2c58848b | |||
5cfec76d3a | |||
97dfdbf7c0 | |||
42344302de | |||
d4fa6bbcb0 | |||
629ae8bfa4 | |||
15c14c6dea | |||
58ec2768ec | |||
c7c2587079 | |||
02aa47d8c6 | |||
b07ea86034 | |||
8982dd556c | |||
f7d2ca1da0 | |||
ea9961f9f6 | |||
636edf500c | |||
9267ae99cb | |||
77d4fc89e0 | |||
2f44cab13f | |||
d4f42255b3 | |||
173b831cf7 | |||
62e4077e93 | |||
9bc1860345 | |||
f93c00cd1a | |||
22a32f19eb | |||
a764e7b19a | |||
8a5d6bb340 | |||
c3bd6f5e8e | |||
f56c8fbbf1 | |||
0fa43eec4b | |||
43b5cc190e | |||
4a732c01e5 | |||
3a25134e8c | |||
e2e6fe2533 | |||
0833f143ff | |||
db4661339a | |||
8442472f37 | |||
9d5f213289 | |||
67abcb03c2 | |||
31a8055c6e | |||
8edc1be7b5 | |||
d22f0344ea | |||
bab78f9913 | |||
0294643403 | |||
f5a8f23d2b | |||
0305dadc7f | |||
4219fabca8 | |||
1e3b2ca2f7 | |||
17680b130e | |||
bdfc367c6c | |||
9ce586e21d | |||
c6f7aa2eda | |||
faeb00af25 | |||
7a9bc497fc | |||
8899446fe3 | |||
2d49e34805 | |||
f54fef4210 | |||
97b178dbdb | |||
95a435c5c9 | |||
e05b107cc1 | |||
621082b0a4 | |||
1e454ae5ed | |||
c169a9e1b8 | |||
0cd6366b5c | |||
907b92ed61 | |||
30ad035b07 | |||
db83d96527 | |||
8cb0ff6308 | |||
4c6cef76f5 | |||
4a9c3236aa | |||
45bad20bb3 | |||
d92770edb4 | |||
d4d65b5377 | |||
71a36ebee0 | |||
ea4b694ac3 | |||
7fa67de9aa | |||
fd6048c7b6 | |||
db07f2af57 | |||
efa5b03cf8 | |||
d4d516af1e | |||
55c1293b4c | |||
5aafb38624 | |||
a729d909a7 | |||
dbcf69206a | |||
1e5e9bbc57 | |||
f23c063b71 | |||
7dd6340e46 | |||
e1b6ba9f0d | |||
94f1d8dde0 | |||
ee5e82fe9a | |||
426893077f | |||
5e1beb5b46 | |||
7f11af1c13 | |||
cd34616339 | |||
9558a1af58 | |||
0c8765647a | |||
df7e62915b | |||
a71904a25e | |||
ddad808d88 | |||
1790e9f530 | |||
f1477a3608 | |||
b6881c20d2 | |||
44c5f71ac2 | |||
6c41b68bca | |||
44a94ea04d | |||
d86fe768f3 | |||
d19c00faab | |||
162b801839 | |||
77dec4da01 | |||
860e2607e4 | |||
886301e765 | |||
f50d383670 | |||
b3e57a013f | |||
4ddb5d5b7d | |||
0346f2238f | |||
2a423174b1 | |||
487d8c4a3d | |||
0f4adedeab | |||
16236ec0bf | |||
6bf4ea9f43 | |||
38f3557e0b | |||
cd81a2bcec | |||
37a35eeece | |||
e2e3a59336 | |||
1daad45f11 | |||
2da5920bda | |||
fb1aef882a | |||
8618af2ee0 | |||
14cc8b7827 | |||
2ea6446323 | |||
bf8a16b7fe | |||
a8b9d8d96c | |||
39d59edece | |||
92c4df6e9c | |||
8d7cd4ea91 | |||
02db91bcc9 | |||
e136edb6ac | |||
0ad9def514 | |||
4f85779e78 | |||
4ff5995617 | |||
bf2a80a7b5 | |||
c5564f6bff | |||
d202eaa267 | |||
f7d34739b5 | |||
dfdcb95a5d | |||
b4d5732a50 | |||
810ecd429a | |||
73b1293522 | |||
3d56e0aad0 | |||
f6b7ce7383 | |||
74f9421417 | |||
8e2cce5b43 | |||
ccb01d964c | |||
580e3c7675 | |||
590d0fc228 | |||
808b2b249b | |||
49c5e6a502 | |||
bb260c4c04 | |||
ec8e4fc458 | |||
4fe53fc170 | |||
c46160c99e | |||
619d49b669 | |||
b6ca2f08ff | |||
8b4095c0cf | |||
2e9a8dfb0d | |||
c546228c7e | |||
bfdd751db9 | |||
e1eb0e6353 | |||
b381c9161d | |||
e254f76459 | |||
a5802f492b | |||
2da5c270ff | |||
1762db8bd4 | |||
c9dc323bcc | |||
235a3dad92 | |||
3f540a8240 | |||
43c9665523 | |||
612a58eb2f | |||
0661b4aa37 | |||
31646f2a28 | |||
7ed8188f54 | |||
0797cfad84 | |||
2006902b8a | |||
3cf4e2105e | |||
62917d3d1f | |||
73b1124dce | |||
34f4d0abf4 | |||
a392a08c7a | |||
efae5fd28d | |||
0793c35eb3 | |||
0dff676021 | |||
c98b755f17 | |||
f49bf09261 | |||
f2e5511544 | |||
a242fb15a3 | |||
09482aed7b | |||
81188e8ef6 | |||
fb49f468f1 | |||
b67fdb7806 | |||
5a9233c426 | |||
2bea5f67b9 | |||
c1406adcb2 | |||
f86d73972d | |||
682d3070e9 | |||
ee6fb8c619 | |||
2b051194c8 | |||
b08b23d59e | |||
39ca8f74ae | |||
0010d4965c | |||
6f3c88663a | |||
7d1af273d1 | |||
8d755793f2 | |||
d3efa6b82f | |||
99386510d8 | |||
bb16b3e30b | |||
d55420faae | |||
ac02bd370b | |||
dbabdd83dc | |||
8d255fc331 | |||
1ccdeb015c | |||
53a2101de3 | |||
5bff07f6f6 | |||
527cac4c75 | |||
7355db94d6 | |||
ff5a231ea0 | |||
7cbc0dc047 | |||
da23d42692 | |||
07ec00c33f | |||
e83079eec4 | |||
d28a9a9253 | |||
9380230614 | |||
b230358553 | |||
6626fbcfbd | |||
bbb41a2b5b | |||
c28a5c25b9 | |||
d6e6dab565 | |||
0030f68831 | |||
fe6ac3e954 | |||
4e8cc2e164 | |||
e9123af089 | |||
d4b9b6ed32 | |||
62fb5524ae | |||
4e5c924e5c | |||
be591d016a | |||
7b13c6f076 | |||
36a62fb365 | |||
b4b571b50f | |||
260c0dd538 | |||
6b263bf43d | |||
63c84cd362 | |||
7591088982 | |||
e94301b122 | |||
f58aac6fb3 | |||
27e4994d10 | |||
946d01a61b | |||
084ed153e3 | |||
d7f4a14bfe | |||
46e7231bfa | |||
7a752081e7 | |||
316e199aa4 | |||
95dc519019 | |||
107749e91a | |||
e967859d5f | |||
09a6c8b067 | |||
153d9c6b2c | |||
205fed6992 | |||
86474bd535 | |||
357e010f39 | |||
be85a7f224 | |||
5196c1ebad | |||
7054fe2bd2 | |||
e936233ead | |||
8f838b80e7 | |||
2e964d0a7e | |||
fe08547d6b | |||
ce08201d13 | |||
faf8e525b3 | |||
45bdfe205a | |||
d34d11fbb3 | |||
1f86ace5d8 | |||
56004ec338 | |||
12cdec3f2e | |||
ac4c857146 | |||
27e715f4a4 | |||
c4be637ba8 | |||
7d057a9ca7 | |||
3d04afa4f6 | |||
9aa6f3f590 | |||
cce29fe2c5 | |||
5005d7a1f1 | |||
edba18375f | |||
a0523b3427 | |||
a2440e665f | |||
3aea9d34e9 | |||
f856229141 | |||
8ce55af55d | |||
23c969ad1b | |||
1dc356a8f2 | |||
2cfd0e1fe0 | |||
79ea700121 | |||
5738642d44 | |||
0a48b28d83 | |||
fab237d373 | |||
a222dc9237 | |||
25dee77600 | |||
1ddc617b79 | |||
d8eed4d15e | |||
950bad1d7a | |||
80c6e48b98 | |||
13768a7d28 | |||
0d9eb5d198 | |||
beb2af73b7 | |||
ca96d09a23 | |||
f82edcd7e7 | |||
3afc38efcb | |||
3886c2a82f | |||
18c82f2d06 | |||
051f6c5a7f | |||
2e7f6d4b6a | |||
51696ed813 | |||
1672de266e | |||
43be85cee9 | |||
ab74fa6e2c | |||
5ebd6e313b | |||
de59dc7d9f | |||
8194433372 | |||
819f6c5ad2 | |||
69c7b32c1d | |||
95047ba695 | |||
6f47a78afd | |||
c8b5165618 | |||
39a6ff8a2b | |||
bf77cb57fd | |||
31e97fdd0e | |||
96ea3accdf | |||
a0983ac2da | |||
0be544676b | |||
dbe81f1e59 | |||
e37ae0c559 | |||
bb4740ba76 | |||
528d2b5fb7 | |||
d7d8a51332 | |||
f6f8cd7a29 | |||
69cc8b3c89 | |||
810a6b8125 | |||
72af0a4947 | |||
3c1db1d7d6 | |||
453eab3a12 | |||
e4e570381c | |||
817141a781 | |||
97a9f7c8ff | |||
c86ea9463d | |||
d1548572d4 | |||
5804af2082 | |||
85b7c01c2d | |||
22725968e8 | |||
5d6ea4f32e | |||
7554600dd4 | |||
77a98bfd14 | |||
883b6dbef2 | |||
8ecacc9978 | |||
430d48434c | |||
9053a0bb6a | |||
01753a814c | |||
e43c591890 | |||
6195e5d8f6 | |||
039c12266a | |||
1632ae27d0 | |||
8d72cb1416 | |||
8100f98d6b | |||
6828cd1075 | |||
84f460d9b8 | |||
5bb0b198e6 | |||
ed4b6e85ac | |||
1e8c0547ec | |||
cf49a3427e | |||
4c0479fe3d | |||
c395e74e64 | |||
4c62b0d50c | |||
5339f529aa | |||
7fd200db63 | |||
97973d47f2 | |||
bf4d55564c | |||
0858938eea | |||
d3d4dce54d | |||
fdd321311e | |||
e987917c64 | |||
60a8f61040 | |||
b6cde34a08 | |||
44b9597981 | |||
7267f89377 | |||
9164f65693 | |||
103c3395dd | |||
640d8df487 | |||
49f1a01dba | |||
67f7975ced | |||
5bff3abd52 | |||
3de3d05b8a | |||
916ebcb1be | |||
c0f430e509 | |||
df04d697b1 | |||
1fbdb170e4 | |||
cdb810ebc5 | |||
3873c4def1 | |||
00bca229f0 | |||
2c499c9f6d | |||
b3f48cf926 | |||
f07a6db7ab | |||
9414989fc1 | |||
a7e928ed4f | |||
8a7408097d | |||
0af3f10997 | |||
ed67dc5de5 | |||
74ccef02bf | |||
88b7f5bc2f | |||
52fd311016 | |||
70128e22b9 | |||
351a203acd | |||
120953773e | |||
a56ad36a42 | |||
74220d8a24 | |||
7e2edc2c9f | |||
0260bcbf3b | |||
44d6c1cf6d | |||
4b239a9c9c | |||
288aaac465 | |||
0dc76e773f | |||
6a6789a3c7 | |||
be69f88ef6 | |||
43fc531a6f | |||
e345bc8ac8 | |||
5ce67ba093 | |||
191f8429c3 | |||
e9e440a625 | |||
f2e5bd1b3c | |||
da5a876f93 | |||
c28fda6b28 | |||
35e5ff2fc7 | |||
2954afd77d | |||
ab2bb2881f | |||
1a9ad1a7e3 | |||
bd619220d0 | |||
b01e01bc19 | |||
a15e922e27 | |||
6857c5dd08 | |||
6d4fb93e6b | |||
38b8cdbb5e | |||
f2fb27f6a4 | |||
ddb1a280cb | |||
5146686814 | |||
1bf8c1578d | |||
50a9ef5b3a | |||
01857a50ee | |||
71def026c9 | |||
c86c4f0362 | |||
08f2ebd373 | |||
20c9df39b1 | |||
b2eb11b5ef | |||
b5b0ebe00d | |||
49046be361 | |||
27393bb804 | |||
218e06fbd4 | |||
167101c3aa | |||
d66d5dc144 | |||
653b46e2f4 | |||
5aa9d7e1dc | |||
fc7e8339b7 | |||
047c4d0a98 | |||
f3c10bdd42 | |||
5e6e27d73f | |||
4faf669348 | |||
f105a11cd4 | |||
c816bc9834 | |||
7cad5063f2 | |||
8d4475ff84 | |||
b8531e6070 | |||
d0ac101b0d | |||
dfc4cad712 | |||
8f59ca1bec | |||
941849eaa8 | |||
731b29c059 | |||
f475cc39ef | |||
e5a6417a82 | |||
0fb462c88e | |||
8dcdfbdffa | |||
8d6b3fa335 | |||
d1648823c3 | |||
6fdd6d4f8e | |||
884984f9b4 | |||
73755ce973 | |||
589d7a9811 | |||
4a8aae1f1a | |||
68adda5821 | |||
d4a068e2de | |||
a941fe97a6 | |||
58d9490c2a | |||
fe51c6d7e7 | |||
20415a2edb | |||
2506feb1ea | |||
5c7f34bd48 | |||
d5c9fb5536 | |||
bb066ecb02 | |||
6c6168e80a | |||
c63c0f2ec8 | |||
6fa48d3acf | |||
827310a645 | |||
dd3d8f8fb3 | |||
c3ed4ebc5e | |||
6dbc6eb842 | |||
8b417fe97a | |||
85efebc6be | |||
501730f2ca | |||
4551c10a5c | |||
9188943261 | |||
11eedc3ea1 | |||
e8c64b084e | |||
9f503b6de9 | |||
97ed29e1dd | |||
e719e4ff81 | |||
cbf82a1bc7 | |||
eab823ba66 | |||
68cc9a2e28 | |||
04046719c1 | |||
894d6f162d | |||
4b7a53c5e1 | |||
6b5f6e3e79 | |||
e0b15f18e1 | |||
276266e24f | |||
9107f9e351 | |||
6aa5ab160d | |||
8deb056ecf | |||
f909e096bc | |||
f6bf4a1c94 | |||
f9f35fa498 | |||
5f0fcf5f41 | |||
92bd4ed083 | |||
5bca4b7323 | |||
f816a3972f | |||
0d02f49b9b | |||
8e6be8a4d4 | |||
fee81e78e1 | |||
59174db7e5 | |||
eaf8c35f40 | |||
7a0a1b86f6 | |||
49f2721908 | |||
7fec928ba8 | |||
2bf4416aec | |||
c485ec8c42 | |||
b613be7761 | |||
46c58dcb8d | |||
4cb6ec9eae | |||
45586f7150 | |||
f04b27b921 | |||
e4bbefd0a5 | |||
1e8ca56c65 | |||
2a9525a860 | |||
8bbca05ad6 | |||
f7a828122c | |||
9c5147ae09 | |||
15dbbf4efc | |||
5ed8cc6320 | |||
fb17107406 | |||
00681e876f | |||
61438cbe5a | |||
20a6133d73 | |||
4e67e55a7d | |||
f282a973f8 | |||
9852eb072b | |||
02593c51e2 | |||
14ef881d17 | |||
6e93bd2c9b | |||
59ec0348b6 | |||
92eb45a6f5 | |||
845b4a240a | |||
3acb14dac9 | |||
0f6d27497d | |||
aadb506b8f | |||
88cbcc0694 | |||
c051b70537 | |||
1b97c1031d | |||
be8cf925d8 | |||
9ba6b78726 | |||
22b02dff31 | |||
615353c582 | |||
3070ae098a | |||
81e1d15ee9 | |||
ee7272305a | |||
bfc8959bb9 | |||
649d60c119 | |||
25517f3ad7 | |||
fd4492ab41 | |||
02b35ab367 | |||
e26bbeccbe | |||
5d9a123827 | |||
bc1a6319a0 | |||
7a6b560303 | |||
fe4f453a34 | |||
ecede860ef | |||
2dbb150463 | |||
a9994656c3 | |||
4fe7dc0808 | |||
d798a0fe9b | |||
f18a7b2fe6 | |||
964da1487a | |||
deac669532 | |||
221aad7104 | |||
b9ca43d529 | |||
9e28c0d541 | |||
c490091346 | |||
0610784632 | |||
c07342b67b | |||
827e37f3d4 | |||
7fcf683da3 | |||
bd2907c13f | |||
7ce1f1cf9e | |||
63828b95e0 | |||
7f82b440a2 | |||
f78333a544 | |||
fedb44435d | |||
24fa5b0e38 | |||
3efd888727 | |||
5bac385a75 | |||
9c46bc5713 | |||
a24ab74c4c | |||
b91248719a | |||
ca36da8280 | |||
3074251be9 | |||
3da870be8c | |||
eebfa4bfd9 | |||
b943b12cd1 | |||
f8c894d7e1 | |||
fa4331bcd9 | |||
e03694b49c | |||
da0ab54292 | |||
59ec07be87 | |||
7e8ceaeda4 | |||
a3a5ce9aa4 | |||
a121ada239 | |||
84e524c635 | |||
906e79f39b | |||
e957a52e43 | |||
c09f06fccc | |||
863e9b0b48 | |||
5bc3ef3ed8 | |||
cf3d1e928d | |||
dfba84d811 | |||
b9df9d6981 | |||
ab15fef282 | |||
b501d648e3 | |||
acb6179b30 | |||
f6c76ff9bd | |||
25c6cb4f6f | |||
599568a428 | |||
75dda78b9c | |||
4fe946d791 | |||
8cfc7aae09 | |||
32d98c327d | |||
8d8ab049cd | |||
0602a9495a | |||
ddfd942e66 | |||
70a5fb99d4 | |||
901b0eff7d | |||
f9ca608ad5 | |||
9c4ea85041 | |||
b10962f13f | |||
f8e53d5f72 | |||
37c61e2413 | |||
2bcb9bcca1 | |||
913e88185e | |||
f646fbbd4f | |||
dc6252d3f6 | |||
1ce2792fc4 | |||
feb5071786 | |||
477e2f9cd1 | |||
c733e72e7a | |||
1453e262d1 | |||
a2195c15e4 | |||
4e63ef9764 | |||
288e8148fd | |||
7975f4debc | |||
7330dc9553 | |||
95abf830cd | |||
7724762c14 | |||
6c7ddd0f47 | |||
a94d3f1b6d | |||
51684f7a2a | |||
c7ad6d9d3d | |||
ca8df65dfd | |||
bdb6064c76 | |||
69a4207ea4 | |||
c97352905d | |||
9ecb776760 | |||
8886459be9 | |||
53fe991407 | |||
26fa60f475 | |||
a3e448acf5 | |||
0d068f34a8 | |||
8403d277b4 | |||
ab982ecc3c | |||
2685f46669 | |||
3ccdee6f00 | |||
890555785d | |||
a09b21decd | |||
40f05b837d | |||
a145b8e27c | |||
742929280d | |||
4196e627f9 | |||
559281bfb9 | |||
b82ceb162b | |||
38e8c2eb41 | |||
0044be266e | |||
558a6fba0a | |||
3cba89dc9e | |||
416f45d1e3 | |||
6013a02fc2 | |||
c432ee431d | |||
421d2b7b70 | |||
13a53706f0 | |||
c6f6e8d8d2 | |||
b57830b859 | |||
7b27e7d024 | |||
837dad2535 | |||
3144882491 | |||
5669cf40db | |||
f92aeceb22 | |||
735393fb03 | |||
44353772da | |||
9905fdcbef | |||
e448dc711c | |||
23b59ece45 | |||
c8418a638d | |||
c0442e4c99 | |||
38bc8e6782 | |||
51ee504e90 | |||
e7bcec77df | |||
a3fd86f9a9 | |||
227c9263e5 | |||
5d2453347f | |||
0d074f1cbe | |||
d1e5a8f492 | |||
116a73c8d0 | |||
3e713e8be8 | |||
93131fb542 | |||
72e5e9f237 | |||
939e2a00f3 | |||
13e81e5a41 | |||
fa993c29d5 | |||
f7449c565f | |||
7e704b2d73 | |||
865e5bb41b | |||
4639a2528d | |||
e4701be708 | |||
6d931e8dcb | |||
2c570fa9ef | |||
b5d96d215f | |||
c53015c1af | |||
0b3741859f | |||
f2ddb633b1 | |||
1988bbd149 | |||
03a99ecd60 | |||
053e2ffbbf | |||
12b5cbc40f | |||
82fc55c1f1 | |||
a15216493a | |||
12bbaef4ab | |||
9c90e94ef8 | |||
f6051d3f6b | |||
339f65295d | |||
7883feca30 | |||
14847260b6 | |||
85cb039426 | |||
aee1ec2739 | |||
99ee31d795 | |||
e28efe5e38 | |||
572c93d06b | |||
c4f6701d65 | |||
a9198b1cf6 | |||
79a2c72c06 | |||
bbaea66d11 | |||
b213e988ca | |||
72aa243666 | |||
b7ccb5a294 | |||
60777b9b1f | |||
dbb51e0f46 | |||
c0749136fc | |||
003ae7131b | |||
cb4312f410 | |||
51d5a4eff4 | |||
c6e282bffd | |||
349edad826 | |||
ec70bd91a8 | |||
91e3ca88bb | |||
335bd803af | |||
6b024191c1 | |||
6d6b5cb2a9 | |||
1904e9b0ab | |||
e5fa6e32f9 | |||
1fdd1ff0c3 | |||
b4951b1f86 | |||
93265ef830 | |||
ede0f44ca2 | |||
4f737ce5bb | |||
e6241556be | |||
b765abcb65 | |||
ffa9685b41 | |||
33dbc80dbc | |||
c605f35335 | |||
dcdc1d150f | |||
5cf24b80e6 | |||
bc05ca1c63 | |||
8f2caca6d7 | |||
72fafc5b4f | |||
869e978a99 | |||
4f5807b8fe | |||
8d124dd2a2 | |||
ff9dd3833e | |||
a9e3b551e6 | |||
90c3c3b22e | |||
43aceccb41 | |||
8057c416fb | |||
25460156cb | |||
15ea2db31b | |||
2640c2a15e | |||
46931b0a7d | |||
f7429ab80b | |||
ede37b9cb6 | |||
5722bd9845 | |||
6bfd6ed473 | |||
25f02eb211 | |||
d6d54c2c56 | |||
cfca2fa155 | |||
710ba10772 | |||
9946b918d7 | |||
d3cbcfcc8b | |||
33d82287be | |||
59a1a9cd36 | |||
e77de3315a | |||
a122ed5b7f | |||
f38fa9b98e | |||
e98291dd3f | |||
48ebde6008 | |||
96d3e58734 | |||
9a8cde189d | |||
deaf803467 | |||
826482bb5b | |||
3e708f9c0b |
1
.browserslistrc
Normal file
1
.browserslistrc
Normal file
|
@ -0,0 +1 @@
|
|||
last 2 year, firefox esr
|
|
@ -6,6 +6,7 @@ root = true
|
|||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
|
@ -15,6 +16,6 @@ insert_final_newline = true
|
|||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml}]
|
||||
[*.{json,md,yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
# third party
|
||||
client/js/libs/jquery/*.js
|
||||
|
||||
public/
|
||||
coverage/
|
||||
dist/
|
||||
|
|
193
.eslintrc.cjs
Normal file
193
.eslintrc.cjs
Normal file
|
@ -0,0 +1,193 @@
|
|||
// @ts-check
|
||||
const {defineConfig} = require("eslint-define-config");
|
||||
|
||||
const projects = defineConfig({
|
||||
parserOptions: {
|
||||
project: [
|
||||
"./tsconfig.json",
|
||||
"./client/tsconfig.json",
|
||||
"./server/tsconfig.json",
|
||||
"./shared/tsconfig.json",
|
||||
"./test/tsconfig.json",
|
||||
],
|
||||
},
|
||||
}).parserOptions.project;
|
||||
|
||||
const baseRules = defineConfig({
|
||||
rules: {
|
||||
"block-scoped-var": "error",
|
||||
curly: ["error", "all"],
|
||||
"dot-notation": "error",
|
||||
eqeqeq: "error",
|
||||
"handle-callback-err": "error",
|
||||
"no-alert": "error",
|
||||
"no-catch-shadow": "error",
|
||||
"no-control-regex": "off",
|
||||
"no-console": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-restricted-globals": ["error", "event", "fdescribe"],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-use-before-define": [
|
||||
"error",
|
||||
{
|
||||
functions: false,
|
||||
},
|
||||
],
|
||||
"no-var": "error",
|
||||
"object-shorthand": [
|
||||
"error",
|
||||
"methods",
|
||||
{
|
||||
avoidExplicitReturnArrows: true,
|
||||
},
|
||||
],
|
||||
"padding-line-between-statements": [
|
||||
"error",
|
||||
{
|
||||
blankLine: "always",
|
||||
prev: ["block", "block-like"],
|
||||
next: "*",
|
||||
},
|
||||
{
|
||||
blankLine: "always",
|
||||
prev: "*",
|
||||
next: ["block", "block-like"],
|
||||
},
|
||||
],
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"spaced-comment": ["error", "always"],
|
||||
strict: "off",
|
||||
yoda: "error",
|
||||
},
|
||||
}).rules;
|
||||
|
||||
const vueRules = defineConfig({
|
||||
rules: {
|
||||
"import/no-default-export": 0,
|
||||
"import/unambiguous": 0, // vue SFC can miss script tags
|
||||
"@typescript-eslint/prefer-readonly": 0, // can be used in template
|
||||
"vue/component-tags-order": [
|
||||
"error",
|
||||
{
|
||||
order: ["template", "style", "script"],
|
||||
},
|
||||
],
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-mutating-props": "off",
|
||||
"vue/no-v-html": "off",
|
||||
"vue/require-default-prop": "off",
|
||||
"vue/v-slot-style": ["error", "longform"],
|
||||
},
|
||||
}).rules;
|
||||
|
||||
const tsRules = defineConfig({
|
||||
rules: {
|
||||
// note you must disable the base rule as it can report incorrect errors
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"@typescript-eslint/no-redundant-type-constituents": "off",
|
||||
},
|
||||
}).rules;
|
||||
|
||||
const tsRulesTemp = defineConfig({
|
||||
rules: {
|
||||
// TODO: eventually remove these
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
}).rules;
|
||||
|
||||
const tsTestRulesTemp = defineConfig({
|
||||
rules: {
|
||||
// TODO: remove these
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/restrict-plus-operands": "off",
|
||||
},
|
||||
}).rules;
|
||||
|
||||
module.exports = defineConfig({
|
||||
root: true,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/*.ts", "**/*.vue"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: projects,
|
||||
extraFileExtensions: [".vue"],
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"prettier",
|
||||
],
|
||||
rules: {
|
||||
...baseRules,
|
||||
...tsRules,
|
||||
...tsRulesTemp,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.vue"],
|
||||
parser: "vue-eslint-parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
parser: "@typescript-eslint/parser",
|
||||
tsconfigRootDir: __dirname,
|
||||
project: projects,
|
||||
},
|
||||
plugins: ["vue"],
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"prettier",
|
||||
],
|
||||
rules: {...baseRules, ...tsRules, ...tsRulesTemp, ...vueRules},
|
||||
},
|
||||
{
|
||||
files: ["./tests/**/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
rules: {
|
||||
...baseRules,
|
||||
...tsRules,
|
||||
...tsRulesTemp,
|
||||
...tsTestRulesTemp,
|
||||
},
|
||||
},
|
||||
],
|
||||
env: {
|
||||
es6: true,
|
||||
browser: true,
|
||||
mocha: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ["eslint:recommended", "prettier"],
|
||||
rules: baseRules,
|
||||
});
|
|
@ -1,71 +0,0 @@
|
|||
---
|
||||
|
||||
root: true
|
||||
|
||||
env:
|
||||
es6: true
|
||||
browser: true
|
||||
mocha: true
|
||||
node: true
|
||||
|
||||
rules:
|
||||
arrow-body-style: error
|
||||
arrow-parens: [error, always]
|
||||
arrow-spacing: error
|
||||
block-scoped-var: error
|
||||
block-spacing: [error, always]
|
||||
brace-style: [error, 1tbs]
|
||||
comma-dangle:
|
||||
- error
|
||||
- always-multiline
|
||||
curly: [error, all]
|
||||
dot-location: [error, property]
|
||||
dot-notation: error
|
||||
eol-last: error
|
||||
eqeqeq: error
|
||||
handle-callback-err: error
|
||||
indent: [error, tab]
|
||||
key-spacing: [error, {beforeColon: false, afterColon: true}]
|
||||
keyword-spacing: [error, {before: true, after: true}]
|
||||
linebreak-style: [error, unix]
|
||||
no-alert: error
|
||||
no-catch-shadow: error
|
||||
no-confusing-arrow: error
|
||||
no-control-regex: off
|
||||
no-duplicate-imports: error
|
||||
no-else-return: error
|
||||
no-implicit-globals: error
|
||||
no-multi-spaces: error
|
||||
no-multiple-empty-lines: [error, { "max": 1 }]
|
||||
no-shadow: error
|
||||
no-template-curly-in-string: error
|
||||
no-trailing-spaces: error
|
||||
no-unsafe-negation: error
|
||||
no-useless-computed-key: error
|
||||
no-useless-return: error
|
||||
no-use-before-define: [error, {functions: false}]
|
||||
object-curly-spacing: [error, never]
|
||||
padded-blocks: [error, never]
|
||||
prefer-const: error
|
||||
quote-props: [error, as-needed]
|
||||
quotes: [error, double, avoid-escape]
|
||||
semi-spacing: error
|
||||
semi-style: [error, last]
|
||||
semi: [error, always]
|
||||
space-before-blocks: error
|
||||
space-before-function-paren:
|
||||
- error
|
||||
- anonymous: never
|
||||
named: never
|
||||
asyncArrow: always # Otherwise requires `async()`
|
||||
space-in-parens: [error, never]
|
||||
space-infix-ops: error
|
||||
spaced-comment: [error, always]
|
||||
strict: error
|
||||
template-curly-spacing: error
|
||||
yoda: error
|
||||
|
||||
globals:
|
||||
log: false
|
||||
|
||||
extends: eslint:recommended
|
18
CONTRIBUTING.md → .github/CONTRIBUTING.md
vendored
18
CONTRIBUTING.md → .github/CONTRIBUTING.md
vendored
|
@ -6,17 +6,17 @@ your contributions.
|
|||
### I want to report a bug
|
||||
|
||||
- Look at the [open and closed
|
||||
issues](https://github.com/thelounge/lounge/issues?q=is%3Aissue) to see if
|
||||
issues](https://github.com/thelounge/thelounge/issues?q=is%3Aissue) to see if
|
||||
this was not already discussed before. If you can't see any, feel free to
|
||||
[open a new issue](https://github.com/thelounge/lounge/issues/new).
|
||||
[open a new issue](https://github.com/thelounge/thelounge/issues/new).
|
||||
- If you think you discovered a security vulnerability, **do not open a public
|
||||
issue on GitHub.** Refer to our [security guidelines](SECURITY.md) instead.
|
||||
issue on GitHub.** Refer to our [security guidelines](/SECURITY.md) instead.
|
||||
|
||||
### I want to contribute to the code
|
||||
|
||||
- Make sure to discuss your ideas with the community in an
|
||||
[issue](https://github.com/thelounge/lounge/issues) or on the IRC channel.
|
||||
- Take a look at the open issues labeled as [`up for grabs`](https://github.com/thelounge/lounge/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22up%20for%20grabs%22)
|
||||
[issue](https://github.com/thelounge/thelounge/issues) or on the IRC channel.
|
||||
- Take a look at the open issues labeled as [`help wanted`](https://github.com/thelounge/thelounge/labels/help%20wanted)
|
||||
if you want to help without having a specific idea in mind.
|
||||
- Make sure that your PRs do not contain unnecessary commits or merge commits.
|
||||
[Squash commits](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History)
|
||||
|
@ -28,6 +28,10 @@ your contributions.
|
|||
Pope's guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
- Each PR will be reviewed by at least two different project maintainers. You
|
||||
can read more about this in the [maintainers'
|
||||
corner](https://github.com/thelounge/lounge/wiki/Maintainers'-corner).
|
||||
corner](https://github.com/thelounge/thelounge/wiki/Maintainers'-corner).
|
||||
- Please document any relevant changes in the documentation that can be found
|
||||
[in its own repository](https://github.com/thelounge/thelounge.github.io).
|
||||
[in its own repository](https://github.com/thelounge/thelounge.chat).
|
||||
- Note that we use prettier on the project. You can set up IDE plugins to format
|
||||
on save ([see VS Code one here](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)).
|
||||
- We have a git hook to automatically run prettier before commit, in case you don't install the plugin.
|
||||
- If for any reason, prettier does not work for you, you can run `yarn format:prettier` and that should format everything.
|
14
.github/ISSUE_TEMPLATE/Bug_Report.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/Bug_Report.md
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
name: Bug Report
|
||||
about: Create a bug report
|
||||
labels: "Type: Bug"
|
||||
---
|
||||
|
||||
<!-- Have a question? Join #thelounge on Libera.Chat -->
|
||||
|
||||
- _Node version:_
|
||||
- _Browser version:_
|
||||
- _Device, operating system:_
|
||||
- _The Lounge version:_
|
||||
|
||||
---
|
10
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: Feature Request
|
||||
about: Request a new feature
|
||||
labels: "Type: Feature"
|
||||
---
|
||||
|
||||
<!-- Have a question? Join #thelounge on Libera.Chat. -->
|
||||
<!-- Make sure to check the existing issues prior to submitting your suggestion. -->
|
||||
|
||||
### Feature Description
|
16
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
16
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
contact_links:
|
||||
- name: Docker container issues
|
||||
url: https://github.com/thelounge/thelounge-docker/issues
|
||||
about: Report issues related to the Docker container here
|
||||
|
||||
- name: Debian package issues
|
||||
url: https://github.com/thelounge/thelounge-deb/issues
|
||||
about: Report issues related to the Debian package here
|
||||
|
||||
- name: Arch Linux package issues
|
||||
url: https://github.com/thelounge/thelounge-archlinux/issues
|
||||
about: Report issues related to the Arch Linux package here
|
||||
|
||||
- name: General support
|
||||
url: https://demo.thelounge.chat/?join=%23thelounge
|
||||
about: "Join #thelounge on Libera.Chat to ask a question before creating an issue"
|
2
SUPPORT.md → .github/SUPPORT.md
vendored
2
SUPPORT.md → .github/SUPPORT.md
vendored
|
@ -6,6 +6,6 @@ need help, you have a few options:
|
|||
- Check out [existing questions on Stack Overflow](https://stackoverflow.com/questions/tagged/thelounge)
|
||||
to see if yours has been answered before. If not, feel free to [ask for a new question](https://stackoverflow.com/questions/ask?tags=thelounge)
|
||||
(using `thelounge` tag so that other people can easily find it).
|
||||
- Find us on the Freenode channel `#thelounge`. You might not get an answer
|
||||
- Find us on the Libera.Chat channel `#thelounge`. You might not get an answer
|
||||
right away, but this channel is full of nice people who will be happy to
|
||||
help you.
|
48
.github/workflows/build.yml
vendored
Normal file
48
.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,48 @@
|
|||
name: Build
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Node ${{ matrix.node_version }} on ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# EOL: April 2025
|
||||
- os: macOS-latest
|
||||
node_version: 18.x
|
||||
- os: windows-latest
|
||||
node_version: 18.x
|
||||
- os: ubuntu-latest
|
||||
node_version: 18.x
|
||||
# EOL: April 2026
|
||||
- os: ubuntu-latest
|
||||
node_version: 20.x
|
||||
# EOL: April June 2024
|
||||
- os: ubuntu-latest
|
||||
node_version: 21.x
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
|
||||
- name: Install
|
||||
run: yarn --frozen-lockfile --non-interactive
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
53
.github/workflows/release.yml
vendored
Normal file
53
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
name: Release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: v*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release workflow
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "latest"
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
|
||||
- name: Install
|
||||
run: yarn --frozen-lockfile --non-interactive
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
- name: Publish latest
|
||||
if: "!contains(github.ref, '-')"
|
||||
run: npm publish --tag latest --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
|
||||
- name: Publish next
|
||||
if: contains(github.ref, '-')
|
||||
run: npm publish --tag next --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
|
||||
- name: Remove next tag
|
||||
if: "!contains(github.ref, '-')"
|
||||
run: npm dist-tag rm thelounge next || true
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,7 +1,9 @@
|
|||
node_modules/
|
||||
npm-debug.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
|
||||
.nyc_output/
|
||||
coverage/
|
||||
public/
|
||||
dist/
|
||||
|
|
20
.npmignore
20
.npmignore
|
@ -1,20 +0,0 @@
|
|||
# This file must not contain generated assets listed in .gitignore.
|
||||
# npm-debug.log and node_modules/ are ignored by default.
|
||||
# See https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package
|
||||
|
||||
# Ignore all dot files except for .thelounge_home
|
||||
.*
|
||||
!.thelounge_home
|
||||
|
||||
# Ignore client folder as it's being built into public/ folder
|
||||
# except for the specified files which are used by the server
|
||||
client/**
|
||||
!client/js/libs/handlebars/ircmessageparser/findLinks.js
|
||||
!client/js/libs/handlebars/ircmessageparser/cleanIrcMessage.js
|
||||
|
||||
public/js/bundle.vendor.js.map
|
||||
coverage/
|
||||
scripts/
|
||||
test/
|
||||
appveyor.yml
|
||||
webpack.config.js
|
14
.nycrc
14
.nycrc
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"all": true,
|
||||
"exclude": [
|
||||
"coverage",
|
||||
"public/",
|
||||
"test/",
|
||||
"webpack.config.js"
|
||||
],
|
||||
"reporter": [
|
||||
"lcov",
|
||||
"text",
|
||||
"text-summary"
|
||||
]
|
||||
}
|
28
.prettierignore
Normal file
28
.prettierignore
Normal file
|
@ -0,0 +1,28 @@
|
|||
coverage/
|
||||
public/
|
||||
dist/
|
||||
test/fixtures/.thelounge/logs/
|
||||
test/fixtures/.thelounge/certificates/
|
||||
test/fixtures/.thelounge/storage/
|
||||
test/fixtures/.thelounge/sts-policies.json
|
||||
*.log
|
||||
*.png
|
||||
*.svg
|
||||
*.ico
|
||||
*.wav
|
||||
*.tpl
|
||||
*.sh
|
||||
*.opts
|
||||
*.txt
|
||||
yarn.lock
|
||||
.gitignore
|
||||
.npmrc
|
||||
.npmignore
|
||||
.prettierignore
|
||||
.thelounge_home
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
.gitattributes
|
||||
.browserslistrc
|
||||
|
||||
*.css
|
|
@ -1,11 +0,0 @@
|
|||
extends: stylelint-config-standard
|
||||
|
||||
ignoreFiles:
|
||||
- client/css/bootstrap.css
|
||||
|
||||
rules:
|
||||
indentation: tab
|
||||
# complains about FontAwesome
|
||||
font-family-no-missing-generic-family-keyword:
|
||||
# needs a lot of refactoring to be enabled
|
||||
no-descending-specificity:
|
|
@ -1 +1 @@
|
|||
~/.lounge
|
||||
~/.thelounge
|
||||
|
|
37
.travis.yml
37
.travis.yml
|
@ -1,37 +0,0 @@
|
|||
language: node_js
|
||||
node_js:
|
||||
- 9 # Current stable
|
||||
- 8 # Active LTS until April 2019
|
||||
- 6 # Active LTS until April 2018
|
||||
- 4 # Maintenance LTS until 2018-04-01, will be dropped in The Lounge v3
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- node_js: 8 # Version used to deploy to npm registry
|
||||
env: BUILD_ENV=production
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- ~/.npm
|
||||
|
||||
before_script:
|
||||
- NODE_ENV=$BUILD_ENV npm run build
|
||||
|
||||
notifications:
|
||||
email:
|
||||
on_success: never
|
||||
on_failure: always
|
||||
|
||||
deploy:
|
||||
skip_cleanup: true # prevent git stash --all which nukes node_modules folder
|
||||
provider: npm
|
||||
email:
|
||||
secure: Eb/dO3VEnuG5CFSJbiTBDZ4X29o1bTITqfzc4SZJqkSKHLZ5/l0VHyd1In7T2U9yBtysnmm+dsOWYFwnH5NMt5kvGkkX754HBDz0QXO//IqADA/1cH1MMXuzJjRvHNrtbq3c6Iv0vO827kXfvqwkfGTmXfreT5w+xF7Y+0SjF8pfu2d/Z5omrmoy9J9SF/kfmahKYZwakc3h8p29JPmnFMUAR0JiZS/2gLSHQnGA3mCcnlO+U3bQuTVW3Z9RhiG51f/EMFfNZ8pBttM6CgE2Zth3AT50jbKjRgYdYN2ee/Z3qUJIoA6dfPALC7B+Z2UekqTiKx4SCk+9vZJJXqT8J+Fe67Dki/FgNWnEZaTn8eFs+Gfh2nnokNZUMd/2mMT0y0KbRaOYQarn6lFw+/Cn9hD6e8uRCqY0+YspMvGtV3LuHFy+br6YphlG6YKxJzExtGDvrwlDD70xJtqcgnlET3XOdzvfCpRSskh7FmVJMoL39f/j9r4FzWVDmfnRnDT6Cac2dSdbQM0Ldw3+65l/57K/Km7NeHbLA3LsnjSJqXuysYwosd6iUOQen59Dy+TvwKafEfAGXWcZNguFURIMf2LRZ4rwTZl6pp30nj23U6rmkWm3JTRZC95i/O4yP2rVoljNUEuMlHVts63r3lwXtuGQVo3+lQCYErK4Ceo7cQc=
|
||||
api_key:
|
||||
secure: I9iN31GWI+Mz0xPw81N7qh1M6uidB+3BmiPUXt8QigX45zwp9EhvfZ0U/AIdUyQwzK2RK1zLRQSt+2/1jyeVi+U+AAsRRmaAUx8iqKaQPAkPnQtElolgRP04WSgo7fvNejfM7zS939bQNKG3RlSm04yPgu+ke2igf799p2bpFe2LtyoEeIiUfrUkBiMSpMguN9XF8a7jqCyIouTKjXHR24RmzJ9r7ZoMV27yQauS7XlD81bontzNRZxTytDKdJpZ+sxGIT9mbbtM4LUFX8MeNe3p/bjWavEhrO0ZIpkbOfS/L/w1375YDoNPXxCs288lnGUH+NbGNAEfn+BTz8cmUp7jI7QWR/kNACPeopdAX4OdZxT8wfQcfQZrfCuSpKciOMC7vGgPpQqjQ61t1RKcKs9VUnwC0SwWjyo8LlzkFKnP1ks0eDGYsSoPLdpC9+76UmePkQdxMhscO8TOgkOCcsTMLiyt6ABGOGKu2iE5SsjUYtPiSiRzSBAQENoO560+xBSVTKwqvvhzUAIt4AuAQSgsFjAylDdyzKoObHX12hBdALrqSOOSVwwIQ5/jTgNAsilURHo7KPD407PhRnLOsvumL0qg4sr9S1hjuUKnNla5dg9GY8FVjJ+b2t0A2vgfG1pR1e3vrJRXrpkfRorhmjvKAk2o5you5pQ1Itty7rM=
|
||||
on:
|
||||
node: 8
|
||||
condition: "$BUILD_ENV = production"
|
||||
tags: true
|
||||
repo: thelounge/lounge
|
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"name": "Run Dev",
|
||||
"request": "launch",
|
||||
"command": "yarn dev",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.useEditorConfig": true,
|
||||
"prettier.requireConfig": true,
|
||||
"prettier.disableLanguages": [],
|
||||
"eslint.packageManager": "yarn",
|
||||
"eslint.codeActionsOnSave.mode": "all",
|
||||
"[typescript]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
|
||||
"[vue]": {"editor.defaultFormatter": "esbenp.prettier-vscode"}
|
||||
}
|
4435
CHANGELOG.md
4435
CHANGELOG.md
File diff suppressed because it is too large
Load diff
109
README.md
109
README.md
|
@ -1,67 +1,76 @@
|
|||
# The Lounge
|
||||
<h1 align="center">
|
||||
<img
|
||||
width="300"
|
||||
alt="The Lounge"
|
||||
src="https://raw.githubusercontent.com/thelounge/thelounge/master/client/img/logo-vertical-transparent-bg.svg?sanitize=true">
|
||||
</h1>
|
||||
|
||||
Modern web IRC client designed for self-hosting.
|
||||
<h3 align="center">
|
||||
Modern web IRC client designed for self-hosting
|
||||
</h3>
|
||||
|
||||
[![#thelounge IRC channel on freenode](https://img.shields.io/badge/freenode-%23thelounge-BA68C8.svg)](https://demo.thelounge.chat/)
|
||||
[![npm version](https://img.shields.io/npm/v/thelounge.svg)](https://www.npmjs.org/package/thelounge)
|
||||
[![Travis CI Build Status](https://img.shields.io/travis/thelounge/lounge/master.svg?label=linux+build)](https://travis-ci.org/thelounge/lounge)
|
||||
[![AppVeyor Build Status](https://img.shields.io/appveyor/ci/astorije/lounge/master.svg?label=windows+build)](https://ci.appveyor.com/project/astorije/lounge/branch/master)
|
||||
[![Dependencies Status](https://img.shields.io/david/thelounge/lounge.svg)](https://david-dm.org/thelounge/lounge)
|
||||
<p align="center">
|
||||
<strong>
|
||||
<a href="https://thelounge.chat/">Website</a>
|
||||
•
|
||||
<a href="https://thelounge.chat/docs">Docs</a>
|
||||
•
|
||||
<a href="https://demo.thelounge.chat/">Demo</a>
|
||||
•
|
||||
<a href="https://github.com/thelounge/thelounge-docker">Docker</a>
|
||||
</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://demo.thelounge.chat/"><img
|
||||
alt="#thelounge IRC channel on Libera.Chat"
|
||||
src="https://img.shields.io/badge/Libera.Chat-%23thelounge-415364.svg?colorA=ff9e18"></a>
|
||||
<a href="https://yarn.pm/thelounge"><img
|
||||
alt="npm version"
|
||||
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a>
|
||||
<a href="https://github.com/thelounge/thelounge/actions"><img
|
||||
alt="Build Status"
|
||||
src="https://github.com/thelounge/thelounge/workflows/Build/badge.svg"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/thelounge/thelounge.github.io/master/img/thelounge-screenshot.png" width="550">
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
|
||||
* **Modern features brought to IRC.** Push notifications, link previews, new message markers, and more bring IRC to the 21st century.
|
||||
* **Always connected.** Remains connected to IRC servers while you are offline.
|
||||
* **Cross platform.** It doesn't matter what OS you use, it just works wherever Node.js runs.
|
||||
* **Responsive interface.** The client works smoothly on every desktop, smartphone and tablet.
|
||||
* **Synchronized experience.** Always resume where you left off no matter what device.
|
||||
- **Modern features brought to IRC.** Push notifications, link previews, new message markers, and more bring IRC to the 21st century.
|
||||
- **Always connected.** Remains connected to IRC servers while you are offline.
|
||||
- **Cross platform.** It doesn't matter what OS you use, it just works wherever Node.js runs.
|
||||
- **Responsive interface.** The client works smoothly on every desktop, smartphone and tablet.
|
||||
- **Synchronized experience.** Always resume where you left off no matter what device.
|
||||
|
||||
To learn more about configuration, usage and features of The Lounge, take a look at [the website](https://thelounge.github.io).
|
||||
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8675906/28143204-53116e8c-6719-11e7-992b-d1ba442c6c37.png" width="550">
|
||||
</p>
|
||||
To learn more about configuration, usage and features of The Lounge, take a look at [the website](https://thelounge.chat).
|
||||
|
||||
The Lounge is the official and community-managed fork of [Shout](https://github.com/erming/shout), by [Mattias Erming](https://github.com/erming).
|
||||
|
||||
## Installation and usage
|
||||
|
||||
The Lounge requires [Node.js](https://nodejs.org/) v4 or more recent.
|
||||
The Lounge requires latest [Node.js](https://nodejs.org/) LTS version or more recent.
|
||||
The [Yarn package manager](https://yarnpkg.com/) is also recommended.
|
||||
If you want to install with npm, `--unsafe-perm` is required for a correct install.
|
||||
|
||||
### Running stable releases from npm (recommended)
|
||||
### Running stable releases
|
||||
|
||||
Run this in a terminal to install (or upgrade) the latest stable release from
|
||||
[npm](https://www.npmjs.com/):
|
||||
|
||||
```sh
|
||||
[sudo] npm install -g thelounge
|
||||
```
|
||||
|
||||
When installation is complete, run:
|
||||
|
||||
```sh
|
||||
thelounge start
|
||||
```
|
||||
|
||||
For more information, read the [documentation](https://thelounge.github.io/docs/), [wiki](https://github.com/thelounge/lounge/wiki), or run:
|
||||
|
||||
```sh
|
||||
thelounge --help
|
||||
```
|
||||
Please refer to the [install and upgrade documentation on our website](https://thelounge.chat/docs/install-and-upgrade) for all available installation methods.
|
||||
|
||||
### Running from source
|
||||
|
||||
The following commands install and run the development version of The Lounge:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/thelounge/lounge.git
|
||||
cd lounge
|
||||
npm install
|
||||
NODE_ENV=production npm run build
|
||||
npm start
|
||||
git clone https://github.com/thelounge/thelounge.git
|
||||
cd thelounge
|
||||
yarn install
|
||||
NODE_ENV=production yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
When installed like this, npm doesn't create a `thelounge` executable. Use `npm start -- <command>` to run subcommands.
|
||||
When installed like this, `thelounge` executable is not created. Use `node index <command>` to run commands.
|
||||
|
||||
⚠️ While it is the most recent codebase, this is not production-ready! Run at
|
||||
your own risk. It is also not recommended to run this as root.
|
||||
|
@ -73,6 +82,14 @@ fork.
|
|||
|
||||
Before submitting any change, make sure to:
|
||||
|
||||
- Read the [Contributing instructions](https://github.com/thelounge/lounge/blob/master/CONTRIBUTING.md#contributing)
|
||||
- Run `npm test` to execute linters and test suite
|
||||
- Run `npm run build` if you change or add anything in `client/js` or `client/views`
|
||||
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
|
||||
- Run `yarn test` to execute linters and the test suite
|
||||
- Run `yarn format:prettier` if linting fails
|
||||
- Run `yarn build:client` if you change or add anything in `client/js` or `client/components`
|
||||
- The built files will be output to `public/` by webpack
|
||||
- Run `yarn build:server` if you change anything in `server/`
|
||||
- The built files will be output to `dist/` by tsc
|
||||
- `yarn dev` can be used to start The Lounge with hot module reloading
|
||||
|
||||
To ensure that you don't commit files that fail the linting, you can install a pre-commit git hook.
|
||||
Execute `yarn githooks-install` to do so.
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
- Contact us privately first, in a
|
||||
[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure)
|
||||
manner.
|
||||
- On IRC, send a private message to any voiced user on our Freenode channel,
|
||||
- On IRC, send a private message to any voiced user on our Libera.Chat channel,
|
||||
`#thelounge`.
|
||||
- By email, send us your report at <mailto:security@thelounge.chat>.
|
||||
- By email, send us your report at <security@thelounge.chat>.
|
||||
|
|
33
appveyor.yml
33
appveyor.yml
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
# http://www.appveyor.com/docs/appveyor-yml
|
||||
|
||||
# Build version format
|
||||
version: "{build}"
|
||||
|
||||
# Do not build on tags (GitHub only)
|
||||
skip_tags: true
|
||||
|
||||
# Do not build feature branch with open pull requests
|
||||
skip_branch_with_pr: true
|
||||
|
||||
environment:
|
||||
nodejs_version: '4'
|
||||
|
||||
install:
|
||||
- ps: Install-Product node $env:nodejs_version
|
||||
- appveyor-retry npm install
|
||||
- npm run build
|
||||
- npm install mocha-appveyor-reporter
|
||||
- echo --reporter mocha-appveyor-reporter >> test/mocha.opts
|
||||
|
||||
test_script:
|
||||
- node --version
|
||||
- npm --version
|
||||
- npm test
|
||||
|
||||
# cache npm modules
|
||||
cache:
|
||||
- '%AppData%\npm-cache -> package.json'
|
||||
|
||||
# Don't actually build
|
||||
build: off
|
4
babel.config.cjs
Normal file
4
babel.config.cjs
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
presets: [["@babel/preset-env", {bugfixes: true}], "babel-preset-typescript-vue3"],
|
||||
plugins: ["@babel/plugin-transform-runtime"],
|
||||
};
|
Binary file not shown.
BIN
client/audio/pop.wav
Normal file
BIN
client/audio/pop.wav
Normal file
Binary file not shown.
195
client/components/App.vue
Normal file
195
client/components/App.vue
Normal file
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<div id="viewport" :class="viewportClasses" role="tablist">
|
||||
<Sidebar v-if="store.state.appLoaded" :overlay="overlay" />
|
||||
<div
|
||||
id="sidebar-overlay"
|
||||
ref="overlay"
|
||||
aria-hidden="true"
|
||||
@click="store.commit('sidebarOpen', false)"
|
||||
/>
|
||||
<router-view ref="loungeWindow"></router-view>
|
||||
<Mentions />
|
||||
<ImageViewer ref="imageViewer" />
|
||||
<ContextMenu ref="contextMenu" />
|
||||
<ConfirmDialog ref="confirmDialog" />
|
||||
<div id="upload-overlay"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import constants from "../js/constants";
|
||||
import eventbus from "../js/eventbus";
|
||||
import Mousetrap, {ExtendedKeyboardEvent} from "mousetrap";
|
||||
import throttle from "lodash/throttle";
|
||||
import storage from "../js/localStorage";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
|
||||
import Sidebar from "./Sidebar.vue";
|
||||
import ImageViewer from "./ImageViewer.vue";
|
||||
import ContextMenu from "./ContextMenu.vue";
|
||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||
import Mentions from "./Mentions.vue";
|
||||
import {
|
||||
computed,
|
||||
provide,
|
||||
defineComponent,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
Ref,
|
||||
InjectionKey,
|
||||
} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import type {DebouncedFunc} from "lodash";
|
||||
|
||||
export const imageViewerKey = Symbol() as InjectionKey<Ref<typeof ImageViewer | null>>;
|
||||
const contextMenuKey = Symbol() as InjectionKey<Ref<typeof ContextMenu | null>>;
|
||||
const confirmDialogKey = Symbol() as InjectionKey<Ref<typeof ConfirmDialog | null>>;
|
||||
|
||||
export default defineComponent({
|
||||
name: "App",
|
||||
components: {
|
||||
Sidebar,
|
||||
ImageViewer,
|
||||
ContextMenu,
|
||||
ConfirmDialog,
|
||||
Mentions,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const overlay = ref(null);
|
||||
const loungeWindow = ref(null);
|
||||
const imageViewer = ref(null);
|
||||
const contextMenu = ref(null);
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
provide(imageViewerKey, imageViewer);
|
||||
provide(contextMenuKey, contextMenu);
|
||||
provide(confirmDialogKey, confirmDialog);
|
||||
|
||||
const viewportClasses = computed(() => {
|
||||
return {
|
||||
notified: store.getters.highlightCount > 0,
|
||||
"menu-open": store.state.appLoaded && store.state.sidebarOpen,
|
||||
"menu-dragging": store.state.sidebarDragging,
|
||||
"userlist-open": store.state.userlistOpen,
|
||||
};
|
||||
});
|
||||
|
||||
const debouncedResize = ref<DebouncedFunc<() => void>>();
|
||||
const dayChangeTimeout = ref<any>();
|
||||
|
||||
const escapeKey = () => {
|
||||
eventbus.emit("escapekey");
|
||||
};
|
||||
|
||||
const toggleSidebar = (e: ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
store.commit("toggleSidebar");
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleUserList = (e: ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
store.commit("toggleUserlist");
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleMentions = () => {
|
||||
if (store.state.networks.length !== 0) {
|
||||
eventbus.emit("mentions:toggle");
|
||||
}
|
||||
};
|
||||
|
||||
const msUntilNextDay = () => {
|
||||
// Compute how many milliseconds are remaining until the next day starts
|
||||
const today = new Date();
|
||||
const tommorow = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate() + 1
|
||||
).getTime();
|
||||
|
||||
return tommorow - today.getTime();
|
||||
};
|
||||
|
||||
const prepareOpenStates = () => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
let isUserlistOpen = storage.get("thelounge.state.userlist");
|
||||
|
||||
if (viewportWidth > constants.mobileViewportPixels) {
|
||||
store.commit("sidebarOpen", storage.get("thelounge.state.sidebar") !== "false");
|
||||
}
|
||||
|
||||
// If The Lounge is opened on a small screen (less than 1024px), and we don't have stored
|
||||
// user list state, close it by default
|
||||
if (viewportWidth >= 1024 && isUserlistOpen !== "true" && isUserlistOpen !== "false") {
|
||||
isUserlistOpen = "true";
|
||||
}
|
||||
|
||||
store.commit("userlistOpen", isUserlistOpen === "true");
|
||||
};
|
||||
|
||||
prepareOpenStates();
|
||||
|
||||
onMounted(() => {
|
||||
Mousetrap.bind("esc", escapeKey);
|
||||
Mousetrap.bind("alt+u", toggleUserList);
|
||||
Mousetrap.bind("alt+s", toggleSidebar);
|
||||
Mousetrap.bind("alt+m", toggleMentions);
|
||||
|
||||
debouncedResize.value = throttle(() => {
|
||||
eventbus.emit("resize");
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("resize", debouncedResize.value, {passive: true});
|
||||
|
||||
// Emit a daychange event every time the day changes so date markers know when to update themselves
|
||||
const emitDayChange = () => {
|
||||
eventbus.emit("daychange");
|
||||
// This should always be 24h later but re-computing exact value just in case
|
||||
dayChangeTimeout.value = setTimeout(emitDayChange, msUntilNextDay());
|
||||
};
|
||||
|
||||
dayChangeTimeout.value = setTimeout(emitDayChange, msUntilNextDay());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Mousetrap.unbind("esc");
|
||||
Mousetrap.unbind("alt+u");
|
||||
Mousetrap.unbind("alt+s");
|
||||
Mousetrap.unbind("alt+m");
|
||||
|
||||
if (debouncedResize.value) {
|
||||
window.removeEventListener("resize", debouncedResize.value);
|
||||
}
|
||||
|
||||
if (dayChangeTimeout.value) {
|
||||
clearTimeout(dayChangeTimeout.value);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
viewportClasses,
|
||||
escapeKey,
|
||||
toggleSidebar,
|
||||
toggleUserList,
|
||||
toggleMentions,
|
||||
store,
|
||||
overlay,
|
||||
loungeWindow,
|
||||
imageViewer,
|
||||
contextMenu,
|
||||
confirmDialog,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
65
client/components/Channel.vue
Normal file
65
client/components/Channel.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<!-- TODO: investigate -->
|
||||
<ChannelWrapper ref="wrapper" v-bind="$props">
|
||||
<span class="name">{{ channel.name }}</span>
|
||||
<span
|
||||
v-if="channel.unread"
|
||||
:class="{highlight: channel.highlight && !channel.muted}"
|
||||
class="badge"
|
||||
>{{ unreadCount }}</span
|
||||
>
|
||||
<template v-if="channel.type === 'channel'">
|
||||
<span
|
||||
v-if="channel.state === 0"
|
||||
class="parted-channel-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Not currently joined"
|
||||
>
|
||||
<span class="parted-channel-icon" />
|
||||
</span>
|
||||
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Leave">
|
||||
<button class="close" aria-label="Leave" @click.stop="close" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="close-tooltip tooltipped tooltipped-w" aria-label="Close">
|
||||
<button class="close" aria-label="Close" @click.stop="close" />
|
||||
</span>
|
||||
</template>
|
||||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {PropType, defineComponent, computed} from "vue";
|
||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||
import useCloseChannel from "../js/hooks/use-close-channel";
|
||||
import {ClientChan, ClientNetwork} from "../js/types";
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
channel: {
|
||||
type: Object as PropType<ClientChan>,
|
||||
required: true,
|
||||
},
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
const unreadCount = computed(() => roundBadgeNumber(props.channel.unread));
|
||||
const close = useCloseChannel(props.channel);
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
close,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
112
client/components/ChannelWrapper.vue
Normal file
112
client/components/ChannelWrapper.vue
Normal file
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<!-- TODO: move closed style to it's own class -->
|
||||
<div
|
||||
v-if="isChannelVisible"
|
||||
ref="element"
|
||||
:class="[
|
||||
'channel-list-item',
|
||||
{active: active},
|
||||
{'parted-channel': channel.type === 'channel' && channel.state === 0},
|
||||
{'has-draft': channel.pendingMessage},
|
||||
{'has-unread': channel.unread},
|
||||
{'has-highlight': channel.highlight},
|
||||
{
|
||||
'not-secure':
|
||||
channel.type === 'lobby' && network.status.connected && !network.status.secure,
|
||||
},
|
||||
{'not-connected': channel.type === 'lobby' && !network.status.connected},
|
||||
{'is-muted': channel.muted},
|
||||
]"
|
||||
:aria-label="getAriaLabel()"
|
||||
:title="getAriaLabel()"
|
||||
:data-name="channel.name"
|
||||
:data-type="channel.type"
|
||||
:aria-controls="'#chan-' + channel.id"
|
||||
:aria-selected="active"
|
||||
:style="channel.closed ? {transition: 'none', opacity: 0.4} : undefined"
|
||||
role="tab"
|
||||
@click="click"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
>
|
||||
<slot :network="network" :channel="channel" :active-channel="activeChannel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import eventbus from "../js/eventbus";
|
||||
import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
|
||||
import {ClientNetwork, ClientChan} from "../js/types";
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {switchToChannel} from "../js/router";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChannelWrapper",
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
channel: {
|
||||
type: Object as PropType<ClientChan>,
|
||||
required: true,
|
||||
},
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
const activeChannel = computed(() => store.state.activeChannel);
|
||||
const isChannelVisible = computed(
|
||||
() => props.isFiltering || !isChannelCollapsed(props.network, props.channel)
|
||||
);
|
||||
|
||||
const getAriaLabel = () => {
|
||||
const extra: string[] = [];
|
||||
const type = props.channel.type;
|
||||
|
||||
if (props.channel.unread > 0) {
|
||||
if (props.channel.unread > 1) {
|
||||
extra.push(`${props.channel.unread} unread messages`);
|
||||
} else {
|
||||
extra.push(`${props.channel.unread} unread message`);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.channel.highlight > 0) {
|
||||
if (props.channel.highlight > 1) {
|
||||
extra.push(`${props.channel.highlight} mentions`);
|
||||
} else {
|
||||
extra.push(`${props.channel.highlight} mention`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${type}: ${props.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
|
||||
};
|
||||
|
||||
const click = () => {
|
||||
if (props.isFiltering) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchToChannel(props.channel);
|
||||
};
|
||||
|
||||
const openContextMenu = (event: MouseEvent) => {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: props.channel,
|
||||
network: props.network,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
activeChannel,
|
||||
isChannelVisible,
|
||||
getAriaLabel,
|
||||
click,
|
||||
openContextMenu,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
274
client/components/Chat.vue
Normal file
274
client/components/Chat.vue
Normal file
|
@ -0,0 +1,274 @@
|
|||
<template>
|
||||
<div id="chat-container" class="window" :data-current-channel="channel.name" lang="">
|
||||
<div
|
||||
id="chat"
|
||||
:class="{
|
||||
'hide-motd': !store.state.settings.motd,
|
||||
'time-seconds': store.state.settings.showSeconds,
|
||||
'time-12h': store.state.settings.use12hClock,
|
||||
'colored-nicks': true, // TODO temporarily fixes themes, to be removed in next major version
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:id="'chan-' + channel.id"
|
||||
class="chat-view"
|
||||
:data-type="channel.type"
|
||||
:aria-label="channel.name"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
<span class="title" :aria-label="'Currently open ' + channel.type">{{
|
||||
channel.name
|
||||
}}</span>
|
||||
<div v-if="channel.editTopic === true" class="topic-container">
|
||||
<input
|
||||
ref="topicInput"
|
||||
:value="channel.topic"
|
||||
class="topic-input"
|
||||
placeholder="Set channel topic"
|
||||
enterkeyhint="done"
|
||||
@keyup.enter="saveTopic"
|
||||
@keyup.esc="channel.editTopic = false"
|
||||
/>
|
||||
<span aria-label="Save topic" class="save-topic" @click="saveTopic">
|
||||
<span type="button" aria-label="Save topic"></span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
:title="channel.topic"
|
||||
:class="{topic: true, empty: !channel.topic}"
|
||||
@dblclick="editTopic"
|
||||
><ParsedMessage
|
||||
v-if="channel.topic"
|
||||
:network="network"
|
||||
:text="channel.topic"
|
||||
/></span>
|
||||
<MessageSearchForm
|
||||
v-if="
|
||||
store.state.settings.searchEnabled &&
|
||||
['channel', 'query'].includes(channel.type)
|
||||
"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
/>
|
||||
<button
|
||||
class="mentions"
|
||||
aria-label="Open your mentions"
|
||||
@click="openMentions"
|
||||
/>
|
||||
<button
|
||||
class="menu"
|
||||
aria-label="Open the context menu"
|
||||
@click="openContextMenu"
|
||||
/>
|
||||
<span
|
||||
v-if="channel.type === 'channel'"
|
||||
class="rt-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Toggle user list"
|
||||
>
|
||||
<button
|
||||
class="rt"
|
||||
aria-label="Toggle user list"
|
||||
@click="store.commit('toggleUserlist')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="channel.type === 'special'" class="chat-content">
|
||||
<div class="chat">
|
||||
<div class="messages">
|
||||
<div class="msg">
|
||||
<component
|
||||
:is="specialComponent"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chat-content">
|
||||
<div
|
||||
:class="[
|
||||
'scroll-down tooltipped tooltipped-w tooltipped-no-touch',
|
||||
{'scroll-down-shown': !channel.scrolledToBottom},
|
||||
]"
|
||||
aria-label="Jump to recent messages"
|
||||
@click="messageList?.jumpToBottom()"
|
||||
>
|
||||
<div class="scroll-down-arrow" />
|
||||
</div>
|
||||
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
|
||||
<MessageList
|
||||
ref="messageList"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
:focused="focused"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.state.currentUserVisibleError"
|
||||
id="user-visible-error"
|
||||
@click="hideUserVisibleError"
|
||||
>
|
||||
{{ store.state.currentUserVisibleError }}
|
||||
</div>
|
||||
<ChatInput :network="network" :channel="channel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import socket from "../js/socket";
|
||||
import eventbus from "../js/eventbus";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageList from "./MessageList.vue";
|
||||
import ChatInput from "./ChatInput.vue";
|
||||
import ChatUserList from "./ChatUserList.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
import MessageSearchForm from "./MessageSearchForm.vue";
|
||||
import ListBans from "./Special/ListBans.vue";
|
||||
import ListInvites from "./Special/ListInvites.vue";
|
||||
import ListChannels from "./Special/ListChannels.vue";
|
||||
import ListIgnored from "./Special/ListIgnored.vue";
|
||||
import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Component} from "vue";
|
||||
import type {ClientNetwork, ClientChan} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
import {SpecialChanType, ChanType} from "../../shared/types/chan";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Chat",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
MessageList,
|
||||
ChatInput,
|
||||
ChatUserList,
|
||||
SidebarToggle,
|
||||
MessageSearchForm,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
focused: Number,
|
||||
},
|
||||
emits: ["channel-changed"],
|
||||
setup(props, {emit}) {
|
||||
const store = useStore();
|
||||
|
||||
const messageList = ref<typeof MessageList>();
|
||||
const topicInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const specialComponent = computed(() => {
|
||||
switch (props.channel.special) {
|
||||
case SpecialChanType.BANLIST:
|
||||
return ListBans as Component;
|
||||
case SpecialChanType.INVITELIST:
|
||||
return ListInvites as Component;
|
||||
case SpecialChanType.CHANNELLIST:
|
||||
return ListChannels as Component;
|
||||
case SpecialChanType.IGNORELIST:
|
||||
return ListIgnored as Component;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const channelChanged = () => {
|
||||
// Triggered when active channel is set or changed
|
||||
emit("channel-changed", props.channel);
|
||||
|
||||
socket.emit("open", props.channel.id);
|
||||
|
||||
if (props.channel.usersOutdated) {
|
||||
props.channel.usersOutdated = false;
|
||||
|
||||
socket.emit("names", {
|
||||
target: props.channel.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hideUserVisibleError = () => {
|
||||
store.commit("currentUserVisibleError", null);
|
||||
};
|
||||
|
||||
const editTopic = () => {
|
||||
if (props.channel.type === ChanType.CHANNEL) {
|
||||
props.channel.editTopic = true;
|
||||
}
|
||||
};
|
||||
|
||||
const saveTopic = () => {
|
||||
props.channel.editTopic = false;
|
||||
|
||||
if (!topicInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTopic = topicInput.value.value;
|
||||
|
||||
if (props.channel.topic !== newTopic) {
|
||||
const target = props.channel.id;
|
||||
const text = `/raw TOPIC ${props.channel.name} :${newTopic}`;
|
||||
socket.emit("input", {target, text});
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = (event: any) => {
|
||||
eventbus.emit("contextmenu:channel", {
|
||||
event: event,
|
||||
channel: props.channel,
|
||||
network: props.network,
|
||||
});
|
||||
};
|
||||
|
||||
const openMentions = (event: any) => {
|
||||
eventbus.emit("mentions:toggle", {
|
||||
event: event,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.channel,
|
||||
() => {
|
||||
channelChanged();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.editTopic,
|
||||
(newTopic) => {
|
||||
if (newTopic) {
|
||||
void nextTick(() => {
|
||||
topicInput.value?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
channelChanged();
|
||||
|
||||
if (props.channel.editTopic) {
|
||||
void nextTick(() => {
|
||||
topicInput.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
messageList,
|
||||
topicInput,
|
||||
specialComponent,
|
||||
hideUserVisibleError,
|
||||
editTopic,
|
||||
saveTopic,
|
||||
openContextMenu,
|
||||
openMentions,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
358
client/components/ChatInput.vue
Normal file
358
client/components/ChatInput.vue
Normal file
|
@ -0,0 +1,358 @@
|
|||
<template>
|
||||
<form id="form" method="post" action="" @submit.prevent="onSubmit">
|
||||
<span id="upload-progressbar" />
|
||||
<span id="nick">{{ network.nick }}</span>
|
||||
<textarea
|
||||
id="input"
|
||||
ref="input"
|
||||
dir="auto"
|
||||
class="mousetrap"
|
||||
enterkeyhint="send"
|
||||
:value="channel.pendingMessage"
|
||||
:placeholder="getInputPlaceholder(channel)"
|
||||
:aria-label="getInputPlaceholder(channel)"
|
||||
@input="setPendingMessage"
|
||||
@keypress.enter.exact.prevent="onSubmit"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<span
|
||||
v-if="store.state.serverConfiguration?.fileUpload"
|
||||
id="upload-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Upload file"
|
||||
@click="openFileUpload"
|
||||
>
|
||||
<input
|
||||
id="upload-input"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
aria-labelledby="upload"
|
||||
multiple
|
||||
@change="onUploadInputChange"
|
||||
/>
|
||||
<button
|
||||
id="upload"
|
||||
type="button"
|
||||
aria-label="Upload file"
|
||||
:disabled="!store.state.isConnected"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
id="submit-tooltip"
|
||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<button
|
||||
id="submit"
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
:disabled="!store.state.isConnected"
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Mousetrap from "mousetrap";
|
||||
import {wrapCursor} from "undate";
|
||||
import autocompletion from "../js/autocompletion";
|
||||
import {commands} from "../js/commands/index";
|
||||
import socket from "../js/socket";
|
||||
import upload from "../js/upload";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
|
||||
import type {ClientNetwork, ClientChan} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
import {ChanType} from "../../shared/types/chan";
|
||||
|
||||
const formattingHotkeys = {
|
||||
"mod+k": "\x03",
|
||||
"mod+b": "\x02",
|
||||
"mod+u": "\x1F",
|
||||
"mod+i": "\x1D",
|
||||
"mod+o": "\x0F",
|
||||
"mod+s": "\x1e",
|
||||
"mod+m": "\x11",
|
||||
};
|
||||
|
||||
// Autocomplete bracket and quote characters like in a modern IDE
|
||||
// For example, select `text`, press `[` key, and it becomes `[text]`
|
||||
const bracketWraps = {
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
"(": ")",
|
||||
"<": ">",
|
||||
"[": "]",
|
||||
"{": "}",
|
||||
"*": "*",
|
||||
"`": "`",
|
||||
"~": "~",
|
||||
_: "_",
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChatInput",
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
const input = ref<HTMLTextAreaElement>();
|
||||
const uploadInput = ref<HTMLInputElement>();
|
||||
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
|
||||
|
||||
const setInputSize = () => {
|
||||
void nextTick(() => {
|
||||
if (!input.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(input.value);
|
||||
const lineHeight = parseFloat(style.lineHeight) || 1;
|
||||
|
||||
// Start by resetting height before computing as scrollHeight does not
|
||||
// decrease when deleting characters
|
||||
input.value.style.height = "";
|
||||
|
||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
||||
// because some browsers tend to incorrently round the values when using high density
|
||||
// displays or using page zoom feature
|
||||
input.value.style.height = `${
|
||||
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
|
||||
}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const setPendingMessage = (e: Event) => {
|
||||
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
|
||||
props.channel.inputHistoryPosition = 0;
|
||||
setInputSize();
|
||||
};
|
||||
|
||||
const getInputPlaceholder = (channel: ClientChan) => {
|
||||
if (channel.type === ChanType.CHANNEL || channel.type === ChanType.QUERY) {
|
||||
return `Write to ${channel.name}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!input.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Triggering click event opens the virtual keyboard on mobile
|
||||
// This can only be called from another interactive event (e.g. button click)
|
||||
input.value.click();
|
||||
input.value.focus();
|
||||
|
||||
if (!store.state.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = props.channel.id;
|
||||
const text = props.channel.pendingMessage;
|
||||
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.hide();
|
||||
}
|
||||
|
||||
props.channel.inputHistoryPosition = 0;
|
||||
props.channel.pendingMessage = "";
|
||||
input.value.value = "";
|
||||
setInputSize();
|
||||
|
||||
// Store new message in history if last message isn't already equal
|
||||
if (props.channel.inputHistory[1] !== text) {
|
||||
props.channel.inputHistory.splice(1, 0, text);
|
||||
}
|
||||
|
||||
// Limit input history to a 100 entries
|
||||
if (props.channel.inputHistory.length > 100) {
|
||||
props.channel.inputHistory.pop();
|
||||
}
|
||||
|
||||
if (text[0] === "/") {
|
||||
const args = text.substring(1).split(" ");
|
||||
const cmd = args.shift()?.toLowerCase();
|
||||
|
||||
if (!cmd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(commands, cmd) && commands[cmd](args)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("input", {target, text});
|
||||
};
|
||||
|
||||
const onUploadInputChange = () => {
|
||||
if (!uploadInput.value || !uploadInput.value.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(uploadInput.value.files);
|
||||
upload.triggerUpload(files);
|
||||
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
|
||||
};
|
||||
|
||||
const openFileUpload = () => {
|
||||
uploadInput.value?.click();
|
||||
};
|
||||
|
||||
const blurInput = () => {
|
||||
input.value?.blur();
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.hide();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.channel.id,
|
||||
() => {
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.hide();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.pendingMessage,
|
||||
() => {
|
||||
setInputSize();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("escapekey", blurInput);
|
||||
|
||||
if (store.state.settings.autocomplete) {
|
||||
if (!input.value) {
|
||||
throw new Error("ChatInput autocomplete: input element is not available");
|
||||
}
|
||||
|
||||
autocompletionRef.value = autocompletion(input.value);
|
||||
}
|
||||
|
||||
const inputTrap = Mousetrap(input.value);
|
||||
|
||||
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
|
||||
const modifier = formattingHotkeys[key];
|
||||
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrapCursor(
|
||||
e.target as HTMLTextAreaElement,
|
||||
modifier,
|
||||
(e.target as HTMLTextAreaElement).selectionStart ===
|
||||
(e.target as HTMLTextAreaElement).selectionEnd
|
||||
? ""
|
||||
: modifier
|
||||
);
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
|
||||
if (
|
||||
(e.target as HTMLTextAreaElement)?.selectionStart !==
|
||||
(e.target as HTMLTextAreaElement).selectionEnd
|
||||
) {
|
||||
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
inputTrap.bind(["up", "down"], (e, key) => {
|
||||
if (
|
||||
store.state.isAutoCompleting ||
|
||||
(e.target as HTMLTextAreaElement).selectionStart !==
|
||||
(e.target as HTMLTextAreaElement).selectionEnd ||
|
||||
!input.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onRow = (
|
||||
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
|
||||
[]
|
||||
).length;
|
||||
const totalRows = (input.value.value.match(/\n/g) || []).length;
|
||||
|
||||
const {channel} = props;
|
||||
|
||||
if (channel.inputHistoryPosition === 0) {
|
||||
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
||||
}
|
||||
|
||||
if (key === "up" && onRow === 0) {
|
||||
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
||||
channel.inputHistoryPosition++;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
key === "down" &&
|
||||
channel.inputHistoryPosition > 0 &&
|
||||
onRow === totalRows
|
||||
) {
|
||||
channel.inputHistoryPosition--;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
|
||||
input.value.value = channel.pendingMessage;
|
||||
setInputSize();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (store.state.serverConfiguration?.fileUpload) {
|
||||
upload.mounted();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", blurInput);
|
||||
|
||||
if (autocompletionRef.value) {
|
||||
autocompletionRef.value.destroy();
|
||||
autocompletionRef.value = undefined;
|
||||
}
|
||||
|
||||
upload.unmounted();
|
||||
upload.abort();
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
input,
|
||||
uploadInput,
|
||||
onUploadInputChange,
|
||||
openFileUpload,
|
||||
blurInput,
|
||||
onBlur,
|
||||
setInputSize,
|
||||
upload,
|
||||
getInputPlaceholder,
|
||||
onSubmit,
|
||||
setPendingMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
255
client/components/ChatUserList.vue
Normal file
255
client/components/ChatUserList.vue
Normal file
|
@ -0,0 +1,255 @@
|
|||
<template>
|
||||
<aside
|
||||
ref="userlist"
|
||||
class="userlist"
|
||||
:aria-label="'User list for ' + channel.name"
|
||||
@mouseleave="removeHoverUser"
|
||||
>
|
||||
<div class="count">
|
||||
<input
|
||||
ref="input"
|
||||
:value="userSearchInput"
|
||||
:placeholder="
|
||||
channel.users.length + ' user' + (channel.users.length === 1 ? '' : 's')
|
||||
"
|
||||
type="search"
|
||||
class="search"
|
||||
aria-label="Search among the user list"
|
||||
tabindex="-1"
|
||||
@input="setUserSearchInput"
|
||||
@keydown.up="navigateUserList($event, -1)"
|
||||
@keydown.down="navigateUserList($event, 1)"
|
||||
@keydown.page-up="navigateUserList($event, -10)"
|
||||
@keydown.page-down="navigateUserList($event, 10)"
|
||||
@keydown.enter="selectUser"
|
||||
/>
|
||||
</div>
|
||||
<div class="names">
|
||||
<div
|
||||
v-for="(users, mode) in groupedUsers"
|
||||
:key="mode"
|
||||
:class="['user-mode', getModeClass(String(mode))]"
|
||||
>
|
||||
<template v-if="userSearchInput.length > 0">
|
||||
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
|
||||
<Username
|
||||
v-for="user in users"
|
||||
:key="user.original.nick + '-search'"
|
||||
:on-hover="hoverUser"
|
||||
:active="user.original === activeUser"
|
||||
:user="user.original"
|
||||
v-html="user.string"
|
||||
/>
|
||||
<!-- eslint-enable -->
|
||||
</template>
|
||||
<template v-else>
|
||||
<Username
|
||||
v-for="user in users"
|
||||
:key="user.nick"
|
||||
:on-hover="hoverUser"
|
||||
:active="user === activeUser"
|
||||
:user="user"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {filter as fuzzyFilter} from "fuzzy";
|
||||
import {computed, defineComponent, nextTick, PropType, ref} from "vue";
|
||||
import type {UserInMessage} from "../../shared/types/msg";
|
||||
import type {ClientChan, ClientUser} from "../js/types";
|
||||
import Username from "./Username.vue";
|
||||
|
||||
const modes = {
|
||||
"~": "owner",
|
||||
"&": "admin",
|
||||
"!": "admin",
|
||||
"@": "op",
|
||||
"%": "half-op",
|
||||
"+": "voice",
|
||||
"": "normal",
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChatUserList",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup(props) {
|
||||
const userSearchInput = ref("");
|
||||
const activeUser = ref<UserInMessage | null>();
|
||||
const userlist = ref<HTMLDivElement>();
|
||||
const filteredUsers = computed(() => {
|
||||
if (!userSearchInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
return fuzzyFilter(userSearchInput.value, props.channel.users, {
|
||||
pre: "<b>",
|
||||
post: "</b>",
|
||||
extract: (u) => u.nick,
|
||||
});
|
||||
});
|
||||
|
||||
const groupedUsers = computed(() => {
|
||||
const groups = {};
|
||||
|
||||
if (userSearchInput.value && filteredUsers.value) {
|
||||
const result = filteredUsers.value;
|
||||
|
||||
for (const user of result) {
|
||||
const mode: string = user.original.modes[0] || "";
|
||||
|
||||
if (!groups[mode]) {
|
||||
groups[mode] = [];
|
||||
}
|
||||
|
||||
// Prepend user mode to search result
|
||||
user.string = mode + user.string;
|
||||
|
||||
groups[mode].push(user);
|
||||
}
|
||||
} else {
|
||||
for (const user of props.channel.users) {
|
||||
const mode = user.modes[0] || "";
|
||||
|
||||
if (!groups[mode]) {
|
||||
groups[mode] = [user];
|
||||
} else {
|
||||
groups[mode].push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups as {
|
||||
[mode: string]: (ClientUser & {
|
||||
original: UserInMessage;
|
||||
string: string;
|
||||
})[];
|
||||
};
|
||||
});
|
||||
|
||||
const setUserSearchInput = (e: Event) => {
|
||||
userSearchInput.value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
const getModeClass = (mode: string) => {
|
||||
return modes[mode] as typeof modes;
|
||||
};
|
||||
|
||||
const selectUser = () => {
|
||||
// Simulate a click on the active user to open the context menu.
|
||||
// Coordinates are provided to position the menu correctly.
|
||||
if (!activeUser.value || !userlist.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = userlist.value.querySelector(".active");
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ev = new MouseEvent("click", {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left,
|
||||
clientY: rect.top + rect.height,
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
};
|
||||
|
||||
const hoverUser = (user: UserInMessage) => {
|
||||
activeUser.value = user;
|
||||
};
|
||||
|
||||
const removeHoverUser = () => {
|
||||
activeUser.value = null;
|
||||
};
|
||||
|
||||
const scrollToActiveUser = () => {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
void nextTick(() => {
|
||||
const el = userlist.value?.querySelector(".active");
|
||||
el?.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
});
|
||||
};
|
||||
|
||||
const navigateUserList = (event: Event, direction: number) => {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
let users = props.channel.users;
|
||||
|
||||
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
||||
if (userSearchInput.value && filteredUsers.value) {
|
||||
users = filteredUsers.value.map((result) => result.original);
|
||||
}
|
||||
|
||||
// Bail out if there's no users to select
|
||||
if (!users.length) {
|
||||
activeUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const abort = () => {
|
||||
activeUser.value = direction ? users[0] : users[users.length - 1];
|
||||
scrollToActiveUser();
|
||||
};
|
||||
|
||||
// If there's no active user select the first or last one depending on direction
|
||||
if (!activeUser.value) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = users.indexOf(activeUser.value as ClientUser);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
// Wrap around the list if necessary. Normaly each loop iterates once at most,
|
||||
// but might iterate more often if pgup or pgdown are used in a very short user list
|
||||
while (currentIndex < 0) {
|
||||
currentIndex += users.length;
|
||||
}
|
||||
|
||||
while (currentIndex > users.length - 1) {
|
||||
currentIndex -= users.length;
|
||||
}
|
||||
|
||||
activeUser.value = users[currentIndex];
|
||||
scrollToActiveUser();
|
||||
};
|
||||
|
||||
return {
|
||||
filteredUsers,
|
||||
groupedUsers,
|
||||
userSearchInput,
|
||||
activeUser,
|
||||
userlist,
|
||||
|
||||
setUserSearchInput,
|
||||
getModeClass,
|
||||
selectUser,
|
||||
hoverUser,
|
||||
removeHoverUser,
|
||||
navigateUserList,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
102
client/components/ConfirmDialog.vue
Normal file
102
client/components/ConfirmDialog.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<div id="confirm-dialog-overlay" :class="{opened: !!data}">
|
||||
<div v-if="data !== null" id="confirm-dialog">
|
||||
<div class="confirm-text">
|
||||
<div class="confirm-text-title">{{ data?.title }}</div>
|
||||
<p>{{ data?.text }}</p>
|
||||
</div>
|
||||
<div class="confirm-buttons">
|
||||
<button class="btn btn-cancel" @click="close(false)">Cancel</button>
|
||||
<button class="btn btn-danger" @click="close(true)">{{ data?.button }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#confirm-dialog {
|
||||
background: var(--body-bg-color);
|
||||
color: #fff;
|
||||
margin: 10px;
|
||||
border-radius: 5px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-text {
|
||||
padding: 15px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-text-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-buttons .btn {
|
||||
margin-bottom: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#confirm-dialog .confirm-buttons .btn-cancel {
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import eventbus from "../js/eventbus";
|
||||
import {defineComponent, onMounted, onUnmounted, ref} from "vue";
|
||||
|
||||
type ConfirmDialogData = {
|
||||
title: string;
|
||||
text: string;
|
||||
button: string;
|
||||
};
|
||||
|
||||
type ConfirmDialogCallback = {
|
||||
(confirmed: boolean): void;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "ConfirmDialog",
|
||||
setup() {
|
||||
const data = ref<ConfirmDialogData>();
|
||||
const callback = ref<ConfirmDialogCallback>();
|
||||
|
||||
const open = (incoming: ConfirmDialogData, cb: ConfirmDialogCallback) => {
|
||||
data.value = incoming;
|
||||
callback.value = cb;
|
||||
};
|
||||
|
||||
const close = (result: boolean) => {
|
||||
data.value = undefined;
|
||||
|
||||
if (callback.value) {
|
||||
callback.value(!!result);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("escapekey", close);
|
||||
eventbus.on("confirm-dialog", open);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", close);
|
||||
eventbus.off("confirm-dialog", open);
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
close,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
284
client/components/ContextMenu.vue
Normal file
284
client/components/ContextMenu.vue
Normal file
|
@ -0,0 +1,284 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
id="context-menu-container"
|
||||
:class="{passthrough}"
|
||||
@click="containerClick"
|
||||
@contextmenu.prevent="containerClick"
|
||||
@keydown.exact.up.prevent="navigateMenu(-1)"
|
||||
@keydown.exact.down.prevent="navigateMenu(1)"
|
||||
@keydown.exact.tab.prevent="navigateMenu(1)"
|
||||
@keydown.shift.tab.prevent="navigateMenu(-1)"
|
||||
>
|
||||
<ul
|
||||
id="context-menu"
|
||||
ref="contextMenu"
|
||||
role="menu"
|
||||
:style="{
|
||||
top: style.top + 'px',
|
||||
left: style.left + 'px',
|
||||
}"
|
||||
tabindex="-1"
|
||||
@mouseleave="activeItem = -1"
|
||||
@keydown.enter.prevent="clickActiveItem"
|
||||
>
|
||||
<!-- TODO: type -->
|
||||
<template v-for="(item, id) of (items as any)" :key="item.name">
|
||||
<li
|
||||
:class="[
|
||||
'context-menu-' + item.type,
|
||||
item.class ? 'context-menu-' + item.class : null,
|
||||
{active: id === activeItem},
|
||||
]"
|
||||
role="menuitem"
|
||||
@mouseenter="hoverItem(id)"
|
||||
@click="clickItem(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
generateUserContextMenu,
|
||||
generateChannelContextMenu,
|
||||
generateInlineChannelContextMenu,
|
||||
ContextMenuItem,
|
||||
} from "../js/helpers/contextMenu";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {defineComponent, nextTick, onMounted, onUnmounted, PropType, ref} from "vue";
|
||||
import {ClientChan, ClientMessage, ClientNetwork, ClientUser} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
import {useRouter} from "vue-router";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ContextMenu",
|
||||
props: {
|
||||
message: {
|
||||
required: false,
|
||||
type: Object as PropType<ClientMessage>,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const passthrough = ref(false);
|
||||
|
||||
const contextMenu = ref<HTMLUListElement | null>();
|
||||
const previousActiveElement = ref<HTMLElement | null>();
|
||||
const items = ref<ContextMenuItem[]>([]);
|
||||
const activeItem = ref(-1);
|
||||
const style = ref({
|
||||
top: 0,
|
||||
left: 0,
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = false;
|
||||
items.value = [];
|
||||
|
||||
if (previousActiveElement.value) {
|
||||
previousActiveElement.value.focus();
|
||||
previousActiveElement.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const enablePointerEvents = () => {
|
||||
passthrough.value = false;
|
||||
document.body.removeEventListener("pointerup", enablePointerEvents);
|
||||
};
|
||||
|
||||
const containerClick = (event: MouseEvent) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const positionContextMenu = (event: MouseEvent) => {
|
||||
const element = event.target as HTMLElement;
|
||||
|
||||
if (!contextMenu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuWidth = contextMenu.value?.offsetWidth;
|
||||
const menuHeight = contextMenu.value?.offsetHeight;
|
||||
|
||||
if (element && element.classList.contains("menu")) {
|
||||
return {
|
||||
left: element.getBoundingClientRect().left - (menuWidth - element.offsetWidth),
|
||||
top: element.getBoundingClientRect().top + element.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const offset = {left: event.pageX, top: event.pageY};
|
||||
|
||||
if (window.innerWidth - offset.left < menuWidth) {
|
||||
offset.left = window.innerWidth - menuWidth;
|
||||
}
|
||||
|
||||
if (window.innerHeight - offset.top < menuHeight) {
|
||||
offset.top = window.innerHeight - menuHeight;
|
||||
}
|
||||
|
||||
return offset;
|
||||
};
|
||||
|
||||
const hoverItem = (id: number) => {
|
||||
activeItem.value = id;
|
||||
};
|
||||
|
||||
const clickItem = (item: ContextMenuItem) => {
|
||||
close();
|
||||
|
||||
if ("action" in item && item.action) {
|
||||
item.action();
|
||||
} else if ("link" in item && item.link) {
|
||||
router.push(item.link).catch(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to navigate to", item.link);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clickActiveItem = () => {
|
||||
if (items.value[activeItem.value]) {
|
||||
clickItem(items.value[activeItem.value]);
|
||||
}
|
||||
};
|
||||
|
||||
const open = (event: MouseEvent, newItems: ContextMenuItem[]) => {
|
||||
event.preventDefault();
|
||||
|
||||
previousActiveElement.value = document.activeElement as HTMLElement;
|
||||
items.value = newItems;
|
||||
activeItem.value = 0;
|
||||
isOpen.value = true;
|
||||
|
||||
// Position the menu and set the focus on the first item after it's size has updated
|
||||
nextTick(() => {
|
||||
const pos = positionContextMenu(event);
|
||||
|
||||
if (!pos) {
|
||||
return;
|
||||
}
|
||||
|
||||
style.value.left = pos.left;
|
||||
style.value.top = pos.top;
|
||||
contextMenu.value?.focus();
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const openChannelContextMenu = (data: {
|
||||
event: MouseEvent;
|
||||
channel: ClientChan;
|
||||
network: ClientNetwork;
|
||||
}) => {
|
||||
if (data.event.type === "contextmenu") {
|
||||
// Pass through all pointer events to allow the network list's
|
||||
// dragging events to continue triggering.
|
||||
passthrough.value = true;
|
||||
document.body.addEventListener("pointerup", enablePointerEvents, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const newItems = generateChannelContextMenu(data.channel, data.network);
|
||||
open(data.event, newItems);
|
||||
};
|
||||
|
||||
const openInlineChannelContextMenu = (data: {channel: string; event: MouseEvent}) => {
|
||||
const {network} = store.state.activeChannel;
|
||||
const newItems = generateInlineChannelContextMenu(store, data.channel, network);
|
||||
|
||||
open(data.event, newItems);
|
||||
};
|
||||
|
||||
const openUserContextMenu = (data: {
|
||||
user: Pick<ClientUser, "nick" | "modes">;
|
||||
event: MouseEvent;
|
||||
}) => {
|
||||
const {network, channel} = store.state.activeChannel;
|
||||
|
||||
const newItems = generateUserContextMenu(
|
||||
store,
|
||||
channel,
|
||||
network,
|
||||
channel.users.find((u) => u.nick === data.user.nick) || {
|
||||
nick: data.user.nick,
|
||||
modes: [],
|
||||
}
|
||||
);
|
||||
open(data.event, newItems);
|
||||
};
|
||||
|
||||
const navigateMenu = (direction: number) => {
|
||||
let currentIndex = activeItem.value;
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
const nextItem = items.value[currentIndex];
|
||||
|
||||
// If the next item we would select is a divider, skip over it
|
||||
if (nextItem && "type" in nextItem && nextItem.type === "divider") {
|
||||
currentIndex += direction;
|
||||
}
|
||||
|
||||
if (currentIndex < 0) {
|
||||
currentIndex += items.value.length;
|
||||
}
|
||||
|
||||
if (currentIndex > items.value.length - 1) {
|
||||
currentIndex -= items.value.length;
|
||||
}
|
||||
|
||||
activeItem.value = currentIndex;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("escapekey", close);
|
||||
eventbus.on("contextmenu:cancel", close);
|
||||
eventbus.on("contextmenu:user", openUserContextMenu);
|
||||
eventbus.on("contextmenu:channel", openChannelContextMenu);
|
||||
eventbus.on("contextmenu:inline-channel", openInlineChannelContextMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", close);
|
||||
eventbus.off("contextmenu:cancel", close);
|
||||
eventbus.off("contextmenu:user", openUserContextMenu);
|
||||
eventbus.off("contextmenu:channel", openChannelContextMenu);
|
||||
eventbus.off("contextmenu:inline-channel", openInlineChannelContextMenu);
|
||||
|
||||
close();
|
||||
});
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
items,
|
||||
activeItem,
|
||||
style,
|
||||
contextMenu,
|
||||
passthrough,
|
||||
close,
|
||||
containerClick,
|
||||
navigateMenu,
|
||||
hoverItem,
|
||||
clickItem,
|
||||
clickActiveItem,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
66
client/components/DateMarker.vue
Normal file
66
client/components/DateMarker.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div :aria-label="localeDate" class="date-marker-container tooltipped tooltipped-s">
|
||||
<div class="date-marker">
|
||||
<span :aria-label="friendlyDate()" class="date-marker-text" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import calendar from "dayjs/plugin/calendar";
|
||||
import {computed, defineComponent, onBeforeUnmount, onMounted, PropType} from "vue";
|
||||
import eventbus from "../js/eventbus";
|
||||
import type {ClientMessage} from "../js/types";
|
||||
|
||||
dayjs.extend(calendar);
|
||||
|
||||
export default defineComponent({
|
||||
name: "DateMarker",
|
||||
props: {
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
focused: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
|
||||
|
||||
const hoursPassed = () => {
|
||||
return (Date.now() - Date.parse(props.message.time.toString())) / 3600000;
|
||||
};
|
||||
|
||||
const dayChange = () => {
|
||||
if (hoursPassed() >= 48) {
|
||||
eventbus.off("daychange", dayChange);
|
||||
}
|
||||
};
|
||||
|
||||
const friendlyDate = () => {
|
||||
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
||||
return dayjs(props.message.time).calendar(null, {
|
||||
sameDay: "[Today]",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "D MMMM YYYY",
|
||||
sameElse: "D MMMM YYYY",
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (hoursPassed() < 48) {
|
||||
eventbus.on("daychange", dayChange);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventbus.off("daychange", dayChange);
|
||||
});
|
||||
|
||||
return {
|
||||
localeDate,
|
||||
friendlyDate,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
120
client/components/Draggable.vue
Normal file
120
client/components/Draggable.vue
Normal file
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div ref="containerRef" :class="$props.class">
|
||||
<slot
|
||||
v-for="(item, index) of list"
|
||||
:key="item[itemKey]"
|
||||
:element="item"
|
||||
:index="index"
|
||||
name="item"
|
||||
></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, PropType, watch, onUnmounted, onBeforeUnmount} from "vue";
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
const Props = {
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: false,
|
||||
},
|
||||
delayOnTouchOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
touchStartThreshold: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
required: false,
|
||||
},
|
||||
handle: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
draggable: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
ghostClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
dragClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
group: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
class: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
itemKey: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: true,
|
||||
},
|
||||
list: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: [],
|
||||
required: true,
|
||||
},
|
||||
filter: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "Draggable",
|
||||
props: Props,
|
||||
emits: ["change", "choose", "unchoose"],
|
||||
setup(props, {emit}) {
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const sortable = ref<Sortable | null>(null);
|
||||
|
||||
watch(containerRef, (newDraggable) => {
|
||||
if (newDraggable) {
|
||||
sortable.value = new Sortable(newDraggable, {
|
||||
...props,
|
||||
|
||||
onChoose(event) {
|
||||
emit("choose", event);
|
||||
},
|
||||
|
||||
onUnchoose(event) {
|
||||
emit("unchoose", event);
|
||||
},
|
||||
|
||||
onEnd(event) {
|
||||
emit("change", event);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (sortable.value) {
|
||||
sortable.value.destroy();
|
||||
containerRef.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
478
client/components/ImageViewer.vue
Normal file
478
client/components/ImageViewer.vue
Normal file
|
@ -0,0 +1,478 @@
|
|||
<template>
|
||||
<div
|
||||
id="image-viewer"
|
||||
ref="viewer"
|
||||
:class="{opened: link !== null}"
|
||||
@wheel="onMouseWheel"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="link !== null">
|
||||
<button class="close-btn" aria-label="Close"></button>
|
||||
|
||||
<button
|
||||
v-if="previousImage"
|
||||
class="previous-image-btn"
|
||||
aria-label="Previous image"
|
||||
@click.stop="previous"
|
||||
></button>
|
||||
<button
|
||||
v-if="nextImage"
|
||||
class="next-image-btn"
|
||||
aria-label="Next image"
|
||||
@click.stop="next"
|
||||
></button>
|
||||
|
||||
<a class="open-btn" :href="link.link" target="_blank" rel="noopener"></a>
|
||||
|
||||
<img
|
||||
ref="image"
|
||||
:src="link.thumb"
|
||||
alt=""
|
||||
:style="computeImageStyles"
|
||||
@load="onImageLoad"
|
||||
@mousedown="onImageMouseDown"
|
||||
@touchstart.passive="onImageTouchStart"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Mousetrap from "mousetrap";
|
||||
import {computed, defineComponent, ref, watch} from "vue";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {ClientChan, ClientLinkPreview} from "../js/types";
|
||||
import {SharedMsg} from "../../shared/types/msg";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ImageViewer",
|
||||
setup() {
|
||||
const viewer = ref<HTMLDivElement>();
|
||||
const image = ref<HTMLImageElement>();
|
||||
|
||||
const link = ref<ClientLinkPreview | null>(null);
|
||||
const previousImage = ref<ClientLinkPreview | null>();
|
||||
const nextImage = ref<ClientLinkPreview | null>();
|
||||
const channel = ref<ClientChan | null>();
|
||||
|
||||
const position = ref<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const transform = ref<{
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}>({
|
||||
scale: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const computeImageStyles = computed(() => {
|
||||
// Sub pixels may cause the image to blur in certain browsers
|
||||
// round it down to prevent that
|
||||
const transformX = Math.floor(transform.value.x);
|
||||
const transformY = Math.floor(transform.value.y);
|
||||
|
||||
return {
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${transform.value.scale}, ${transform.value.scale}, 1)`,
|
||||
};
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
if (link.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
channel.value = null;
|
||||
previousImage.value = null;
|
||||
nextImage.value = null;
|
||||
link.value = null;
|
||||
};
|
||||
|
||||
const setPrevNextImages = () => {
|
||||
if (!channel.value || !link.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const links = channel.value.messages
|
||||
.map((msg: SharedMsg) => msg.previews)
|
||||
.flat()
|
||||
.filter((preview) => preview && preview.thumb);
|
||||
|
||||
const currentIndex = links.indexOf(link.value);
|
||||
|
||||
previousImage.value = links[currentIndex - 1] || null;
|
||||
nextImage.value = links[currentIndex + 1] || null;
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (previousImage.value) {
|
||||
link.value = previousImage.value;
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (nextImage.value) {
|
||||
link.value = nextImage.value;
|
||||
}
|
||||
};
|
||||
|
||||
const prepareImage = () => {
|
||||
const viewerEl = viewer.value;
|
||||
const imageEl = image.value;
|
||||
|
||||
if (!viewerEl || !imageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = viewerEl.offsetWidth;
|
||||
const height = viewerEl.offsetHeight;
|
||||
const scale = Math.min(1, width / imageEl.width, height / imageEl.height);
|
||||
|
||||
position.value.x = Math.floor(-image.value!.naturalWidth / 2);
|
||||
position.value.y = Math.floor(-image.value!.naturalHeight / 2);
|
||||
transform.value.scale = Math.max(scale, 0.1);
|
||||
transform.value.x = width / 2;
|
||||
transform.value.y = height / 2;
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
prepareImage();
|
||||
};
|
||||
|
||||
const calculateZoomShift = (newScale: number, x: number, y: number, oldScale: number) => {
|
||||
if (!image.value || !viewer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = image.value.width;
|
||||
const centerX = viewer.value.offsetWidth / 2;
|
||||
const centerY = viewer.value.offsetHeight / 2;
|
||||
|
||||
return {
|
||||
x:
|
||||
centerX -
|
||||
((centerX - (y - (imageWidth * x) / 2)) / x) * newScale +
|
||||
(imageWidth * newScale) / 2,
|
||||
y:
|
||||
centerY -
|
||||
((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale +
|
||||
(imageWidth * newScale) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const correctPosition = () => {
|
||||
const imageEl = image.value;
|
||||
const viewerEl = viewer.value;
|
||||
|
||||
if (!imageEl || !viewerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widthScaled = imageEl.width * transform.value.scale;
|
||||
const heightScaled = imageEl.height * transform.value.scale;
|
||||
const containerWidth = viewerEl.offsetWidth;
|
||||
const containerHeight = viewerEl.offsetHeight;
|
||||
|
||||
if (widthScaled < containerWidth) {
|
||||
transform.value.x = containerWidth / 2;
|
||||
} else if (transform.value.x - widthScaled / 2 > 0) {
|
||||
transform.value.x = widthScaled / 2;
|
||||
} else if (transform.value.x + widthScaled / 2 < containerWidth) {
|
||||
transform.value.x = containerWidth - widthScaled / 2;
|
||||
}
|
||||
|
||||
if (heightScaled < containerHeight) {
|
||||
transform.value.y = containerHeight / 2;
|
||||
} else if (transform.value.y - heightScaled / 2 > 0) {
|
||||
transform.value.y = heightScaled / 2;
|
||||
} else if (transform.value.y + heightScaled / 2 < containerHeight) {
|
||||
transform.value.y = containerHeight - heightScaled / 2;
|
||||
}
|
||||
};
|
||||
|
||||
// Reduce multiple touch points into a single x/y/scale
|
||||
const reduceTouches = (touches: TouchList) => {
|
||||
let totalX = 0;
|
||||
let totalY = 0;
|
||||
let totalScale = 0;
|
||||
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
const x = touches[i].clientX;
|
||||
const y = touches[i].clientY;
|
||||
|
||||
totalX += x;
|
||||
totalY += y;
|
||||
|
||||
for (let i2 = 0; i2 < touches.length; i2++) {
|
||||
if (i !== i2) {
|
||||
const x2 = touches[i2].clientX;
|
||||
const y2 = touches[i2].clientY;
|
||||
totalScale += Math.sqrt((x - x2) * (x - x2) + (y - y2) * (y - y2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalScale === 0) {
|
||||
totalScale = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
x: totalX / touches.length,
|
||||
y: totalY / touches.length,
|
||||
scale: totalScale / touches.length,
|
||||
};
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
// prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
// Touch image manipulation:
|
||||
// 1. Move around by dragging it with one finger
|
||||
// 2. Change image scale by using two fingers
|
||||
const onImageTouchStart = (e: TouchEvent) => {
|
||||
const img = image.value;
|
||||
let touch = reduceTouches(e.touches);
|
||||
let currentTouches = e.touches;
|
||||
let touchEndFingers = 0;
|
||||
|
||||
const currentTransform = {
|
||||
x: touch.x,
|
||||
y: touch.y,
|
||||
scale: touch.scale,
|
||||
};
|
||||
|
||||
const startTransform = {
|
||||
x: transform.value.x,
|
||||
y: transform.value.y,
|
||||
scale: transform.value.scale,
|
||||
};
|
||||
|
||||
const touchMove = (moveEvent) => {
|
||||
touch = reduceTouches(moveEvent.touches);
|
||||
|
||||
if (currentTouches.length !== moveEvent.touches.length) {
|
||||
currentTransform.x = touch.x;
|
||||
currentTransform.y = touch.y;
|
||||
currentTransform.scale = touch.scale;
|
||||
startTransform.x = transform.value.x;
|
||||
startTransform.y = transform.value.y;
|
||||
startTransform.scale = transform.value.scale;
|
||||
}
|
||||
|
||||
const deltaX = touch.x - currentTransform.x;
|
||||
const deltaY = touch.y - currentTransform.y;
|
||||
const deltaScale = touch.scale / currentTransform.scale;
|
||||
currentTouches = moveEvent.touches;
|
||||
touchEndFingers = 0;
|
||||
|
||||
const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale));
|
||||
|
||||
const fixedPosition = calculateZoomShift(
|
||||
newScale,
|
||||
startTransform.scale,
|
||||
startTransform.x,
|
||||
startTransform.y
|
||||
);
|
||||
|
||||
if (!fixedPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
transform.value.x = fixedPosition.x + deltaX;
|
||||
transform.value.y = fixedPosition.y + deltaY;
|
||||
transform.value.scale = newScale;
|
||||
correctPosition();
|
||||
};
|
||||
|
||||
const touchEnd = (endEvent: TouchEvent) => {
|
||||
const changedTouches = endEvent.changedTouches.length;
|
||||
|
||||
if (currentTouches.length > changedTouches + touchEndFingers) {
|
||||
touchEndFingers += changedTouches;
|
||||
return;
|
||||
}
|
||||
|
||||
// todo: this is swipe to close, but it's not working very well due to unfinished delta calculation
|
||||
/* if (
|
||||
transform.value.scale <= 1 &&
|
||||
endEvent.changedTouches[0].clientY - startTransform.y <= -70
|
||||
) {
|
||||
return this.closeViewer();
|
||||
}*/
|
||||
|
||||
correctPosition();
|
||||
|
||||
img?.removeEventListener("touchmove", touchMove);
|
||||
img?.removeEventListener("touchend", touchEnd);
|
||||
};
|
||||
|
||||
img?.addEventListener("touchmove", touchMove, {passive: true});
|
||||
img?.addEventListener("touchend", touchEnd, {passive: true});
|
||||
};
|
||||
|
||||
// Image mouse manipulation:
|
||||
// 1. Mouse wheel scrolling will zoom in and out
|
||||
// 2. If image is zoomed in, simply dragging it will move it around
|
||||
const onImageMouseDown = (e: MouseEvent) => {
|
||||
// todo: ignore if in touch event currently?
|
||||
|
||||
// only left mouse
|
||||
// TODO: e.buttons?
|
||||
if (e.which !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const viewerEl = viewer.value;
|
||||
const imageEl = image.value;
|
||||
|
||||
if (!viewerEl || !imageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startTransformX = transform.value.x;
|
||||
const startTransformY = transform.value.y;
|
||||
const widthScaled = imageEl.width * transform.value.scale;
|
||||
const heightScaled = imageEl.height * transform.value.scale;
|
||||
const containerWidth = viewerEl.offsetWidth;
|
||||
const containerHeight = viewerEl.offsetHeight;
|
||||
const centerX = transform.value.x - widthScaled / 2;
|
||||
const centerY = transform.value.y - heightScaled / 2;
|
||||
let movedDistance = 0;
|
||||
|
||||
const mouseMove = (moveEvent: MouseEvent) => {
|
||||
moveEvent.stopPropagation();
|
||||
moveEvent.preventDefault();
|
||||
|
||||
const newX = moveEvent.clientX - startX;
|
||||
const newY = moveEvent.clientY - startY;
|
||||
|
||||
movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY));
|
||||
|
||||
if (centerX < 0 || widthScaled + centerX > containerWidth) {
|
||||
transform.value.x = startTransformX + newX;
|
||||
}
|
||||
|
||||
if (centerY < 0 || heightScaled + centerY > containerHeight) {
|
||||
transform.value.y = startTransformY + newY;
|
||||
}
|
||||
|
||||
correctPosition();
|
||||
};
|
||||
|
||||
const mouseUp = (upEvent: MouseEvent) => {
|
||||
correctPosition();
|
||||
|
||||
if (movedDistance < 2 && upEvent.button === 0) {
|
||||
closeViewer();
|
||||
}
|
||||
|
||||
image.value?.removeEventListener("mousemove", mouseMove);
|
||||
image.value?.removeEventListener("mouseup", mouseUp);
|
||||
};
|
||||
|
||||
image.value?.addEventListener("mousemove", mouseMove);
|
||||
image.value?.addEventListener("mouseup", mouseUp);
|
||||
};
|
||||
|
||||
// If image is zoomed in, holding ctrl while scrolling will move the image up and down
|
||||
const onMouseWheel = (e: WheelEvent) => {
|
||||
// if image viewer is closing (css animation), you can still trigger mousewheel
|
||||
// TODO: Figure out a better fix for this
|
||||
if (link.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault(); // TODO: Can this be passive?
|
||||
|
||||
if (e.ctrlKey) {
|
||||
transform.value.y += e.deltaY;
|
||||
} else {
|
||||
const delta = e.deltaY > 0 ? 0.1 : -0.1;
|
||||
const newScale = Math.min(3, Math.max(0.1, transform.value.scale + delta));
|
||||
const fixedPosition = calculateZoomShift(
|
||||
newScale,
|
||||
transform.value.scale,
|
||||
transform.value.x,
|
||||
transform.value.y
|
||||
);
|
||||
|
||||
if (!fixedPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
transform.value.scale = newScale;
|
||||
transform.value.x = fixedPosition.x;
|
||||
transform.value.y = fixedPosition.y;
|
||||
}
|
||||
|
||||
correctPosition();
|
||||
};
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
// If click triggers on the image, ignore it
|
||||
if (e.target === image.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeViewer();
|
||||
};
|
||||
|
||||
watch(link, (newLink, oldLink) => {
|
||||
// TODO: history.pushState
|
||||
if (newLink === null) {
|
||||
eventbus.off("escapekey", closeViewer);
|
||||
eventbus.off("resize", correctPosition);
|
||||
Mousetrap.unbind("left");
|
||||
Mousetrap.unbind("right");
|
||||
return;
|
||||
}
|
||||
|
||||
setPrevNextImages();
|
||||
|
||||
if (!oldLink) {
|
||||
eventbus.on("escapekey", closeViewer);
|
||||
eventbus.on("resize", correctPosition);
|
||||
Mousetrap.bind("left", previous);
|
||||
Mousetrap.bind("right", next);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
link,
|
||||
channel,
|
||||
image,
|
||||
transform,
|
||||
closeViewer,
|
||||
next,
|
||||
previous,
|
||||
onImageLoad,
|
||||
onImageMouseDown,
|
||||
onMouseWheel,
|
||||
onClick,
|
||||
onTouchStart,
|
||||
previousImage,
|
||||
nextImage,
|
||||
onImageTouchStart,
|
||||
computeImageStyles,
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
35
client/components/InlineChannel.vue
Normal file
35
client/components/InlineChannel.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<span
|
||||
class="inline-channel"
|
||||
dir="auto"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.prevent="openContextMenu"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
><slot></slot
|
||||
></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import eventbus from "../js/eventbus";
|
||||
|
||||
export default defineComponent({
|
||||
name: "InlineChannel",
|
||||
props: {
|
||||
channel: String,
|
||||
},
|
||||
setup(props) {
|
||||
const openContextMenu = (event) => {
|
||||
eventbus.emit("contextmenu:inline-channel", {
|
||||
event: event,
|
||||
channel: props.channel,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
openContextMenu,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
93
client/components/JoinChannel.vue
Normal file
93
client/components/JoinChannel.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<form
|
||||
:id="'join-channel-' + channel.id"
|
||||
class="join-form"
|
||||
method="post"
|
||||
action=""
|
||||
autocomplete="off"
|
||||
@keydown.esc.prevent="$emit('toggle-join-channel')"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<input
|
||||
v-model="inputChannel"
|
||||
v-focus
|
||||
type="text"
|
||||
class="input"
|
||||
name="channel"
|
||||
placeholder="Channel"
|
||||
pattern="[^\s]+"
|
||||
maxlength="200"
|
||||
title="The channel name may not contain spaces"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="inputPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
name="key"
|
||||
placeholder="Password (optional)"
|
||||
pattern="[^\s]+"
|
||||
maxlength="200"
|
||||
title="The channel password may not contain spaces"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="submit" class="btn btn-small">Join</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType, ref} from "vue";
|
||||
import {switchToChannel} from "../js/router";
|
||||
import socket from "../js/socket";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientNetwork, ClientChan} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "JoinChannel",
|
||||
directives: {
|
||||
focus: {
|
||||
mounted: (el: HTMLFormElement) => el.focus(),
|
||||
},
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
emits: ["toggle-join-channel"],
|
||||
setup(props, {emit}) {
|
||||
const store = useStore();
|
||||
const inputChannel = ref("");
|
||||
const inputPassword = ref("");
|
||||
|
||||
const onSubmit = () => {
|
||||
const existingChannel = store.getters.findChannelOnCurrentNetwork(inputChannel.value);
|
||||
|
||||
if (existingChannel) {
|
||||
switchToChannel(existingChannel);
|
||||
} else {
|
||||
const chanTypes = props.network.serverOptions.CHANTYPES;
|
||||
let channel = inputChannel.value;
|
||||
|
||||
if (chanTypes && chanTypes.length > 0 && !chanTypes.includes(channel[0])) {
|
||||
channel = chanTypes[0] + channel;
|
||||
}
|
||||
|
||||
socket.emit("input", {
|
||||
text: `/join ${channel} ${inputPassword.value}`,
|
||||
target: props.channel.id,
|
||||
});
|
||||
}
|
||||
|
||||
inputChannel.value = "";
|
||||
inputPassword.value = "";
|
||||
emit("toggle-join-channel");
|
||||
};
|
||||
|
||||
return {
|
||||
inputChannel,
|
||||
inputPassword,
|
||||
onSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
329
client/components/LinkPreview.vue
Normal file
329
client/components/LinkPreview.vue
Normal file
|
@ -0,0 +1,329 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="link.shown"
|
||||
v-show="link.sourceLoaded || link.type === 'link'"
|
||||
ref="container"
|
||||
class="preview"
|
||||
dir="ltr"
|
||||
>
|
||||
<div
|
||||
ref="content"
|
||||
:class="['toggle-content', 'toggle-type-' + link.type, {opened: isContentShown}]"
|
||||
>
|
||||
<template v-if="link.type === 'link'">
|
||||
<a
|
||||
v-if="link.thumb"
|
||||
v-show="link.sourceLoaded"
|
||||
:href="link.link"
|
||||
class="toggle-thumbnail"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="onThumbnailClick"
|
||||
>
|
||||
<img
|
||||
:src="link.thumb"
|
||||
decoding="async"
|
||||
alt=""
|
||||
class="thumb"
|
||||
@error="onThumbnailError"
|
||||
@abort="onThumbnailError"
|
||||
@load="onPreviewReady"
|
||||
/>
|
||||
</a>
|
||||
<div class="toggle-text" dir="auto">
|
||||
<div class="head">
|
||||
<div class="overflowable">
|
||||
<a
|
||||
:href="link.link"
|
||||
:title="link.head"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ link.head }}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showMoreButton"
|
||||
:aria-expanded="isContentShown"
|
||||
:aria-label="moreButtonLabel"
|
||||
dir="auto"
|
||||
class="more"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<span class="more-caret" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body overflowable">
|
||||
<a :href="link.link" :title="link.body" target="_blank" rel="noopener">{{
|
||||
link.body
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'image'">
|
||||
<a
|
||||
:href="link.link"
|
||||
class="toggle-thumbnail"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click="onThumbnailClick"
|
||||
>
|
||||
<img
|
||||
v-show="link.sourceLoaded"
|
||||
:src="link.thumb"
|
||||
decoding="async"
|
||||
alt=""
|
||||
@load="onPreviewReady"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'video'">
|
||||
<video
|
||||
v-show="link.sourceLoaded"
|
||||
preload="metadata"
|
||||
controls
|
||||
@canplay="onPreviewReady"
|
||||
>
|
||||
<source :src="link.media" :type="link.mediaType" />
|
||||
</video>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'audio'">
|
||||
<audio
|
||||
v-show="link.sourceLoaded"
|
||||
controls
|
||||
preload="metadata"
|
||||
@canplay="onPreviewReady"
|
||||
>
|
||||
<source :src="link.media" :type="link.mediaType" />
|
||||
</audio>
|
||||
</template>
|
||||
<template v-else-if="link.type === 'error'">
|
||||
<em v-if="link.error === 'image-too-big'">
|
||||
This image is larger than {{ imageMaxSize }} and cannot be previewed.
|
||||
<a :href="link.link" target="_blank" rel="noopener">Click here</a>
|
||||
to open it in a new window.
|
||||
</em>
|
||||
<template v-else-if="link.error === 'message'">
|
||||
<div>
|
||||
<em>
|
||||
A preview could not be loaded.
|
||||
<a :href="link.link" target="_blank" rel="noopener">Click here</a>
|
||||
to open it in a new window.
|
||||
</em>
|
||||
<br />
|
||||
<pre class="prefetch-error">{{ link.message }}</pre>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:aria-expanded="isContentShown"
|
||||
:aria-label="moreButtonLabel"
|
||||
class="more"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<span class="more-caret" />
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
inject,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
PropType,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import {onBeforeRouteUpdate} from "vue-router";
|
||||
import eventbus from "../js/eventbus";
|
||||
import friendlysize from "../js/helpers/friendlysize";
|
||||
import {useStore} from "../js/store";
|
||||
import type {ClientChan, ClientLinkPreview} from "../js/types";
|
||||
import {imageViewerKey} from "./App.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "LinkPreview",
|
||||
props: {
|
||||
link: {
|
||||
type: Object as PropType<ClientLinkPreview>,
|
||||
required: true,
|
||||
},
|
||||
keepScrollPosition: {
|
||||
type: Function as PropType<() => void>,
|
||||
required: true,
|
||||
},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
const showMoreButton = ref(false);
|
||||
const isContentShown = ref(false);
|
||||
const imageViewer = inject(imageViewerKey);
|
||||
|
||||
onBeforeRouteUpdate((to, from, next) => {
|
||||
// cancel the navigation if the user is trying to close the image viewer
|
||||
if (imageViewer?.value?.link) {
|
||||
imageViewer.value.closeViewer();
|
||||
return next(false);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const content = ref<HTMLDivElement | null>(null);
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const moreButtonLabel = computed(() => {
|
||||
return isContentShown.value ? "Less" : "More";
|
||||
});
|
||||
|
||||
const imageMaxSize = computed(() => {
|
||||
if (!props.link.maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
return friendlysize(props.link.maxSize);
|
||||
});
|
||||
|
||||
const handleResize = () => {
|
||||
nextTick(() => {
|
||||
if (!content.value || !container.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error in LinkPreview.handleResize", e);
|
||||
});
|
||||
};
|
||||
|
||||
const onPreviewReady = () => {
|
||||
props.link.sourceLoaded = true;
|
||||
|
||||
props.keepScrollPosition();
|
||||
|
||||
if (props.link.type === "link") {
|
||||
handleResize();
|
||||
}
|
||||
};
|
||||
|
||||
const onPreviewUpdate = () => {
|
||||
// Don't display previews while they are loading on the server
|
||||
if (props.link.type === "loading") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Error does not have any media to render
|
||||
if (props.link.type === "error") {
|
||||
onPreviewReady();
|
||||
}
|
||||
|
||||
// If link doesn't have a thumbnail, render it
|
||||
if (props.link.type === "link") {
|
||||
handleResize();
|
||||
props.keepScrollPosition();
|
||||
}
|
||||
};
|
||||
|
||||
const onThumbnailError = () => {
|
||||
// If thumbnail fails to load, hide it and show the preview without it
|
||||
props.link.thumb = "";
|
||||
onPreviewReady();
|
||||
};
|
||||
|
||||
const onThumbnailClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!imageViewer?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageViewer.value.channel = props.channel;
|
||||
imageViewer.value.link = props.link;
|
||||
};
|
||||
|
||||
const onMoreClick = () => {
|
||||
isContentShown.value = !isContentShown.value;
|
||||
props.keepScrollPosition();
|
||||
};
|
||||
|
||||
const updateShownState = () => {
|
||||
// User has manually toggled the preview, do not apply default
|
||||
if (props.link.shown !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultState = false;
|
||||
|
||||
switch (props.link.type) {
|
||||
case "error":
|
||||
// Collapse all errors by default unless its a message about image being too big
|
||||
if (props.link.error === "image-too-big") {
|
||||
defaultState = store.state.settings.media;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "link":
|
||||
defaultState = store.state.settings.links;
|
||||
break;
|
||||
|
||||
default:
|
||||
defaultState = store.state.settings.media;
|
||||
}
|
||||
|
||||
props.link.shown = defaultState;
|
||||
};
|
||||
|
||||
updateShownState();
|
||||
|
||||
watch(
|
||||
() => props.link.type,
|
||||
() => {
|
||||
updateShownState();
|
||||
onPreviewUpdate();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("resize", handleResize);
|
||||
|
||||
onPreviewUpdate();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventbus.off("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Let this preview go through load/canplay events again,
|
||||
// Otherwise the browser can cause a resize on video elements
|
||||
props.link.sourceLoaded = false;
|
||||
});
|
||||
|
||||
return {
|
||||
moreButtonLabel,
|
||||
imageMaxSize,
|
||||
onThumbnailClick,
|
||||
onThumbnailError,
|
||||
onMoreClick,
|
||||
onPreviewReady,
|
||||
onPreviewUpdate,
|
||||
showMoreButton,
|
||||
isContentShown,
|
||||
content,
|
||||
container,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
22
client/components/LinkPreviewFileSize.vue
Normal file
22
client/components/LinkPreviewFileSize.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<span class="preview-size">({{ previewSize }})</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import friendlysize from "../js/helpers/friendlysize";
|
||||
|
||||
export default defineComponent({
|
||||
name: "LinkPreviewFileSize",
|
||||
props: {
|
||||
size: {type: Number, required: true},
|
||||
},
|
||||
setup(props) {
|
||||
const previewSize = friendlysize(props.size);
|
||||
|
||||
return {
|
||||
previewSize,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
37
client/components/LinkPreviewToggle.vue
Normal file
37
client/components/LinkPreviewToggle.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<button
|
||||
v-if="link.type !== 'loading'"
|
||||
:class="['toggle-button', 'toggle-preview', {opened: link.shown}]"
|
||||
:aria-label="ariaLabel"
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientMessage, ClientLinkPreview} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "LinkPreviewToggle",
|
||||
props: {
|
||||
link: {type: Object as PropType<ClientLinkPreview>, required: true},
|
||||
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||
},
|
||||
emits: ["toggle-link-preview"],
|
||||
setup(props, {emit}) {
|
||||
const ariaLabel = computed(() => {
|
||||
return props.link.shown ? "Collapse preview" : "Expand preview";
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
props.link.shown = !props.link.shown;
|
||||
emit("toggle-link-preview", props.link, props.message);
|
||||
};
|
||||
|
||||
return {
|
||||
ariaLabel,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
247
client/components/Mentions.vue
Normal file
247
client/components/Mentions.vue
Normal file
|
@ -0,0 +1,247 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
id="mentions-popup-container"
|
||||
@click="containerClick"
|
||||
@contextmenu="containerClick"
|
||||
>
|
||||
<div class="mentions-popup">
|
||||
<div class="mentions-popup-title">
|
||||
Recent mentions
|
||||
<button
|
||||
v-if="resolvedMessages.length"
|
||||
class="btn dismiss-all-mentions"
|
||||
@click="dismissAllMentions()"
|
||||
>
|
||||
Dismiss all
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="resolvedMessages.length === 0">
|
||||
<p v-if="isLoading">Loading…</p>
|
||||
<p v-else>You have no recent mentions.</p>
|
||||
</template>
|
||||
<template v-for="message in resolvedMessages" v-else :key="message.msgId">
|
||||
<div :class="['msg', message.type]">
|
||||
<div class="mentions-info">
|
||||
<div>
|
||||
<span class="from">
|
||||
<Username :user="(message.from as any)" />
|
||||
<template v-if="message.channel">
|
||||
in {{ message.channel.channel.name }} on
|
||||
{{ message.channel.network.name }}
|
||||
</template>
|
||||
<template v-else> in unknown channel </template> </span
|
||||
>{{ ` ` }}
|
||||
<span :title="message.localetime" class="time">
|
||||
{{ messageTime(message.time.toString()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="close-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Dismiss this mention"
|
||||
>
|
||||
<button
|
||||
class="msg-dismiss"
|
||||
aria-label="Dismiss this mention"
|
||||
@click="dismissMention(message)"
|
||||
></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" dir="auto">
|
||||
<ParsedMessage :message="(message as any)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#mentions-popup-container {
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.mentions-popup {
|
||||
background-color: var(--window-bg-color);
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
right: 80px;
|
||||
top: 55px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mentions-popup > .mentions-popup-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.mentions-popup .mentions-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mentions-popup .msg {
|
||||
margin-bottom: 15px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.mentions-popup .msg:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mentions-popup .msg .content {
|
||||
background-color: var(--highlight-bg-color);
|
||||
border-radius: 5px;
|
||||
padding: 6px;
|
||||
margin-top: 2px;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word; /* Webkit-specific */
|
||||
}
|
||||
|
||||
.mentions-popup .msg-dismiss::before {
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
content: "×";
|
||||
}
|
||||
|
||||
.mentions-popup .msg-dismiss:hover {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.mentions-popup .dismiss-all-mentions {
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
@media (min-height: 500px) {
|
||||
.mentions-popup {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mentions-popup {
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
top: 45px; /* header height */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import Username from "./Username.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import socket from "../js/socket";
|
||||
import eventbus from "../js/eventbus";
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import {computed, watch, defineComponent, ref, onMounted, onUnmounted} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientMention} from "../js/types";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default defineComponent({
|
||||
name: "Mentions",
|
||||
components: {
|
||||
Username,
|
||||
ParsedMessage,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isOpen = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const resolvedMessages = computed(() => {
|
||||
const messages = store.state.mentions.slice().reverse();
|
||||
|
||||
for (const message of messages) {
|
||||
message.localetime = localetime(message.time);
|
||||
message.channel = store.getters.findChannel(message.chanId);
|
||||
}
|
||||
|
||||
return messages.filter((message) => !message.channel?.channel.muted);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.state.mentions,
|
||||
() => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const messageTime = (time: string) => {
|
||||
return dayjs(time).fromNow();
|
||||
};
|
||||
|
||||
const dismissMention = (message: ClientMention) => {
|
||||
store.state.mentions.splice(
|
||||
store.state.mentions.findIndex((m) => m.msgId === message.msgId),
|
||||
1
|
||||
);
|
||||
|
||||
socket.emit("mentions:dismiss", message.msgId);
|
||||
};
|
||||
|
||||
const dismissAllMentions = () => {
|
||||
store.state.mentions = [];
|
||||
socket.emit("mentions:dismiss_all");
|
||||
};
|
||||
|
||||
const containerClick = (event: Event) => {
|
||||
if (event.currentTarget === event.target) {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const togglePopup = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
|
||||
if (isOpen.value) {
|
||||
isLoading.value = true;
|
||||
socket.emit("mentions:get");
|
||||
}
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
eventbus.on("mentions:toggle", togglePopup);
|
||||
eventbus.on("escapekey", closePopup);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("mentions:toggle", togglePopup);
|
||||
eventbus.off("escapekey", closePopup);
|
||||
});
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isLoading,
|
||||
resolvedMessages,
|
||||
messageTime,
|
||||
dismissMention,
|
||||
dismissAllMentions,
|
||||
containerClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
173
client/components/Message.vue
Normal file
173
client/components/Message.vue
Normal file
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<div
|
||||
:id="'msg-' + message.id"
|
||||
:class="[
|
||||
'msg',
|
||||
{
|
||||
self: message.self,
|
||||
highlight: message.highlight || focused,
|
||||
'previous-source': isPreviousSource,
|
||||
},
|
||||
]"
|
||||
:data-type="message.type"
|
||||
:data-command="message.command"
|
||||
:data-from="message.from && message.from.nick"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:aria-label="messageTimeLocale"
|
||||
class="time tooltipped tooltipped-e"
|
||||
>{{ `${messageTime} ` }}
|
||||
</span>
|
||||
<template v-if="message.type === 'unhandled'">
|
||||
<span class="from">[{{ message.command }}]</span>
|
||||
<span class="content">
|
||||
<span v-for="(param, id) in message.params" :key="id">{{
|
||||
` ${param} `
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isAction()">
|
||||
<span class="from"><span class="only-copy">*** </span></span>
|
||||
<component :is="messageComponent" :network="network" :message="message" />
|
||||
</template>
|
||||
<template v-else-if="message.type === 'action'">
|
||||
<span class="from"><span class="only-copy">* </span></span>
|
||||
<span class="content" dir="auto">
|
||||
<Username
|
||||
:user="message.from"
|
||||
:network="network"
|
||||
:channel="channel"
|
||||
dir="auto"
|
||||
/> <ParsedMessage :message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:link="preview"
|
||||
:channel="channel"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="message.type === 'message'" class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy" aria-hidden="true"><</span>
|
||||
<Username :user="message.from" :network="network" :channel="channel" />
|
||||
<span class="only-copy" aria-hidden="true">> </span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else-if="message.type === 'plugin'" class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy" aria-hidden="true">[</span>
|
||||
{{ message.from.nick }}
|
||||
<span class="only-copy" aria-hidden="true">] </span>
|
||||
</template>
|
||||
</span>
|
||||
<span v-else class="from">
|
||||
<template v-if="message.from && message.from.nick">
|
||||
<span class="only-copy" aria-hidden="true">-</span>
|
||||
<Username :user="message.from" :network="network" :channel="channel" />
|
||||
<span class="only-copy" aria-hidden="true">- </span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="content" dir="auto">
|
||||
<span
|
||||
v-if="message.showInActive"
|
||||
aria-label="This message was shown in your active channel"
|
||||
class="msg-shown-in-active tooltipped tooltipped-e"
|
||||
><span></span
|
||||
></span>
|
||||
<span
|
||||
v-if="message.statusmsgGroup"
|
||||
:aria-label="`This message was only shown to users with ${message.statusmsgGroup} mode`"
|
||||
class="msg-statusmsg tooltipped tooltipped-e"
|
||||
><span>{{ message.statusmsgGroup }}</span></span
|
||||
>
|
||||
<ParsedMessage :network="network" :message="message" />
|
||||
<LinkPreview
|
||||
v-for="preview in message.previews"
|
||||
:key="preview.link"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:link="preview"
|
||||
:channel="channel"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import constants from "../js/constants";
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import Username from "./Username.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import ParsedMessage from "./ParsedMessage.vue";
|
||||
import MessageTypes from "./MessageTypes";
|
||||
|
||||
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
MessageTypes.ParsedMessage = ParsedMessage;
|
||||
MessageTypes.LinkPreview = LinkPreview;
|
||||
MessageTypes.Username = Username;
|
||||
|
||||
export default defineComponent({
|
||||
name: "Message",
|
||||
components: MessageTypes,
|
||||
props: {
|
||||
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: false},
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
keepScrollPosition: Function as PropType<() => void>,
|
||||
isPreviousSource: Boolean,
|
||||
focused: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
const timeFormat = computed(() => {
|
||||
let format: keyof typeof constants.timeFormats;
|
||||
|
||||
if (store.state.settings.use12hClock) {
|
||||
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
|
||||
} else {
|
||||
format = store.state.settings.showSeconds ? "msgWithSeconds" : "msgDefault";
|
||||
}
|
||||
|
||||
return constants.timeFormats[format];
|
||||
});
|
||||
|
||||
const messageTime = computed(() => {
|
||||
return dayjs(props.message.time).format(timeFormat.value);
|
||||
});
|
||||
|
||||
const messageTimeLocale = computed(() => {
|
||||
return localetime(props.message.time);
|
||||
});
|
||||
|
||||
const messageComponent = computed(() => {
|
||||
return "message-" + (props.message.type || "invalid"); // TODO: force existence of type in sharedmsg
|
||||
});
|
||||
|
||||
const isAction = () => {
|
||||
if (!props.message.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof MessageTypes["message-" + props.message.type] !== "undefined";
|
||||
};
|
||||
|
||||
return {
|
||||
timeFormat,
|
||||
messageTime,
|
||||
messageTimeLocale,
|
||||
messageComponent,
|
||||
isAction,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
165
client/components/MessageCondensed.vue
Normal file
165
client/components/MessageCondensed.vue
Normal file
|
@ -0,0 +1,165 @@
|
|||
<template>
|
||||
<div :class="['msg', {closed: isCollapsed}]" data-type="condensed">
|
||||
<div class="condensed-summary">
|
||||
<span class="time" />
|
||||
<span class="from" />
|
||||
<span class="content" @click="onCollapseClick"
|
||||
>{{ condensedText
|
||||
}}<button class="toggle-button" aria-label="Toggle status messages"
|
||||
/></span>
|
||||
</div>
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:network="network"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType, ref} from "vue";
|
||||
import {condensedTypes} from "../../shared/irc";
|
||||
import {MessageType} from "../../shared/types/msg";
|
||||
import {ClientMessage, ClientNetwork} from "../js/types";
|
||||
import Message from "./Message.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageCondensed",
|
||||
components: {
|
||||
Message,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
messages: {
|
||||
type: Array as PropType<ClientMessage[]>,
|
||||
required: true,
|
||||
},
|
||||
keepScrollPosition: {
|
||||
type: Function as PropType<() => void>,
|
||||
required: true,
|
||||
},
|
||||
focused: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
const isCollapsed = ref(true);
|
||||
|
||||
const onCollapseClick = () => {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
props.keepScrollPosition();
|
||||
};
|
||||
|
||||
const condensedText = computed(() => {
|
||||
const obj: Record<string, number> = {};
|
||||
|
||||
condensedTypes.forEach((type) => {
|
||||
obj[type] = 0;
|
||||
});
|
||||
|
||||
for (const message of props.messages) {
|
||||
// special case since one MODE message can change multiple modes
|
||||
if (message.type === MessageType.MODE) {
|
||||
// syntax: +vv-t maybe-some targets
|
||||
// we want the number of mode changes in the message, so count the
|
||||
// number of chars other than + and - before the first space
|
||||
const text = message.text ? message.text : "";
|
||||
const modeChangesCount = text
|
||||
.split(" ")[0]
|
||||
.split("")
|
||||
.filter((char) => char !== "+" && char !== "-").length;
|
||||
obj[message.type] += modeChangesCount;
|
||||
} else {
|
||||
if (!message.type) {
|
||||
/* eslint-disable no-console */
|
||||
console.log(`empty message type, this should not happen: ${message.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[message.type]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count quits as parts in condensed messages to reduce information density
|
||||
obj.part += obj.quit;
|
||||
|
||||
const strings: string[] = [];
|
||||
condensedTypes.forEach((type) => {
|
||||
if (obj[type]) {
|
||||
switch (type) {
|
||||
case "chghost":
|
||||
strings.push(
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1
|
||||
? " users have changed hostname"
|
||||
: " user has changed hostname")
|
||||
);
|
||||
break;
|
||||
case "join":
|
||||
strings.push(
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " users have joined" : " user has joined")
|
||||
);
|
||||
break;
|
||||
case "part":
|
||||
strings.push(
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " users have left" : " user has left")
|
||||
);
|
||||
break;
|
||||
case "nick":
|
||||
strings.push(
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1
|
||||
? " users have changed nick"
|
||||
: " user has changed nick")
|
||||
);
|
||||
break;
|
||||
case "kick":
|
||||
strings.push(
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " users were kicked" : " user was kicked")
|
||||
);
|
||||
break;
|
||||
case "mode":
|
||||
strings.push(
|
||||
String(obj[type]) +
|
||||
(obj[type] > 1 ? " modes were set" : " mode was set")
|
||||
);
|
||||
break;
|
||||
case "away":
|
||||
strings.push(
|
||||
"marked away " +
|
||||
(obj[type] > 1 ? String(obj[type]) + " times" : "once")
|
||||
);
|
||||
break;
|
||||
case "back":
|
||||
strings.push(
|
||||
"marked back " +
|
||||
(obj[type] > 1 ? String(obj[type]) + " times" : "once")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (strings.length) {
|
||||
let text = strings.pop();
|
||||
|
||||
if (strings.length) {
|
||||
text = strings.join(", ") + ", and " + text!;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
condensedText,
|
||||
onCollapseClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
440
client/components/MessageList.vue
Normal file
440
client/components/MessageList.vue
Normal file
|
@ -0,0 +1,440 @@
|
|||
<template>
|
||||
<div ref="chat" class="chat" tabindex="-1">
|
||||
<div v-show="channel.moreHistoryAvailable" class="show-more">
|
||||
<button
|
||||
ref="loadMoreButton"
|
||||
:disabled="channel.historyLoading || !store.state.isConnected"
|
||||
class="btn"
|
||||
@click="onShowMoreClick"
|
||||
>
|
||||
<span v-if="channel.historyLoading">Loading…</span>
|
||||
<span v-else>Show older messages</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="messages"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
@copy="onCopy"
|
||||
>
|
||||
<template v-for="(message, id) in condensedMessages">
|
||||
<DateMarker
|
||||
v-if="shouldDisplayDateMarker(message, id)"
|
||||
:key="message.id + '-date'"
|
||||
:message="message as any"
|
||||
:focused="message.id === focused"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldDisplayUnreadMarker(Number(message.id))"
|
||||
:key="message.id + '-unread'"
|
||||
class="unread-marker"
|
||||
>
|
||||
<span class="unread-marker-text" />
|
||||
</div>
|
||||
|
||||
<MessageCondensed
|
||||
v-if="message.type === 'condensed'"
|
||||
:key="message.messages[0].id"
|
||||
:network="network"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:messages="message.messages"
|
||||
:focused="message.id === focused"
|
||||
/>
|
||||
<Message
|
||||
v-else
|
||||
:key="message.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:message="message"
|
||||
:keep-scroll-position="keepScrollPosition"
|
||||
:is-previous-source="isPreviousSource(message, id)"
|
||||
:focused="message.id === focused"
|
||||
@toggle-link-preview="onLinkPreviewToggle"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {condensedTypes} from "../../shared/irc";
|
||||
import {ChanType} from "../../shared/types/chan";
|
||||
import {MessageType, SharedMsg} from "../../shared/types/msg";
|
||||
import eventbus from "../js/eventbus";
|
||||
import clipboard from "../js/clipboard";
|
||||
import socket from "../js/socket";
|
||||
import Message from "./Message.vue";
|
||||
import MessageCondensed from "./MessageCondensed.vue";
|
||||
import DateMarker from "./DateMarker.vue";
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onBeforeUpdate,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
PropType,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientChan, ClientMessage, ClientNetwork, ClientLinkPreview} from "../js/types";
|
||||
|
||||
type CondensedMessageContainer = {
|
||||
type: "condensed";
|
||||
time: Date;
|
||||
messages: ClientMessage[];
|
||||
id?: number;
|
||||
};
|
||||
|
||||
// TODO; move into component
|
||||
let unreadMarkerShown = false;
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageList",
|
||||
components: {
|
||||
Message,
|
||||
MessageCondensed,
|
||||
DateMarker,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
focused: Number,
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
const chat = ref<HTMLDivElement | null>(null);
|
||||
const loadMoreButton = ref<HTMLButtonElement | null>(null);
|
||||
const historyObserver = ref<IntersectionObserver | null>(null);
|
||||
const skipNextScrollEvent = ref(false);
|
||||
|
||||
const isWaitingForNextTick = ref(false);
|
||||
|
||||
const jumpToBottom = () => {
|
||||
skipNextScrollEvent.value = true;
|
||||
props.channel.scrolledToBottom = true;
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const onShowMoreClick = () => {
|
||||
if (!store.state.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastMessage = -1;
|
||||
|
||||
// Find the id of first message that isn't showInActive
|
||||
// If showInActive is set, this message is actually in another channel
|
||||
for (const message of props.channel.messages) {
|
||||
if (!message.showInActive) {
|
||||
lastMessage = message.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
props.channel.historyLoading = true;
|
||||
|
||||
socket.emit("more", {
|
||||
target: props.channel.id,
|
||||
lastId: lastMessage,
|
||||
condensed: store.state.settings.statusMessages !== "shown",
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadButtonObserved = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
onShowMoreClick();
|
||||
});
|
||||
};
|
||||
|
||||
nextTick(() => {
|
||||
if (!chat.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.IntersectionObserver) {
|
||||
historyObserver.value = new window.IntersectionObserver(onLoadButtonObserved, {
|
||||
root: chat.value,
|
||||
});
|
||||
}
|
||||
|
||||
jumpToBottom();
|
||||
}).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error in new IntersectionObserver", e);
|
||||
});
|
||||
|
||||
const condensedMessages = computed(() => {
|
||||
if (props.channel.type !== ChanType.CHANNEL && props.channel.type !== ChanType.QUERY) {
|
||||
return props.channel.messages;
|
||||
}
|
||||
|
||||
// If actions are hidden, just return a message list with them excluded
|
||||
if (store.state.settings.statusMessages === "hidden") {
|
||||
return props.channel.messages.filter(
|
||||
(message) => !condensedTypes.has(message.type || "")
|
||||
);
|
||||
}
|
||||
|
||||
// If actions are not condensed, just return raw message list
|
||||
if (store.state.settings.statusMessages !== "condensed") {
|
||||
return props.channel.messages;
|
||||
}
|
||||
|
||||
let lastCondensedContainer: CondensedMessageContainer | null = null;
|
||||
|
||||
const condensed: (ClientMessage | CondensedMessageContainer)[] = [];
|
||||
|
||||
for (const message of props.channel.messages) {
|
||||
// If this message is not condensable, or its an action affecting our user,
|
||||
// then just append the message to container and be done with it
|
||||
if (message.self || message.highlight || !condensedTypes.has(message.type || "")) {
|
||||
lastCondensedContainer = null;
|
||||
|
||||
condensed.push(message);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastCondensedContainer) {
|
||||
lastCondensedContainer = {
|
||||
time: message.time,
|
||||
type: "condensed",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
condensed.push(lastCondensedContainer);
|
||||
}
|
||||
|
||||
lastCondensedContainer!.messages.push(message);
|
||||
|
||||
// Set id of the condensed container to last message id,
|
||||
// which is required for the unread marker to work correctly
|
||||
lastCondensedContainer!.id = message.id;
|
||||
|
||||
// If this message is the unread boundary, create a split condensed container
|
||||
if (message.id === props.channel.firstUnread) {
|
||||
lastCondensedContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return condensed.map((message) => {
|
||||
// Skip condensing single messages, it doesn't save any
|
||||
// space but makes useful information harder to see
|
||||
if (message.type === "condensed" && message.messages.length === 1) {
|
||||
return message.messages[0];
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
});
|
||||
|
||||
const shouldDisplayDateMarker = (
|
||||
message: SharedMsg | CondensedMessageContainer,
|
||||
id: number
|
||||
) => {
|
||||
const previousMessage = condensedMessages.value[id - 1];
|
||||
|
||||
if (!previousMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldDate = new Date(previousMessage.time);
|
||||
const newDate = new Date(message.time);
|
||||
|
||||
return (
|
||||
oldDate.getDate() !== newDate.getDate() ||
|
||||
oldDate.getMonth() !== newDate.getMonth() ||
|
||||
oldDate.getFullYear() !== newDate.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const shouldDisplayUnreadMarker = (id: number) => {
|
||||
if (!unreadMarkerShown && id > props.channel.firstUnread) {
|
||||
unreadMarkerShown = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isPreviousSource = (currentMessage: ClientMessage, id: number) => {
|
||||
const previousMessage = condensedMessages.value[id - 1];
|
||||
return (
|
||||
previousMessage &&
|
||||
currentMessage.type === MessageType.MESSAGE &&
|
||||
previousMessage.type === MessageType.MESSAGE &&
|
||||
currentMessage.from &&
|
||||
previousMessage.from &&
|
||||
currentMessage.from.nick === previousMessage.from.nick
|
||||
);
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
if (chat.value) {
|
||||
clipboard(chat.value);
|
||||
}
|
||||
};
|
||||
|
||||
const keepScrollPosition = async () => {
|
||||
// If we are already waiting for the next tick to force scroll position,
|
||||
// we have no reason to perform more checks and set it again in the next tick
|
||||
if (isWaitingForNextTick.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.channel.scrolledToBottom) {
|
||||
if (props.channel.historyLoading) {
|
||||
const heightOld = el.scrollHeight - el.scrollTop;
|
||||
|
||||
isWaitingForNextTick.value = true;
|
||||
|
||||
await nextTick();
|
||||
|
||||
isWaitingForNextTick.value = false;
|
||||
skipNextScrollEvent.value = true;
|
||||
|
||||
el.scrollTop = el.scrollHeight - heightOld;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
isWaitingForNextTick.value = true;
|
||||
await nextTick();
|
||||
isWaitingForNextTick.value = false;
|
||||
|
||||
jumpToBottom();
|
||||
};
|
||||
|
||||
const onLinkPreviewToggle = async (preview: ClientLinkPreview, message: ClientMessage) => {
|
||||
await keepScrollPosition();
|
||||
|
||||
// Tell the server we're toggling so it remembers at page reload
|
||||
socket.emit("msg:preview:toggle", {
|
||||
target: props.channel.id,
|
||||
msgId: message.id,
|
||||
link: preview.link,
|
||||
shown: preview.shown,
|
||||
});
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
// Setting scrollTop also triggers scroll event
|
||||
// We don't want to perform calculations for that
|
||||
if (skipNextScrollEvent.value) {
|
||||
skipNextScrollEvent.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
// Keep message list scrolled to bottom on resize
|
||||
if (props.channel.scrolledToBottom) {
|
||||
jumpToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
chat.value?.addEventListener("scroll", handleScroll, {passive: true});
|
||||
|
||||
eventbus.on("resize", handleResize);
|
||||
|
||||
void nextTick(() => {
|
||||
if (historyObserver.value && loadMoreButton.value) {
|
||||
historyObserver.value.observe(loadMoreButton.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.channel.id,
|
||||
() => {
|
||||
props.channel.scrolledToBottom = true;
|
||||
|
||||
// Re-add the intersection observer to trigger the check again on channel switch
|
||||
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
|
||||
if (historyObserver.value && loadMoreButton.value) {
|
||||
historyObserver.value.unobserve(loadMoreButton.value);
|
||||
historyObserver.value.observe(loadMoreButton.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.messages,
|
||||
async () => {
|
||||
await keepScrollPosition();
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.channel.pendingMessage,
|
||||
async () => {
|
||||
// Keep the scroll stuck when input gets resized while typing
|
||||
await keepScrollPosition();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
unreadMarkerShown = false;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
eventbus.off("resize", handleResize);
|
||||
chat.value?.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (historyObserver.value) {
|
||||
historyObserver.value.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
chat,
|
||||
store,
|
||||
onShowMoreClick,
|
||||
loadMoreButton,
|
||||
onCopy,
|
||||
condensedMessages,
|
||||
shouldDisplayDateMarker,
|
||||
shouldDisplayUnreadMarker,
|
||||
keepScrollPosition,
|
||||
isPreviousSource,
|
||||
jumpToBottom,
|
||||
onLinkPreviewToggle,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
175
client/components/MessageSearchForm.vue
Normal file
175
client/components/MessageSearchForm.vue
Normal file
|
@ -0,0 +1,175 @@
|
|||
<template>
|
||||
<form :class="['message-search', {opened: searchOpened}]" @submit.prevent="searchMessages">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
ref="searchInputField"
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
name="search"
|
||||
class="input"
|
||||
placeholder="Search messages…"
|
||||
@blur="closeSearch"
|
||||
@keyup.esc="closeSearch"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="!onSearchPage"
|
||||
class="search"
|
||||
type="button"
|
||||
aria-label="Search messages in this channel"
|
||||
@mousedown.prevent="toggleSearch"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
form.message-search {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
form.message-search .input-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
form.message-search input {
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
margin: 7px 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background-color: #fafafa;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
form.message-search input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
form.message-search input {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
form.message-search input:focus {
|
||||
min-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
form.message-search .input-wrapper {
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--window-bg-color);
|
||||
}
|
||||
|
||||
form.message-search .input-wrapper input {
|
||||
margin: 7px;
|
||||
}
|
||||
|
||||
form.message-search.opened .input-wrapper {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
#chat form.message-search button {
|
||||
display: flex;
|
||||
color: #607992;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onMounted, PropType, ref, watch} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {ClientNetwork, ClientChan} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageSearchForm",
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup(props) {
|
||||
const searchOpened = ref(false);
|
||||
const searchInput = ref("");
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const searchInputField = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const onSearchPage = computed(() => {
|
||||
return route.name === "SearchResults";
|
||||
});
|
||||
|
||||
watch(route, (newValue) => {
|
||||
if (newValue.query.q) {
|
||||
searchInput.value = String(newValue.query.q);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
searchInput.value = String(route.query.q || "");
|
||||
searchOpened.value = onSearchPage.value;
|
||||
|
||||
if (searchInputField.value && !searchInput.value && searchOpened.value) {
|
||||
searchInputField.value.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const closeSearch = () => {
|
||||
if (!onSearchPage.value) {
|
||||
searchInput.value = "";
|
||||
searchOpened.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSearch = () => {
|
||||
if (searchOpened.value) {
|
||||
searchInputField.value?.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
searchOpened.value = true;
|
||||
searchInputField.value?.focus();
|
||||
};
|
||||
|
||||
const searchMessages = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!searchInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
router
|
||||
.push({
|
||||
name: "SearchResults",
|
||||
params: {
|
||||
id: props.channel.id,
|
||||
},
|
||||
query: {
|
||||
q: searchInput.value,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "NavigationDuplicated") {
|
||||
// Search for the same query again
|
||||
eventbus.emit("re-search");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
searchOpened,
|
||||
searchInput,
|
||||
searchInputField,
|
||||
closeSearch,
|
||||
toggleSearch,
|
||||
searchMessages,
|
||||
onSearchPage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
35
client/components/MessageTypes/away.vue
Normal file
35
client/components/MessageTypes/away.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage v-if="message.self" :network="network" :message="message" />
|
||||
<template v-else>
|
||||
<Username :user="message.from" />
|
||||
is away
|
||||
<i class="away-message">(<ParsedMessage :network="network" :message="message" />)</i>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeAway",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
34
client/components/MessageTypes/back.vue
Normal file
34
client/components/MessageTypes/back.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage v-if="message.self" :network="network" :message="message" />
|
||||
<template v-else>
|
||||
<Username :user="message.from" />
|
||||
is back
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeBack",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
38
client/components/MessageTypes/chghost.vue
Normal file
38
client/components/MessageTypes/chghost.vue
Normal file
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
has changed
|
||||
<span v-if="message.new_ident"
|
||||
>username to <b>{{ message.new_ident }}</b></span
|
||||
>
|
||||
<span v-if="message.new_host"
|
||||
>hostname to
|
||||
<i class="hostmask"><ParsedMessage :network="network" :text="message.new_host" /></i
|
||||
></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeChangeHost",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
31
client/components/MessageTypes/ctcp.vue
Normal file
31
client/components/MessageTypes/ctcp.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
{{ ` ` }}<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
32
client/components/MessageTypes/ctcp_request.vue
Normal file
32
client/components/MessageTypes/ctcp_request.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
|
||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeRequestCTCP",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
77
client/components/MessageTypes/error.vue
Normal file
77
client/components/MessageTypes/error.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<ParsedMessage :network="network" :message="message" :text="errorMessage" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeError",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const errorMessage = computed(() => {
|
||||
// TODO: enforce chan and nick fields so that we can get rid of that
|
||||
const chan = props.message.channel || "!UNKNOWN_CHAN";
|
||||
const nick = props.message.nick || "!UNKNOWN_NICK";
|
||||
|
||||
switch (props.message.error) {
|
||||
case "bad_channel_key":
|
||||
return `Cannot join ${chan} - Bad channel key.`;
|
||||
case "banned_from_channel":
|
||||
return `Cannot join ${chan} - You have been banned from the channel.`;
|
||||
case "cannot_send_to_channel":
|
||||
return `Cannot send to channel ${chan}`;
|
||||
case "channel_is_full":
|
||||
return `Cannot join ${chan} - Channel is full.`;
|
||||
case "chanop_privs_needed":
|
||||
return "Cannot perform action: You're not a channel operator.";
|
||||
case "invite_only_channel":
|
||||
return `Cannot join ${chan} - Channel is invite only.`;
|
||||
case "no_such_nick":
|
||||
return `User ${nick} hasn't logged in or does not exist.`;
|
||||
case "not_on_channel":
|
||||
return "Cannot perform action: You're not on the channel.";
|
||||
case "password_mismatch":
|
||||
return "Password mismatch.";
|
||||
case "too_many_channels":
|
||||
return `Cannot join ${chan} - You've already reached the maximum number of channels allowed.`;
|
||||
case "unknown_command":
|
||||
// TODO: not having message.command should never happen, so force existence
|
||||
return `Unknown command: ${props.message.command || "!UNDEFINED_COMMAND_BUG"}`;
|
||||
case "user_not_in_channel":
|
||||
return `User ${nick} is not on the channel.`;
|
||||
case "user_on_channel":
|
||||
return `User ${nick} is already on the channel.`;
|
||||
default:
|
||||
if (props.message.reason) {
|
||||
return `${props.message.reason} (${
|
||||
props.message.error || "!UNDEFINED_ERR"
|
||||
})`;
|
||||
}
|
||||
|
||||
return props.message.error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
11
client/components/MessageTypes/index.ts
Normal file
11
client/components/MessageTypes/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
// This creates a version of `require()` in the context of the current
|
||||
// directory, so we iterate over its content, which is a map statically built by
|
||||
// Webpack.
|
||||
// Second argument says it's recursive, third makes sure we only load templates.
|
||||
const requireViews = require.context(".", false, /\.vue$/);
|
||||
|
||||
export default requireViews.keys().reduce((acc: Record<string, any>, path) => {
|
||||
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
|
||||
|
||||
return acc;
|
||||
}, {});
|
34
client/components/MessageTypes/invite.vue
Normal file
34
client/components/MessageTypes/invite.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
invited
|
||||
<span v-if="message.invitedYou">you</span>
|
||||
<Username v-else :user="message.target" />
|
||||
to <ParsedMessage :network="network" :text="message.channel" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeInvite",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
38
client/components/MessageTypes/join.vue
Normal file
38
client/components/MessageTypes/join.vue
Normal file
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i>
|
||||
<template v-if="message.account">
|
||||
<i class="account"> [{{ message.account }}]</i>
|
||||
</template>
|
||||
<template v-if="message.gecos">
|
||||
<i class="realname"> ({{ message.gecos }})</i>
|
||||
</template>
|
||||
has joined the channel
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeJoin",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
35
client/components/MessageTypes/kick.vue
Normal file
35
client/components/MessageTypes/kick.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
has kicked
|
||||
<Username :user="message.target" />
|
||||
<i v-if="message.text" class="part-reason"
|
||||
> (<ParsedMessage :network="network" :message="message" />)</i
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeKick",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
32
client/components/MessageTypes/mode.vue
Normal file
32
client/components/MessageTypes/mode.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
sets mode
|
||||
<ParsedMessage :message="message" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeMode",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
24
client/components/MessageTypes/mode_channel.vue
Normal file
24
client/components/MessageTypes/mode_channel.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
Channel mode is <b>{{ message.text }}</b>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageChannelMode",
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
24
client/components/MessageTypes/mode_user.vue
Normal file
24
client/components/MessageTypes/mode_user.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
Your user mode is <b>{{ message.raw_modes }}</b>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageChannelMode",
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
49
client/components/MessageTypes/monospace_block.vue
Normal file
49
client/components/MessageTypes/monospace_block.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<span class="text"><ParsedMessage :network="network" :text="cleanText" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeMonospaceBlock",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const cleanText = computed(() => {
|
||||
let lines = props.message.text.split("\n");
|
||||
|
||||
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
||||
// across MOTDs), remove all the leading hyphens.
|
||||
if (lines.every((line) => line === "" || line[0] === "-")) {
|
||||
lines = lines.map((line) => line.substring(2));
|
||||
}
|
||||
|
||||
// Remove empty lines around the MOTD (but not within it)
|
||||
return lines
|
||||
.map((line) => line.replace(/\s*$/, ""))
|
||||
.join("\n")
|
||||
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||
});
|
||||
|
||||
return {
|
||||
cleanText,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
30
client/components/MessageTypes/nick.vue
Normal file
30
client/components/MessageTypes/nick.vue
Normal file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
is now known as
|
||||
<Username :user="{nick: message.new_nick, mode: message.from.mode}" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeNick",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
35
client/components/MessageTypes/part.vue
Normal file
35
client/components/MessageTypes/part.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
|
||||
left the channel
|
||||
<i v-if="message.text" class="part-reason"
|
||||
>(<ParsedMessage :network="network" :message="message" />)</i
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypePart",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
35
client/components/MessageTypes/quit.vue
Normal file
35
client/components/MessageTypes/quit.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<Username :user="message.from" />
|
||||
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
|
||||
quit
|
||||
<i v-if="message.text" class="quit-reason"
|
||||
>(<ParsedMessage :network="network" :message="message" />)</i
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientMessage, ClientNetwork} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeQuit",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
22
client/components/MessageTypes/raw.vue
Normal file
22
client/components/MessageTypes/raw.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<span class="content">{{ message.text }}</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeRaw",
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
36
client/components/MessageTypes/topic.vue
Normal file
36
client/components/MessageTypes/topic.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<template v-if="message.from && message.from.nick"
|
||||
><Username :user="message.from" /> has changed the topic to:
|
||||
</template>
|
||||
<template v-else>The topic is: </template>
|
||||
<span v-if="message.text" class="new-topic"
|
||||
><ParsedMessage :network="network" :message="message"
|
||||
/></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientMessage, ClientNetwork} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeTopic",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
38
client/components/MessageTypes/topic_set_by.vue
Normal file
38
client/components/MessageTypes/topic_set_by.vue
Normal file
|
@ -0,0 +1,38 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
Topic set by
|
||||
<Username :user="message.from" />
|
||||
on {{ messageTimeLocale }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeTopicSetBy",
|
||||
components: {
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const messageTimeLocale = computed(() => localetime(props.message.when));
|
||||
|
||||
return {
|
||||
messageTimeLocale,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
143
client/components/MessageTypes/whois.vue
Normal file
143
client/components/MessageTypes/whois.vue
Normal file
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<span class="content">
|
||||
<p>
|
||||
<Username :user="{nick: message.whois.nick}" />
|
||||
<span v-if="message.whois.whowas"> is offline, last information:</span>
|
||||
</p>
|
||||
|
||||
<dl class="whois">
|
||||
<template v-if="message.whois.account">
|
||||
<dt>Logged in as:</dt>
|
||||
<dd>{{ message.whois.account }}</dd>
|
||||
</template>
|
||||
|
||||
<dt>Host mask:</dt>
|
||||
<dd class="hostmask">
|
||||
<ParsedMessage
|
||||
:network="network"
|
||||
:text="message.whois.ident + '@' + message.whois.hostname"
|
||||
/>
|
||||
</dd>
|
||||
|
||||
<template v-if="message.whois.actual_hostname">
|
||||
<dt>Actual host:</dt>
|
||||
<dd class="hostmask">
|
||||
<a
|
||||
:href="'https://ipinfo.io/' + message.whois.actual_ip"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ message.whois.actual_ip }}</a
|
||||
>
|
||||
<i v-if="message.whois.actual_hostname != message.whois.actual_ip">
|
||||
({{ message.whois.actual_hostname }})</i
|
||||
>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.real_name">
|
||||
<dt>Real name:</dt>
|
||||
<dd><ParsedMessage :network="network" :text="message.whois.real_name" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.registered_nick">
|
||||
<dt>Registered nick:</dt>
|
||||
<dd>{{ message.whois.registered_nick }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.channels">
|
||||
<dt>Channels:</dt>
|
||||
<dd><ParsedMessage :network="network" :text="message.whois.channels" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.modes">
|
||||
<dt>Modes:</dt>
|
||||
<dd>{{ message.whois.modes }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.special">
|
||||
<template v-for="special in message.whois.special" :key="special">
|
||||
<dt>Special:</dt>
|
||||
<dd>{{ special }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.operator">
|
||||
<dt>Operator:</dt>
|
||||
<dd>{{ message.whois.operator }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.helpop">
|
||||
<dt>Available for help:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.bot">
|
||||
<dt>Is a bot:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.away">
|
||||
<dt>Away:</dt>
|
||||
<dd><ParsedMessage :network="network" :text="message.whois.away" /></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.secure">
|
||||
<dt>Secure connection:</dt>
|
||||
<dd>Yes</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.certfp">
|
||||
<dt>Certificate:</dt>
|
||||
<dd>{{ message.whois.certfp }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.server">
|
||||
<dt>Connected to:</dt>
|
||||
<dd>
|
||||
{{ message.whois.server }} <i>({{ message.whois.server_info }})</i>
|
||||
</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.logonTime">
|
||||
<dt>Connected at:</dt>
|
||||
<dd>{{ localetime(message.whois.logonTime) }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="message.whois.idle">
|
||||
<dt>Idle since:</dt>
|
||||
<dd>{{ localetime(message.whois.idleTime) }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import Username from "../Username.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "MessageTypeWhois",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
Username,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: Object as PropType<ClientMessage>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
localetime: (date: Date) => localetime(date),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
572
client/components/NetworkForm.vue
Normal file
572
client/components/NetworkForm.vue
Normal file
|
@ -0,0 +1,572 @@
|
|||
<template>
|
||||
<div id="connect" class="window" role="tabpanel" aria-label="Connect">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<form class="container" method="post" action="" @submit.prevent="onSubmit">
|
||||
<h1 class="title">
|
||||
<template v-if="defaults.uuid">
|
||||
<input v-model="defaults.uuid" type="hidden" name="uuid" />
|
||||
Edit {{ defaults.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Connect
|
||||
<template
|
||||
v-if="config?.lockNetwork && store?.state.serverConfiguration?.public"
|
||||
>
|
||||
to {{ defaults.name }}
|
||||
</template>
|
||||
</template>
|
||||
</h1>
|
||||
<template v-if="!config?.lockNetwork">
|
||||
<h2>Network settings</h2>
|
||||
<div class="connect-row">
|
||||
<label for="connect:name">Name</label>
|
||||
<input
|
||||
id="connect:name"
|
||||
v-model.trim="defaults.name"
|
||||
class="input"
|
||||
name="name"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:host">Server</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="connect:host"
|
||||
v-model.trim="defaults.host"
|
||||
class="input"
|
||||
name="host"
|
||||
aria-label="Server address"
|
||||
maxlength="255"
|
||||
required
|
||||
/>
|
||||
<span id="connect:portseparator">:</span>
|
||||
<input
|
||||
id="connect:port"
|
||||
v-model="defaults.port"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
name="port"
|
||||
aria-label="Server port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:password"
|
||||
v-model="defaults.password"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Server password (optional)"
|
||||
name="password"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label></label>
|
||||
<div class="input-wrap">
|
||||
<label class="tls">
|
||||
<input
|
||||
v-model="defaults.tls"
|
||||
type="checkbox"
|
||||
name="tls"
|
||||
:disabled="defaults.hasSTSPolicy"
|
||||
/>
|
||||
Use secure connection (TLS)
|
||||
<span
|
||||
v-if="defaults.hasSTSPolicy"
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="This network has a strict transport security policy, you will be unable to disable TLS"
|
||||
>🔒 STS</span
|
||||
>
|
||||
</label>
|
||||
<label class="tls">
|
||||
<input
|
||||
v-model="defaults.rejectUnauthorized"
|
||||
type="checkbox"
|
||||
name="rejectUnauthorized"
|
||||
/>
|
||||
Only allow trusted certificates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Proxy Settings</h2>
|
||||
<div class="connect-row">
|
||||
<label></label>
|
||||
<div class="input-wrap">
|
||||
<label for="connect:proxyEnabled">
|
||||
<input
|
||||
id="connect:proxyEnabled"
|
||||
v-model="defaults.proxyEnabled"
|
||||
type="checkbox"
|
||||
name="proxyEnabled"
|
||||
/>
|
||||
Enable Proxy
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="defaults.proxyEnabled">
|
||||
<div class="connect-row">
|
||||
<label for="connect:proxyHost">SOCKS Address</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="connect:proxyHost"
|
||||
v-model.trim="defaults.proxyHost"
|
||||
class="input"
|
||||
name="proxyHost"
|
||||
aria-label="Proxy host"
|
||||
maxlength="255"
|
||||
/>
|
||||
<span id="connect:proxyPortSeparator">:</span>
|
||||
<input
|
||||
id="connect:proxyPort"
|
||||
v-model="defaults.proxyPort"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
name="proxyPort"
|
||||
aria-label="SOCKS port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connect-row">
|
||||
<label for="connect:proxyUsername">Proxy username</label>
|
||||
<input
|
||||
id="connect:proxyUsername"
|
||||
ref="proxyUsernameInput"
|
||||
v-model.trim="defaults.proxyUsername"
|
||||
class="input username"
|
||||
name="proxyUsername"
|
||||
maxlength="100"
|
||||
placeholder="Proxy username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="connect-row">
|
||||
<label for="connect:proxyPassword">Proxy password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:proxyPassword"
|
||||
ref="proxyPassword"
|
||||
v-model="defaults.proxyPassword"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Proxy password"
|
||||
name="proxyPassword"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="config.lockNetwork && !store.state.serverConfiguration?.public">
|
||||
<h2>Network settings</h2>
|
||||
<div class="connect-row">
|
||||
<label for="connect:name">Name</label>
|
||||
<input
|
||||
id="connect:name"
|
||||
v-model.trim="defaults.name"
|
||||
class="input"
|
||||
name="name"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:password"
|
||||
v-model="defaults.password"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Server password (optional)"
|
||||
name="password"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h2>User preferences</h2>
|
||||
<div class="connect-row">
|
||||
<label for="connect:nick">Nick</label>
|
||||
<input
|
||||
id="connect:nick"
|
||||
v-model="defaults.nick"
|
||||
class="input nick"
|
||||
name="nick"
|
||||
pattern="[^\s:!@]+"
|
||||
maxlength="100"
|
||||
required
|
||||
@input="onNickChanged"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!config?.useHexIp">
|
||||
<div class="connect-row">
|
||||
<label for="connect:username">Username</label>
|
||||
<input
|
||||
id="connect:username"
|
||||
ref="usernameInput"
|
||||
v-model.trim="defaults.username"
|
||||
class="input username"
|
||||
name="username"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="connect-row">
|
||||
<label for="connect:realname">Real name</label>
|
||||
<input
|
||||
id="connect:realname"
|
||||
v-model.trim="defaults.realname"
|
||||
class="input"
|
||||
name="realname"
|
||||
maxlength="300"
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:leaveMessage">Leave message</label>
|
||||
<input
|
||||
id="connect:leaveMessage"
|
||||
v-model.trim="defaults.leaveMessage"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
name="leaveMessage"
|
||||
placeholder="The Lounge - https://thelounge.chat"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="defaults.uuid && !store.state.serverConfiguration?.public">
|
||||
<div class="connect-row">
|
||||
<label for="connect:commands">
|
||||
Commands
|
||||
<span
|
||||
class="tooltipped tooltipped-ne tooltipped-no-delay"
|
||||
aria-label="One /command per line.
|
||||
Each command will be executed in
|
||||
the server tab on new connection"
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="connect:commands"
|
||||
ref="commandsInput"
|
||||
autocomplete="off"
|
||||
:value="defaults.commands ? defaults.commands.join('\n') : ''"
|
||||
class="input"
|
||||
name="commands"
|
||||
@input="resizeCommandsInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="!defaults.uuid">
|
||||
<div class="connect-row">
|
||||
<label for="connect:channels">Channels</label>
|
||||
<input
|
||||
id="connect:channels"
|
||||
v-model.trim="defaults.join"
|
||||
class="input"
|
||||
name="join"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="store.state.serverConfiguration?.public">
|
||||
<template v-if="config?.lockNetwork">
|
||||
<div class="connect-row">
|
||||
<label></label>
|
||||
<div class="input-wrap">
|
||||
<label class="tls">
|
||||
<input v-model="displayPasswordField" type="checkbox" />
|
||||
I have a password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayPasswordField" class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:password"
|
||||
ref="publicPassword"
|
||||
v-model="defaults.password"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
placeholder="Server password (optional)"
|
||||
name="password"
|
||||
maxlength="300"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 id="label-auth">Authentication</h2>
|
||||
<div class="connect-row connect-auth" role="group" aria-labelledby="label-auth">
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="!defaults.sasl"
|
||||
type="radio"
|
||||
name="sasl"
|
||||
value=""
|
||||
@change="setSaslAuth('')"
|
||||
/>
|
||||
No authentication
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="defaults.sasl === 'plain'"
|
||||
type="radio"
|
||||
name="sasl"
|
||||
value="plain"
|
||||
@change="setSaslAuth('plain')"
|
||||
/>
|
||||
Username + password (SASL PLAIN)
|
||||
</label>
|
||||
<label
|
||||
v-if="!store.state.serverConfiguration?.public && defaults.tls"
|
||||
class="opt"
|
||||
>
|
||||
<input
|
||||
:checked="defaults.sasl === 'external'"
|
||||
type="radio"
|
||||
name="sasl"
|
||||
value="external"
|
||||
@change="setSaslAuth('external')"
|
||||
/>
|
||||
Client certificate (SASL EXTERNAL)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="defaults.sasl === 'plain'">
|
||||
<div class="connect-row">
|
||||
<label for="connect:username">Account</label>
|
||||
<input
|
||||
id="connect:saslAccount"
|
||||
v-model.trim="defaults.saslAccount"
|
||||
class="input"
|
||||
name="saslAccount"
|
||||
maxlength="100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-row">
|
||||
<label for="connect:password">Password</label>
|
||||
<RevealPassword
|
||||
v-slot:default="slotProps"
|
||||
class="input-wrap password-container"
|
||||
>
|
||||
<input
|
||||
id="connect:saslPassword"
|
||||
v-model="defaults.saslPassword"
|
||||
class="input"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="saslPassword"
|
||||
maxlength="300"
|
||||
required
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="defaults.sasl === 'external'" class="connect-sasl-external">
|
||||
<p>The Lounge automatically generates and manages the client certificate.</p>
|
||||
<p>
|
||||
On the IRC server, you will need to tell the services to attach the
|
||||
certificate fingerprint (certfp) to your account, for example:
|
||||
</p>
|
||||
<pre><code>/msg NickServ CERT ADD</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn" :disabled="disabled ? true : false">
|
||||
<template v-if="defaults.uuid">Save network</template>
|
||||
<template v-else>Connect</template>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#connect .connect-auth {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#connect .connect-auth .opt {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#connect .connect-auth input {
|
||||
margin: 3px 10px 0 0;
|
||||
}
|
||||
|
||||
#connect .connect-sasl-external {
|
||||
padding: 10px;
|
||||
border-radius: 2px;
|
||||
background-color: #d9edf7;
|
||||
color: #31708f;
|
||||
}
|
||||
|
||||
#connect .connect-sasl-external pre {
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import RevealPassword from "./RevealPassword.vue";
|
||||
import SidebarToggle from "./SidebarToggle.vue";
|
||||
import {defineComponent, nextTick, PropType, ref, watch} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientNetwork} from "../js/types";
|
||||
|
||||
export type NetworkFormDefaults = Partial<ClientNetwork> & {
|
||||
join?: string;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "NetworkForm",
|
||||
components: {
|
||||
RevealPassword,
|
||||
SidebarToggle,
|
||||
},
|
||||
props: {
|
||||
handleSubmit: {
|
||||
type: Function as PropType<(network: ClientNetwork) => void>,
|
||||
required: true,
|
||||
},
|
||||
defaults: {
|
||||
type: Object as PropType<NetworkFormDefaults>,
|
||||
required: true,
|
||||
},
|
||||
disabled: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
const config = ref(store.state.serverConfiguration);
|
||||
const previousUsername = ref(props.defaults?.username);
|
||||
const displayPasswordField = ref(false);
|
||||
|
||||
const publicPassword = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(displayPasswordField, (newValue) => {
|
||||
if (newValue) {
|
||||
void nextTick(() => {
|
||||
publicPassword.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const commandsInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const resizeCommandsInput = () => {
|
||||
if (!commandsInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset height first so it can down size
|
||||
commandsInput.value.style.height = "";
|
||||
|
||||
// 2 pixels to account for the border
|
||||
commandsInput.value.style.height = `${Math.ceil(
|
||||
commandsInput.value.scrollHeight + 2
|
||||
)}px`;
|
||||
};
|
||||
|
||||
watch(
|
||||
// eslint-disable-next-line
|
||||
() => props.defaults?.commands,
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
resizeCommandsInput();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
// eslint-disable-next-line
|
||||
() => props.defaults?.tls,
|
||||
(isSecureChecked) => {
|
||||
const ports = [6667, 6697];
|
||||
const newPort = isSecureChecked ? 0 : 1;
|
||||
|
||||
// If you disable TLS and current port is 6697,
|
||||
// set it to 6667, and vice versa
|
||||
if (props.defaults?.port === ports[newPort]) {
|
||||
props.defaults.port = ports[1 - newPort];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const setSaslAuth = (type: string) => {
|
||||
if (props.defaults) {
|
||||
props.defaults.sasl = type;
|
||||
}
|
||||
};
|
||||
|
||||
const usernameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const onNickChanged = (event: Event) => {
|
||||
if (!usernameInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usernameRef = usernameInput.value;
|
||||
|
||||
if (!usernameRef.value || usernameRef.value === previousUsername.value) {
|
||||
usernameRef.value = (event.target as HTMLInputElement)?.value;
|
||||
}
|
||||
|
||||
previousUsername.value = (event.target as HTMLInputElement)?.value;
|
||||
};
|
||||
|
||||
const onSubmit = (event: Event) => {
|
||||
const formData = new FormData(event.target as HTMLFormElement);
|
||||
const data: Partial<ClientNetwork> = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
data[key] = value;
|
||||
});
|
||||
|
||||
props.handleSubmit(data as ClientNetwork);
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
config,
|
||||
displayPasswordField,
|
||||
publicPassword,
|
||||
commandsInput,
|
||||
resizeCommandsInput,
|
||||
setSaslAuth,
|
||||
usernameInput,
|
||||
onNickChanged,
|
||||
onSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
576
client/components/NetworkList.vue
Normal file
576
client/components/NetworkList.vue
Normal file
|
@ -0,0 +1,576 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="store.state.networks.length === 0"
|
||||
class="empty"
|
||||
role="navigation"
|
||||
aria-label="Network and Channel list"
|
||||
>
|
||||
You are not connected to any networks yet.
|
||||
</div>
|
||||
<div v-else ref="networklist" role="navigation" aria-label="Network and Channel list">
|
||||
<div class="jump-to-input">
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchText"
|
||||
placeholder="Jump to..."
|
||||
type="search"
|
||||
class="search input mousetrap"
|
||||
aria-label="Search among the channel list"
|
||||
tabindex="-1"
|
||||
@input="setSearchText"
|
||||
@keydown.up="navigateResults($event, -1)"
|
||||
@keydown.down="navigateResults($event, 1)"
|
||||
@keydown.page-up="navigateResults($event, -10)"
|
||||
@keydown.page-down="navigateResults($event, 10)"
|
||||
@keydown.enter="selectResult"
|
||||
@keydown.escape="deactivateSearch"
|
||||
@focus="activateSearch"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="searchText" class="jump-to-results">
|
||||
<div v-if="results.length">
|
||||
<div
|
||||
v-for="item in results"
|
||||
:key="item.channel.id"
|
||||
@mouseenter="setActiveSearchItem(item.channel)"
|
||||
@click.prevent="selectResult"
|
||||
>
|
||||
<Channel
|
||||
v-if="item.channel.type !== 'lobby'"
|
||||
:channel="item.channel"
|
||||
:network="item.network"
|
||||
:active="item.channel === activeSearchItem"
|
||||
:is-filtering="true"
|
||||
/>
|
||||
<NetworkLobby
|
||||
v-else
|
||||
:channel="item.channel"
|
||||
:network="item.network"
|
||||
:active="item.channel === activeSearchItem"
|
||||
:is-filtering="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-results">No results found.</div>
|
||||
</div>
|
||||
<Draggable
|
||||
v-else
|
||||
:list="store.state.networks"
|
||||
:delay="LONG_TOUCH_DURATION"
|
||||
:delay-on-touch-only="true"
|
||||
:touch-start-threshold="10"
|
||||
handle=".channel-list-item[data-type='lobby']"
|
||||
draggable=".network"
|
||||
ghost-class="ui-sortable-ghost"
|
||||
drag-class="ui-sortable-dragging"
|
||||
group="networks"
|
||||
class="networks"
|
||||
item-key="uuid"
|
||||
@change="onNetworkSort"
|
||||
@choose="onDraggableChoose"
|
||||
@unchoose="onDraggableUnchoose"
|
||||
>
|
||||
<template v-slot:item="{element: network}">
|
||||
<div
|
||||
:id="'network-' + network.uuid"
|
||||
:key="network.uuid"
|
||||
:class="{
|
||||
collapsed: network.isCollapsed,
|
||||
'not-connected': !network.status.connected,
|
||||
'not-secure': !network.status.secure,
|
||||
}"
|
||||
class="network"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
@touchstart="onDraggableTouchStart"
|
||||
@touchmove="onDraggableTouchMove"
|
||||
@touchend="onDraggableTouchEnd"
|
||||
@touchcancel="onDraggableTouchEnd"
|
||||
>
|
||||
<NetworkLobby
|
||||
:network="network"
|
||||
:is-join-channel-shown="network.isJoinChannelShown"
|
||||
:active="
|
||||
store.state.activeChannel &&
|
||||
network.channels[0] === store.state.activeChannel.channel
|
||||
"
|
||||
@toggle-join-channel="
|
||||
network.isJoinChannelShown = !network.isJoinChannelShown
|
||||
"
|
||||
/>
|
||||
<JoinChannel
|
||||
v-if="network.isJoinChannelShown"
|
||||
:network="network"
|
||||
:channel="network.channels[0]"
|
||||
@toggle-join-channel="
|
||||
network.isJoinChannelShown = !network.isJoinChannelShown
|
||||
"
|
||||
/>
|
||||
|
||||
<Draggable
|
||||
draggable=".channel-list-item"
|
||||
ghost-class="ui-sortable-ghost"
|
||||
drag-class="ui-sortable-dragging"
|
||||
:group="network.uuid"
|
||||
:list="network.channels"
|
||||
:delay="LONG_TOUCH_DURATION"
|
||||
:delay-on-touch-only="true"
|
||||
:touch-start-threshold="10"
|
||||
class="channels"
|
||||
item-key="name"
|
||||
@change="onChannelSort"
|
||||
@choose="onDraggableChoose"
|
||||
@unchoose="onDraggableUnchoose"
|
||||
>
|
||||
<template v-slot:item="{element: channel, index}">
|
||||
<Channel
|
||||
v-if="index > 0"
|
||||
:key="channel.id"
|
||||
:data-item="channel.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:active="
|
||||
store.state.activeChannel &&
|
||||
channel === store.state.activeChannel.channel
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.jump-to-input {
|
||||
margin: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.jump-to-input .input {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding-right: 35px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.jump-to-input .input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.jump-to-input::before {
|
||||
content: "\f002"; /* http://fontawesome.io/icon/search/ */
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
line-height: 35px !important;
|
||||
}
|
||||
|
||||
.jump-to-results {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.jump-to-results .no-results {
|
||||
margin: 14px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item.active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item .add-channel,
|
||||
.jump-to-results .channel-list-item .close-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item[data-type="lobby"] {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.jump-to-results .channel-list-item[data-type="lobby"]::before {
|
||||
content: "\f233";
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
|
||||
|
||||
import Mousetrap from "mousetrap";
|
||||
import Draggable from "./Draggable.vue";
|
||||
import {filter as fuzzyFilter} from "fuzzy";
|
||||
import NetworkLobby from "./NetworkLobby.vue";
|
||||
import Channel from "./Channel.vue";
|
||||
import JoinChannel from "./JoinChannel.vue";
|
||||
|
||||
import socket from "../js/socket";
|
||||
import collapseNetworkHelper from "../js/helpers/collapseNetwork";
|
||||
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||
import distance from "../js/helpers/distance";
|
||||
import eventbus from "../js/eventbus";
|
||||
import {ClientChan, NetChan} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
import {switchToChannel} from "../js/router";
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
export default defineComponent({
|
||||
name: "NetworkList",
|
||||
components: {
|
||||
JoinChannel,
|
||||
NetworkLobby,
|
||||
Channel,
|
||||
Draggable,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const searchText = ref("");
|
||||
const activeSearchItem = ref<ClientChan | null>();
|
||||
// Number of milliseconds a touch has to last to be considered long
|
||||
const LONG_TOUCH_DURATION = 500;
|
||||
|
||||
const startDrag = ref<[number, number] | null>();
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const networklist = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const sidebarWasClosed = ref(false);
|
||||
|
||||
const moveItemInArray = <T>(array: T[], from: number, to: number) => {
|
||||
const item = array.splice(from, 1)[0];
|
||||
array.splice(to, 0, item);
|
||||
};
|
||||
|
||||
const items = computed(() => {
|
||||
const newItems: NetChan[] = [];
|
||||
|
||||
for (const network of store.state.networks) {
|
||||
for (const channel of network.channels) {
|
||||
if (
|
||||
store.state.activeChannel &&
|
||||
channel === store.state.activeChannel.channel
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newItems.push({network, channel});
|
||||
}
|
||||
}
|
||||
|
||||
return newItems;
|
||||
});
|
||||
|
||||
const results = computed(() => {
|
||||
const newResults = fuzzyFilter(searchText.value, items.value, {
|
||||
extract: (item) => item.channel.name,
|
||||
}).map((item) => item.original);
|
||||
|
||||
return newResults;
|
||||
});
|
||||
|
||||
const collapseNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (store.state.activeChannel) {
|
||||
collapseNetworkHelper(store.state.activeChannel.network, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const expandNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (store.state.activeChannel) {
|
||||
collapseNetworkHelper(store.state.activeChannel.network, false);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const onNetworkSort = (e: Sortable.SortableEvent) => {
|
||||
const {oldIndex, newIndex} = e;
|
||||
|
||||
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveItemInArray(store.state.networks, oldIndex, newIndex);
|
||||
|
||||
socket.emit("sort:networks", {
|
||||
order: store.state.networks.map((n) => n.uuid),
|
||||
});
|
||||
};
|
||||
|
||||
const onChannelSort = (e: Sortable.SortableEvent) => {
|
||||
let {oldIndex, newIndex} = e;
|
||||
|
||||
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Indexes are offset by one due to the lobby
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
|
||||
const unparsedId = e.item.getAttribute("data-item");
|
||||
|
||||
if (!unparsedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = parseInt(unparsedId);
|
||||
const netChan = store.getters.findChannel(id);
|
||||
|
||||
if (!netChan) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
|
||||
|
||||
socket.emit("sort:channel", {
|
||||
network: netChan.network.uuid,
|
||||
order: netChan.network.channels.map((c) => c.id),
|
||||
});
|
||||
};
|
||||
|
||||
const isTouchEvent = (event: any): boolean => {
|
||||
// This is the same way Sortable.js detects a touch event. See
|
||||
// SortableJS/Sortable@daaefeda:/src/Sortable.js#L465
|
||||
|
||||
return !!(
|
||||
(event.touches && event.touches[0]) ||
|
||||
(event.pointerType && event.pointerType === "touch")
|
||||
);
|
||||
};
|
||||
|
||||
const onDraggableChoose = (event: any) => {
|
||||
const original = event.originalEvent;
|
||||
|
||||
if (isTouchEvent(original)) {
|
||||
// onDrag is only triggered when the user actually moves the
|
||||
// dragged object but onChoose is triggered as soon as the
|
||||
// item is eligible for dragging. This gives us an opportunity
|
||||
// to tell the user they've held the touch long enough.
|
||||
event.item.classList.add("ui-sortable-dragging-touch-cue");
|
||||
|
||||
if (original instanceof TouchEvent && original.touches.length > 0) {
|
||||
startDrag.value = [original.touches[0].clientX, original.touches[0].clientY];
|
||||
} else if (original instanceof PointerEvent) {
|
||||
startDrag.value = [original.clientX, original.clientY];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDraggableUnchoose = (event: any) => {
|
||||
event.item.classList.remove("ui-sortable-dragging-touch-cue");
|
||||
startDrag.value = null;
|
||||
};
|
||||
|
||||
const onDraggableTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
// This prevents an iOS long touch default behavior: selecting
|
||||
// the nearest selectable text.
|
||||
document.body.classList.add("force-no-select");
|
||||
}
|
||||
};
|
||||
|
||||
const onDraggableTouchMove = (event: TouchEvent) => {
|
||||
if (startDrag.value && event.touches.length > 0) {
|
||||
const touch = event.touches[0];
|
||||
const currentPosition = [touch.clientX, touch.clientY];
|
||||
|
||||
if (distance(startDrag.value, currentPosition as [number, number]) > 10) {
|
||||
// Context menu is shown on Android after long touch.
|
||||
// Dismiss it now that we're sure the user is dragging.
|
||||
eventbus.emit("contextmenu:cancel");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDraggableTouchEnd = (event: TouchEvent) => {
|
||||
if (event.touches.length === 0) {
|
||||
document.body.classList.remove("force-no-select");
|
||||
}
|
||||
};
|
||||
|
||||
const activateSearch = () => {
|
||||
if (searchInput.value === document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarWasClosed.value = store.state.sidebarOpen ? false : true;
|
||||
store.commit("sidebarOpen", true);
|
||||
|
||||
void nextTick(() => {
|
||||
searchInput.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const deactivateSearch = () => {
|
||||
activeSearchItem.value = null;
|
||||
searchText.value = "";
|
||||
searchInput.value?.blur();
|
||||
|
||||
if (sidebarWasClosed.value) {
|
||||
store.commit("sidebarOpen", false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSearch = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||
if (isIgnoredKeybind(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (searchInput.value === document.activeElement) {
|
||||
deactivateSearch();
|
||||
return false;
|
||||
}
|
||||
|
||||
activateSearch();
|
||||
return false;
|
||||
};
|
||||
|
||||
const setSearchText = (e: Event) => {
|
||||
searchText.value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
const setActiveSearchItem = (channel?: ClientChan) => {
|
||||
if (!results.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
channel = results.value[0].channel;
|
||||
}
|
||||
|
||||
activeSearchItem.value = channel;
|
||||
};
|
||||
|
||||
const scrollToActive = () => {
|
||||
// Scroll the list if needed after the active class is applied
|
||||
void nextTick(() => {
|
||||
const el = networklist.value?.querySelector(".channel-list-item.active");
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const selectResult = () => {
|
||||
if (!searchText.value || !results.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSearchItem.value) {
|
||||
switchToChannel(activeSearchItem.value);
|
||||
deactivateSearch();
|
||||
scrollToActive();
|
||||
}
|
||||
};
|
||||
|
||||
const navigateResults = (event: Event, direction: number) => {
|
||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||
// and redirecting it to the message list container for scrolling
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (!searchText.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = results.value.map((r) => r.channel);
|
||||
|
||||
// Bail out if there's no channels to select
|
||||
if (!channels.length) {
|
||||
activeSearchItem.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = activeSearchItem.value
|
||||
? channels.indexOf(activeSearchItem.value)
|
||||
: -1;
|
||||
|
||||
// If there's no active channel select the first or last one depending on direction
|
||||
if (!activeSearchItem.value || currentIndex === -1) {
|
||||
activeSearchItem.value = direction ? channels[0] : channels[channels.length - 1];
|
||||
scrollToActive();
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex += direction;
|
||||
|
||||
// Wrap around the list if necessary. Normaly each loop iterates once at most,
|
||||
// but might iterate more often if pgup or pgdown are used in a very short list
|
||||
while (currentIndex < 0) {
|
||||
currentIndex += channels.length;
|
||||
}
|
||||
|
||||
while (currentIndex > channels.length - 1) {
|
||||
currentIndex -= channels.length;
|
||||
}
|
||||
|
||||
activeSearchItem.value = channels[currentIndex];
|
||||
scrollToActive();
|
||||
};
|
||||
|
||||
watch(searchText, () => {
|
||||
setActiveSearchItem();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
Mousetrap.bind("alt+shift+right", expandNetwork);
|
||||
Mousetrap.bind("alt+shift+left", collapseNetwork);
|
||||
Mousetrap.bind("alt+j", toggleSearch);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Mousetrap.unbind("alt+shift+right");
|
||||
Mousetrap.unbind("alt+shift+left");
|
||||
Mousetrap.unbind("alt+j");
|
||||
});
|
||||
|
||||
const networkContainerRef = ref<HTMLDivElement>();
|
||||
const channelRefs = ref<{[key: string]: HTMLDivElement}>({});
|
||||
|
||||
return {
|
||||
store,
|
||||
networklist,
|
||||
searchInput,
|
||||
searchText,
|
||||
results,
|
||||
activeSearchItem,
|
||||
LONG_TOUCH_DURATION,
|
||||
|
||||
activateSearch,
|
||||
deactivateSearch,
|
||||
toggleSearch,
|
||||
setSearchText,
|
||||
setActiveSearchItem,
|
||||
scrollToActive,
|
||||
selectResult,
|
||||
navigateResults,
|
||||
onChannelSort,
|
||||
onNetworkSort,
|
||||
onDraggableTouchStart,
|
||||
onDraggableTouchMove,
|
||||
onDraggableTouchEnd,
|
||||
onDraggableChoose,
|
||||
onDraggableUnchoose,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
101
client/components/NetworkLobby.vue
Normal file
101
client/components/NetworkLobby.vue
Normal file
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<ChannelWrapper v-bind="$props" :channel="channel">
|
||||
<button
|
||||
v-if="network.channels.length > 1"
|
||||
:aria-controls="'network-' + network.uuid"
|
||||
:aria-label="getExpandLabel(network)"
|
||||
:aria-expanded="!network.isCollapsed"
|
||||
class="collapse-network"
|
||||
@click.stop="onCollapseClick"
|
||||
>
|
||||
<span class="collapse-network-icon" />
|
||||
</button>
|
||||
<span v-else class="collapse-network" />
|
||||
<div class="lobby-wrap">
|
||||
<span :title="channel.name" class="name">{{ channel.name }}</span>
|
||||
<span
|
||||
v-if="network.status.connected && !network.status.secure"
|
||||
class="not-secure-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Insecure connection"
|
||||
>
|
||||
<span class="not-secure-icon" />
|
||||
</span>
|
||||
<span
|
||||
v-if="!network.status.connected"
|
||||
class="not-connected-tooltip tooltipped tooltipped-w"
|
||||
aria-label="Disconnected"
|
||||
>
|
||||
<span class="not-connected-icon" />
|
||||
</span>
|
||||
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
|
||||
unreadCount
|
||||
}}</span>
|
||||
</div>
|
||||
<span
|
||||
:aria-label="joinChannelLabel"
|
||||
class="add-channel-tooltip tooltipped tooltipped-w tooltipped-no-touch"
|
||||
>
|
||||
<button
|
||||
:class="['add-channel', {opened: isJoinChannelShown}]"
|
||||
:aria-controls="'join-channel-' + channel.id"
|
||||
:aria-label="joinChannelLabel"
|
||||
@click.stop="$emit('toggle-join-channel')"
|
||||
/>
|
||||
</span>
|
||||
</ChannelWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||
|
||||
import type {ClientChan, ClientNetwork} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Channel",
|
||||
components: {
|
||||
ChannelWrapper,
|
||||
},
|
||||
props: {
|
||||
network: {
|
||||
type: Object as PropType<ClientNetwork>,
|
||||
required: true,
|
||||
},
|
||||
isJoinChannelShown: Boolean,
|
||||
active: Boolean,
|
||||
isFiltering: Boolean,
|
||||
},
|
||||
emits: ["toggle-join-channel"],
|
||||
setup(props) {
|
||||
const channel = computed(() => {
|
||||
return props.network.channels[0];
|
||||
});
|
||||
|
||||
const joinChannelLabel = computed(() => {
|
||||
return props.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
return roundBadgeNumber(channel.value.unread);
|
||||
});
|
||||
|
||||
const onCollapseClick = () => {
|
||||
collapseNetwork(props.network, !props.network.isCollapsed);
|
||||
};
|
||||
|
||||
const getExpandLabel = (network: ClientNetwork) => {
|
||||
return network.isCollapsed ? "Expand" : "Collapse";
|
||||
};
|
||||
|
||||
return {
|
||||
channel,
|
||||
joinChannelLabel,
|
||||
unreadCount,
|
||||
onCollapseClick,
|
||||
getExpandLabel,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
22
client/components/ParsedMessage.vue
Normal file
22
client/components/ParsedMessage.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import {defineComponent, PropType, h} from "vue";
|
||||
import parse from "../js/helpers/parse";
|
||||
import type {ClientMessage, ClientNetwork} from "../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ParsedMessage",
|
||||
functional: true,
|
||||
props: {
|
||||
text: String,
|
||||
message: {type: Object as PropType<ClientMessage | string>, required: false},
|
||||
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||
},
|
||||
render(context) {
|
||||
return parse(
|
||||
typeof context.text !== "undefined" ? context.text : context.message.text,
|
||||
context.message,
|
||||
context.network
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
37
client/components/RevealPassword.vue
Normal file
37
client/components/RevealPassword.vue
Normal file
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div>
|
||||
<slot :is-visible="isVisible" />
|
||||
<span
|
||||
ref="revealButton"
|
||||
type="button"
|
||||
:class="[
|
||||
'reveal-password tooltipped tooltipped-n tooltipped-no-delay',
|
||||
{'reveal-password-visible': isVisible},
|
||||
]"
|
||||
:aria-label="isVisible ? 'Hide password' : 'Show password'"
|
||||
@click="onClick"
|
||||
>
|
||||
<span :aria-label="isVisible ? 'Hide password' : 'Show password'" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RevealPassword",
|
||||
setup() {
|
||||
const isVisible = ref(false);
|
||||
|
||||
const onClick = () => {
|
||||
isVisible.value = !isVisible.value;
|
||||
};
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
onClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
66
client/components/RoutedChat.vue
Normal file
66
client/components/RoutedChat.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<Chat
|
||||
v-if="activeChannel"
|
||||
:network="activeChannel.network"
|
||||
:channel="activeChannel.channel"
|
||||
:focused="parseInt(String(route.query.focused), 10)"
|
||||
@channel-changed="channelChanged"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {watch, computed, defineComponent, onMounted} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useStore} from "../js/store";
|
||||
import {ClientChan} from "../js/types";
|
||||
|
||||
// Temporary component for routing channels and lobbies
|
||||
import Chat from "./Chat.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RoutedChat",
|
||||
components: {
|
||||
Chat,
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const activeChannel = computed(() => {
|
||||
const chanId = parseInt(String(route.params.id || ""), 10);
|
||||
const channel = store.getters.findChannel(chanId);
|
||||
return channel;
|
||||
});
|
||||
|
||||
const setActiveChannel = () => {
|
||||
if (activeChannel.value) {
|
||||
store.commit("activeChannel", activeChannel.value);
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeChannel, () => {
|
||||
setActiveChannel();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setActiveChannel();
|
||||
});
|
||||
|
||||
const channelChanged = (channel: ClientChan) => {
|
||||
const chanId = channel.id;
|
||||
const chanInStore = store.getters.findChannel(chanId);
|
||||
|
||||
if (chanInStore?.channel) {
|
||||
chanInStore.channel.unread = 0;
|
||||
chanInStore.channel.highlight = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
route,
|
||||
activeChannel,
|
||||
channelChanged,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
83
client/components/Session.vue
Normal file
83
client/components/Session.vue
Normal file
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="session-item">
|
||||
<div class="session-item-info">
|
||||
<strong>{{ session.agent }}</strong>
|
||||
|
||||
<a :href="'https://ipinfo.io/' + session.ip" target="_blank" rel="noopener">{{
|
||||
session.ip
|
||||
}}</a>
|
||||
|
||||
<p v-if="session.active > 1" class="session-usage">
|
||||
Active in {{ session.active }} browsers
|
||||
</p>
|
||||
<p v-else-if="!session.current && !session.active" class="session-usage">
|
||||
Last used on <time>{{ lastUse }}</time>
|
||||
</p>
|
||||
</div>
|
||||
<div class="session-item-btn">
|
||||
<button class="btn" @click.prevent="signOut">
|
||||
<template v-if="session.current">Sign out</template>
|
||||
<template v-else>Revoke</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.session-list .session-item {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session-list .session-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.session-list .session-item-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-list .session-usage {
|
||||
font-style: italic;
|
||||
color: var(--body-color-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import localetime from "../js/helpers/localetime";
|
||||
import Auth from "../js/auth";
|
||||
import socket from "../js/socket";
|
||||
import {ClientSession} from "../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Session",
|
||||
props: {
|
||||
session: {
|
||||
type: Object as PropType<ClientSession>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const lastUse = computed(() => {
|
||||
return localetime(props.session.lastUse);
|
||||
});
|
||||
|
||||
const signOut = () => {
|
||||
if (!props.session.current) {
|
||||
socket.emit("sign-out", props.session.token);
|
||||
} else {
|
||||
socket.emit("sign-out");
|
||||
Auth.signout();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lastUse,
|
||||
signOut,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
197
client/components/Settings/Account.vue
Normal file
197
client/components/Settings/Account.vue
Normal file
|
@ -0,0 +1,197 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="
|
||||
!store.state.serverConfiguration?.public &&
|
||||
!store.state.serverConfiguration?.ldapEnabled
|
||||
"
|
||||
id="change-password"
|
||||
role="group"
|
||||
aria-labelledby="label-change-password"
|
||||
>
|
||||
<h2 id="label-change-password">Change password</h2>
|
||||
<div class="password-container">
|
||||
<label for="current-password" class="sr-only"> Enter current password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="old_password"
|
||||
autocomplete="current-password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="old_password"
|
||||
class="input"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div class="password-container">
|
||||
<label for="new-password" class="sr-only"> Enter desired new password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="new_password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="new_password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
placeholder="Enter desired new password"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div class="password-container">
|
||||
<label for="new-password-verify" class="sr-only"> Repeat new password </label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="new-password-verify"
|
||||
v-model="verify_password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
name="verify_password"
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
placeholder="Repeat new password"
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
<div
|
||||
v-if="passwordChangeStatus && passwordChangeStatus.success"
|
||||
class="feedback success"
|
||||
>
|
||||
Successfully updated your password
|
||||
</div>
|
||||
<div
|
||||
v-else-if="passwordChangeStatus && passwordChangeStatus.error"
|
||||
class="feedback error"
|
||||
>
|
||||
{{ passwordErrors[passwordChangeStatus.error] }}
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn" @click.prevent="changePassword">
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.state.serverConfiguration?.public" class="session-list" role="group">
|
||||
<h2>Sessions</h2>
|
||||
|
||||
<h3>Current session</h3>
|
||||
<Session v-if="currentSession" :session="currentSession" />
|
||||
|
||||
<template v-if="activeSessions.length > 0">
|
||||
<h3>Active sessions</h3>
|
||||
<Session
|
||||
v-for="session in activeSessions"
|
||||
:key="session.token"
|
||||
:session="session"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<h3>Other sessions</h3>
|
||||
<p v-if="store.state.sessions.length === 0">Loading…</p>
|
||||
<p v-else-if="otherSessions.length === 0">
|
||||
<em>You are not currently logged in to any other device.</em>
|
||||
</p>
|
||||
<Session
|
||||
v-for="session in otherSessions"
|
||||
v-else
|
||||
:key="session.token"
|
||||
:session="session"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import socket from "../../js/socket";
|
||||
import RevealPassword from "../RevealPassword.vue";
|
||||
import Session from "../Session.vue";
|
||||
import {computed, defineComponent, onMounted, PropType, ref} from "vue";
|
||||
import {useStore} from "../../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserSettings",
|
||||
components: {
|
||||
RevealPassword,
|
||||
Session,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const passwordErrors = {
|
||||
missing_fields: "Please fill in all fields",
|
||||
password_mismatch: "Both new password fields must match",
|
||||
password_incorrect: "The current password field does not match your account password",
|
||||
update_failed: "Failed to update your password",
|
||||
};
|
||||
|
||||
const passwordChangeStatus = ref<{
|
||||
success: boolean;
|
||||
error: keyof typeof passwordErrors;
|
||||
}>();
|
||||
|
||||
const old_password = ref("");
|
||||
const new_password = ref("");
|
||||
const verify_password = ref("");
|
||||
|
||||
const currentSession = computed(() => {
|
||||
return store.state.sessions.find((item) => item.current);
|
||||
});
|
||||
|
||||
const activeSessions = computed(() => {
|
||||
return store.state.sessions.filter((item) => !item.current && item.active > 0);
|
||||
});
|
||||
|
||||
const otherSessions = computed(() => {
|
||||
return store.state.sessions.filter((item) => !item.current && !item.active);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
socket.emit("sessions:get");
|
||||
});
|
||||
|
||||
const changePassword = () => {
|
||||
const data = {
|
||||
old_password: old_password.value,
|
||||
new_password: new_password.value,
|
||||
verify_password: verify_password.value,
|
||||
};
|
||||
|
||||
if (!data.old_password || !data.new_password || !data.verify_password) {
|
||||
passwordChangeStatus.value = {
|
||||
success: false,
|
||||
error: "missing_fields",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.new_password !== data.verify_password) {
|
||||
passwordChangeStatus.value = {
|
||||
success: false,
|
||||
error: "password_mismatch",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
socket.once("change-password", (response) => {
|
||||
// TODO type
|
||||
passwordChangeStatus.value = response as any;
|
||||
});
|
||||
|
||||
socket.emit("change-password", data);
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
passwordChangeStatus,
|
||||
passwordErrors,
|
||||
currentSession,
|
||||
activeSessions,
|
||||
otherSessions,
|
||||
changePassword,
|
||||
old_password,
|
||||
new_password,
|
||||
verify_password,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
179
client/components/Settings/Appearance.vue
Normal file
179
client/components/Settings/Appearance.vue
Normal file
|
@ -0,0 +1,179 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2>Messages</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input :checked="store.state.settings.motd" type="checkbox" name="motd" />
|
||||
Show <abbr title="Message Of The Day">MOTD</abbr>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.showSeconds"
|
||||
type="checkbox"
|
||||
name="showSeconds"
|
||||
/>
|
||||
Include seconds in timestamp
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.use12hClock"
|
||||
type="checkbox"
|
||||
name="use12hClock"
|
||||
/>
|
||||
Use 12-hour timestamps
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="store.state.serverConfiguration?.prefetch">
|
||||
<h2>Link previews</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input :checked="store.state.settings.media" type="checkbox" name="media" />
|
||||
Auto-expand media
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input :checked="store.state.settings.links" type="checkbox" name="links" />
|
||||
Auto-expand websites
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<h2 id="label-status-messages">
|
||||
Status messages
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="Joins, parts, quits, kicks, nick changes, and mode changes"
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</h2>
|
||||
<div role="group" aria-labelledby="label-status-messages">
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.statusMessages === 'shown'"
|
||||
type="radio"
|
||||
name="statusMessages"
|
||||
value="shown"
|
||||
/>
|
||||
Show all status messages individually
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.statusMessages === 'condensed'"
|
||||
type="radio"
|
||||
name="statusMessages"
|
||||
value="condensed"
|
||||
/>
|
||||
Condense status messages together
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.statusMessages === 'hidden'"
|
||||
type="radio"
|
||||
name="statusMessages"
|
||||
value="hidden"
|
||||
/>
|
||||
Hide all status messages
|
||||
</label>
|
||||
</div>
|
||||
<h2>Visual Aids</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.coloredNicks"
|
||||
type="checkbox"
|
||||
name="coloredNicks"
|
||||
/>
|
||||
Enable colored nicknames
|
||||
</label>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.autocomplete"
|
||||
type="checkbox"
|
||||
name="autocomplete"
|
||||
/>
|
||||
Enable autocomplete
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<label for="nickPostfix" class="opt">
|
||||
Nick autocomplete postfix
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="Nick autocomplete postfix (for example a comma)"
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="nickPostfix"
|
||||
:value="store.state.settings.nickPostfix"
|
||||
type="text"
|
||||
name="nickPostfix"
|
||||
class="input"
|
||||
placeholder="Nick autocomplete postfix (e.g. ', ')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2>Theme</h2>
|
||||
<div>
|
||||
<label for="theme-select" class="sr-only">Theme</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
:value="store.state.settings.theme"
|
||||
name="theme"
|
||||
class="input"
|
||||
>
|
||||
<option
|
||||
v-for="theme in store.state.serverConfiguration?.themes"
|
||||
:key="theme.name"
|
||||
:value="theme.name"
|
||||
>
|
||||
{{ theme.displayName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Custom Stylesheet</h2>
|
||||
<label for="user-specified-css-input" class="sr-only">
|
||||
Custom stylesheet. You can override any style with CSS here.
|
||||
</label>
|
||||
<textarea
|
||||
id="user-specified-css-input"
|
||||
:value="store.state.settings.userStyles"
|
||||
class="input"
|
||||
name="userStyles"
|
||||
placeholder="/* You can override any style with CSS here */"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
textarea#user-specified-css-input {
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import {useStore} from "../../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "AppearanceSettings",
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
175
client/components/Settings/General.vue
Normal file
175
client/components/Settings/General.vue
Normal file
|
@ -0,0 +1,175 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="canRegisterProtocol || hasInstallPromptEvent">
|
||||
<h2>Native app</h2>
|
||||
<button
|
||||
v-if="hasInstallPromptEvent"
|
||||
type="button"
|
||||
class="btn"
|
||||
@click.prevent="nativeInstallPrompt"
|
||||
>
|
||||
Add The Lounge to Home screen
|
||||
</button>
|
||||
<button
|
||||
v-if="canRegisterProtocol"
|
||||
type="button"
|
||||
class="btn"
|
||||
@click.prevent="registerProtocol"
|
||||
>
|
||||
Open irc:// URLs with The Lounge
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="store.state.serverConfiguration?.fileUpload">
|
||||
<h2>File uploads</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.uploadCanvas"
|
||||
type="checkbox"
|
||||
name="uploadCanvas"
|
||||
/>
|
||||
Attempt to remove metadata from images before uploading
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="This option renders the image into a canvas element to remove metadata from the image.
|
||||
This may break orientation if your browser does not support that."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!store.state.serverConfiguration?.public">
|
||||
<h2>Settings synchronisation</h2>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.syncSettings"
|
||||
type="checkbox"
|
||||
name="syncSettings"
|
||||
/>
|
||||
Synchronize settings with other clients
|
||||
</label>
|
||||
<template v-if="!store.state.settings.syncSettings">
|
||||
<div v-if="store.state.serverHasSettings" class="settings-sync-panel">
|
||||
<p>
|
||||
<strong>Warning:</strong> Checking this box will override the settings of
|
||||
this client with those stored on the server.
|
||||
</p>
|
||||
<p>
|
||||
Use the button below to enable synchronization, and override any settings
|
||||
already synced to the server.
|
||||
</p>
|
||||
<button type="button" class="btn btn-small" @click="onForceSyncClick">
|
||||
Sync settings and enable
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="settings-sync-panel">
|
||||
<p>
|
||||
<strong>Warning:</strong> No settings have been synced before. Enabling this
|
||||
will sync all settings of this client as the base for other clients.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="!store.state.serverConfiguration?.public">
|
||||
<h2>Automatic away message</h2>
|
||||
|
||||
<label class="opt">
|
||||
<label for="awayMessage" class="sr-only">Automatic away message</label>
|
||||
<input
|
||||
id="awayMessage"
|
||||
:value="store.state.settings.awayMessage"
|
||||
type="text"
|
||||
name="awayMessage"
|
||||
class="input"
|
||||
placeholder="Away message if The Lounge is not open"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, onMounted, ref} from "vue";
|
||||
import {useStore} from "../../js/store";
|
||||
import {BeforeInstallPromptEvent} from "../../js/types";
|
||||
|
||||
let installPromptEvent: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault();
|
||||
installPromptEvent = e as BeforeInstallPromptEvent;
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: "GeneralSettings",
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const canRegisterProtocol = ref(false);
|
||||
|
||||
const hasInstallPromptEvent = computed(() => {
|
||||
// TODO: This doesn't hide the button after clicking
|
||||
return installPromptEvent !== null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Enable protocol handler registration if supported,
|
||||
// and the network configuration is not locked
|
||||
canRegisterProtocol.value =
|
||||
!!window.navigator.registerProtocolHandler &&
|
||||
!store.state.serverConfiguration?.lockNetwork;
|
||||
});
|
||||
|
||||
const nativeInstallPrompt = () => {
|
||||
if (!installPromptEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
installPromptEvent.prompt().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
installPromptEvent = null;
|
||||
};
|
||||
|
||||
const onForceSyncClick = () => {
|
||||
store.dispatch("settings/syncAll", true).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
store
|
||||
.dispatch("settings/update", {
|
||||
name: "syncSettings",
|
||||
value: true,
|
||||
sync: true,
|
||||
})
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const registerProtocol = () => {
|
||||
const uri = document.location.origin + document.location.pathname + "?uri=%s";
|
||||
// @ts-expect-error
|
||||
// the third argument is deprecated but recommended for compatibility: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler
|
||||
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
|
||||
// @ts-expect-error
|
||||
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
canRegisterProtocol,
|
||||
hasInstallPromptEvent,
|
||||
nativeInstallPrompt,
|
||||
onForceSyncClick,
|
||||
registerProtocol,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
103
client/components/Settings/Navigation.vue
Normal file
103
client/components/Settings/Navigation.vue
Normal file
|
@ -0,0 +1,103 @@
|
|||
<template>
|
||||
<!-- 220px is the width of the sidebar, and we add 100px to allow for the text -->
|
||||
<aside class="settings-menu">
|
||||
<h2>Settings</h2>
|
||||
<ul role="navigation" aria-label="Settings tabs">
|
||||
<SettingTabItem name="General" class-name="general" to="" />
|
||||
<SettingTabItem name="Appearance" class-name="appearance" to="appearance" />
|
||||
<SettingTabItem name="Notifications" class-name="notifications" to="notifications" />
|
||||
<SettingTabItem name="Account" class-name="account" to="account" />
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.settings-menu {
|
||||
position: fixed;
|
||||
/* top: Header + (padding bottom of h2 - border) */
|
||||
top: calc(45px + 5px);
|
||||
/* Mid page minus width of container and 30 pixels for padding */
|
||||
margin-left: calc(50% - 480px - 30px);
|
||||
}
|
||||
|
||||
/** The calculation is mobile + 2/3 of container width. Fairly arbitrary. */
|
||||
@media screen and (max-width: calc(768px + 320px)) {
|
||||
.settings-menu {
|
||||
position: static;
|
||||
width: min(480px, 100%);
|
||||
align-self: center;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-menu ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-menu li {
|
||||
font-size: 18px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.settings-menu button {
|
||||
color: var(--body-color-muted);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-menu li:not(:last-of-type) button {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-menu button::before {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-block;
|
||||
content: "";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.settings-menu .appearance::before {
|
||||
content: "\f108"; /* http://fontawesome.io/icon/desktop/ */
|
||||
}
|
||||
|
||||
.settings-menu .account::before {
|
||||
content: "\f007"; /* http://fontawesome.io/icon/user/ */
|
||||
}
|
||||
|
||||
.settings-menu .messages::before {
|
||||
content: "\f0e0"; /* http://fontawesome.io/icon/envelope/ */
|
||||
}
|
||||
|
||||
.settings-menu .notifications::before {
|
||||
content: "\f0f3"; /* http://fontawesome.io/icon/bell/ */
|
||||
}
|
||||
|
||||
.settings-menu .general::before {
|
||||
content: "\f013"; /* http://fontawesome.io/icon/cog/ */
|
||||
}
|
||||
|
||||
.settings-menu button:hover,
|
||||
.settings-menu button.active {
|
||||
color: var(--body-color);
|
||||
}
|
||||
|
||||
.settings-menu button.active {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import SettingTabItem from "./SettingTabItem.vue";
|
||||
import {defineComponent} from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SettingsTabs",
|
||||
components: {
|
||||
SettingTabItem,
|
||||
},
|
||||
});
|
||||
</script>
|
188
client/components/Settings/Notifications.vue
Normal file
188
client/components/Settings/Notifications.vue
Normal file
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<div>
|
||||
<template v-if="!store.state.serverConfiguration?.public">
|
||||
<h2>Push Notifications</h2>
|
||||
<div>
|
||||
<button
|
||||
id="pushNotifications"
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="
|
||||
store.state.pushNotificationState !== 'supported' &&
|
||||
store.state.pushNotificationState !== 'subscribed'
|
||||
"
|
||||
@click="onPushButtonClick"
|
||||
>
|
||||
<template v-if="store.state.pushNotificationState === 'subscribed'">
|
||||
Unsubscribe from push notifications
|
||||
</template>
|
||||
<template v-else-if="store.state.pushNotificationState === 'loading'">
|
||||
Loading…
|
||||
</template>
|
||||
<template v-else> Subscribe to push notifications </template>
|
||||
</button>
|
||||
<div v-if="store.state.pushNotificationState === 'nohttps'" class="error">
|
||||
<strong>Warning</strong>: Push notifications are only supported over HTTPS
|
||||
connections.
|
||||
</div>
|
||||
<div v-if="store.state.pushNotificationState === 'unsupported'" class="error">
|
||||
<strong>Warning</strong>:
|
||||
<span>Push notifications are not supported by your browser.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h2>Browser Notifications</h2>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
id="desktopNotifications"
|
||||
:checked="store.state.settings.desktopNotifications"
|
||||
:disabled="store.state.desktopNotificationState === 'nohttps'"
|
||||
type="checkbox"
|
||||
name="desktopNotifications"
|
||||
/>
|
||||
Enable browser notifications<br />
|
||||
<div v-if="store.state.desktopNotificationState === 'unsupported'" class="error">
|
||||
<strong>Warning</strong>: Notifications are not supported by your browser.
|
||||
</div>
|
||||
<div
|
||||
v-if="store.state.desktopNotificationState === 'nohttps'"
|
||||
id="warnBlockedDesktopNotifications"
|
||||
class="error"
|
||||
>
|
||||
<strong>Warning</strong>: Notifications are only supported over HTTPS
|
||||
connections.
|
||||
</div>
|
||||
<div
|
||||
v-if="store.state.desktopNotificationState === 'blocked'"
|
||||
id="warnBlockedDesktopNotifications"
|
||||
class="error"
|
||||
>
|
||||
<strong>Warning</strong>: Notifications are blocked by your browser.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.notification"
|
||||
type="checkbox"
|
||||
name="notification"
|
||||
/>
|
||||
Enable notification sound
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="opt">
|
||||
<button id="play" @click.prevent="playNotification">Play sound</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="opt">
|
||||
<input
|
||||
:checked="store.state.settings.notifyAllMessages"
|
||||
type="checkbox"
|
||||
name="notifyAllMessages"
|
||||
/>
|
||||
Enable notification for all messages
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.state.serverConfiguration?.public">
|
||||
<label class="opt">
|
||||
<label for="highlights" class="opt">
|
||||
Custom highlights
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will trigger a highlight."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="highlights"
|
||||
:value="store.state.settings.highlights"
|
||||
type="text"
|
||||
name="highlights"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.state.serverConfiguration?.public">
|
||||
<label class="opt">
|
||||
<label for="highlightExceptions" class="opt">
|
||||
Highlight exceptions
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-delay"
|
||||
aria-label="If a message contains any of these comma-separated
|
||||
expressions, it will not trigger a highlight even if it contains
|
||||
your nickname or expressions defined in custom highlights."
|
||||
>
|
||||
<button class="extra-help" />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="highlightExceptions"
|
||||
:value="store.state.settings.highlightExceptions"
|
||||
type="text"
|
||||
name="highlightExceptions"
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
placeholder="Comma-separated, e.g.: word, some more words, anotherword"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent} from "vue";
|
||||
import {useStore} from "../../js/store";
|
||||
import webpush from "../../js/webpush";
|
||||
|
||||
export default defineComponent({
|
||||
name: "NotificationSettings",
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const isIOS = computed(
|
||||
() =>
|
||||
[
|
||||
"iPad Simulator",
|
||||
"iPhone Simulator",
|
||||
"iPod Simulator",
|
||||
"iPad",
|
||||
"iPhone",
|
||||
"iPod",
|
||||
].includes(navigator.platform) ||
|
||||
// iPad on iOS 13 detection
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
);
|
||||
|
||||
const playNotification = () => {
|
||||
const pop = new Audio();
|
||||
pop.src = "audio/pop.wav";
|
||||
|
||||
// eslint-disable-next-line
|
||||
pop.play();
|
||||
};
|
||||
|
||||
const onPushButtonClick = () => {
|
||||
webpush.togglePushSubscription();
|
||||
};
|
||||
|
||||
return {
|
||||
isIOS,
|
||||
store,
|
||||
playNotification,
|
||||
onPushButtonClick,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
43
client/components/Settings/SettingTabItem.vue
Normal file
43
client/components/Settings/SettingTabItem.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<li :aria-label="name" role="tab" :aria-selected="route.name === name" aria-controls="settings">
|
||||
<router-link v-slot:default="{navigate, isExactActive}" :to="'/settings/' + to" custom>
|
||||
<button
|
||||
:class="['icon', className, {active: isExactActive}]"
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SettingTabListItem",
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
|
||||
return {
|
||||
route,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
269
client/components/Sidebar.vue
Normal file
269
client/components/Sidebar.vue
Normal file
|
@ -0,0 +1,269 @@
|
|||
<template>
|
||||
<aside id="sidebar" ref="sidebar">
|
||||
<div class="scrollable-area">
|
||||
<div class="logo-container">
|
||||
<img
|
||||
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
|
||||
class="logo"
|
||||
alt="The Lounge"
|
||||
role="presentation"
|
||||
/>
|
||||
<img
|
||||
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
|
||||
class="logo-inverted"
|
||||
alt="The Lounge"
|
||||
role="presentation"
|
||||
/>
|
||||
<span
|
||||
v-if="isDevelopment"
|
||||
title="The Lounge has been built in development mode"
|
||||
:style="{
|
||||
backgroundColor: '#ff9e18',
|
||||
color: '#000',
|
||||
padding: '2px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
}"
|
||||
>DEVELOPER</span
|
||||
>
|
||||
</div>
|
||||
<NetworkList />
|
||||
</div>
|
||||
<footer id="footer">
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||
aria-label="Connect to network"
|
||||
><router-link
|
||||
v-slot:default="{navigate, isActive}"
|
||||
to="/connect"
|
||||
role="tab"
|
||||
aria-controls="connect"
|
||||
>
|
||||
<button
|
||||
:class="['icon', 'connect', {active: isActive}]"
|
||||
:aria-selected="isActive"
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
/> </router-link
|
||||
></span>
|
||||
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
|
||||
><router-link
|
||||
v-slot:default="{navigate, isActive}"
|
||||
to="/settings"
|
||||
role="tab"
|
||||
aria-controls="settings"
|
||||
>
|
||||
<button
|
||||
:class="['icon', 'settings', {active: isActive}]"
|
||||
:aria-selected="isActive"
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
></button> </router-link
|
||||
></span>
|
||||
<span
|
||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||
:aria-label="
|
||||
store.state.serverConfiguration?.isUpdateAvailable
|
||||
? 'Help\n(update available)'
|
||||
: 'Help'
|
||||
"
|
||||
><router-link
|
||||
v-slot:default="{navigate, isActive}"
|
||||
to="/help"
|
||||
role="tab"
|
||||
aria-controls="help"
|
||||
>
|
||||
<button
|
||||
:aria-selected="route.name === 'Help'"
|
||||
:class="[
|
||||
'icon',
|
||||
'help',
|
||||
{notified: store.state.serverConfiguration?.isUpdateAvailable},
|
||||
{active: isActive},
|
||||
]"
|
||||
@click="navigate"
|
||||
@keypress.enter="navigate"
|
||||
></button> </router-link
|
||||
></span>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, nextTick, onMounted, onUnmounted, PropType, ref} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useStore} from "../js/store";
|
||||
import NetworkList from "./NetworkList.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Sidebar",
|
||||
components: {
|
||||
NetworkList,
|
||||
},
|
||||
props: {
|
||||
overlay: {type: Object as PropType<HTMLElement | null>, required: true},
|
||||
},
|
||||
setup(props) {
|
||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const touchStartPos = ref<Touch | null>();
|
||||
const touchCurPos = ref<Touch | null>();
|
||||
const touchStartTime = ref<number>(0);
|
||||
const menuWidth = ref<number>(0);
|
||||
const menuIsMoving = ref<boolean>(false);
|
||||
const menuIsAbsolute = ref<boolean>(false);
|
||||
|
||||
const sidebar = ref<HTMLElement | null>(null);
|
||||
|
||||
const toggle = (state: boolean) => {
|
||||
store.commit("sidebarOpen", state);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const touch = (touchCurPos.value = e.touches.item(0));
|
||||
|
||||
if (
|
||||
!touch ||
|
||||
!touchStartPos.value ||
|
||||
!touchStartPos.value.screenX ||
|
||||
!touchStartPos.value.screenY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let distX = touch.screenX - touchStartPos.value.screenX;
|
||||
const distY = touch.screenY - touchStartPos.value.screenY;
|
||||
|
||||
if (!menuIsMoving.value) {
|
||||
// tan(45°) is 1. Gestures in 0°-45° (< 1) are considered horizontal, so
|
||||
// menu must be open; gestures in 45°-90° (>1) are considered vertical, so
|
||||
// chat windows must be scrolled.
|
||||
if (Math.abs(distY / distX) >= 1) {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
onTouchEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const devicePixelRatio = window.devicePixelRatio || 2;
|
||||
|
||||
if (Math.abs(distX) > devicePixelRatio) {
|
||||
store.commit("sidebarDragging", true);
|
||||
menuIsMoving.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not animate the menu on desktop view
|
||||
if (!menuIsAbsolute.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.state.sidebarOpen) {
|
||||
distX += menuWidth.value;
|
||||
}
|
||||
|
||||
if (distX > menuWidth.value) {
|
||||
distX = menuWidth.value;
|
||||
} else if (distX < 0) {
|
||||
distX = 0;
|
||||
}
|
||||
|
||||
if (sidebar.value) {
|
||||
sidebar.value.style.transform = "translate3d(" + distX.toString() + "px, 0, 0)";
|
||||
}
|
||||
|
||||
if (props.overlay) {
|
||||
props.overlay.style.opacity = `${distX / menuWidth.value}`;
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = touchCurPos.value.screenX - touchStartPos.value.screenX;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (
|
||||
absDiff > menuWidth.value / 2 ||
|
||||
(Date.now() - touchStartTime.value < 180 && absDiff > 50)
|
||||
) {
|
||||
toggle(diff > 0);
|
||||
}
|
||||
|
||||
document.body.removeEventListener("touchmove", onTouchMove);
|
||||
document.body.removeEventListener("touchend", onTouchEnd);
|
||||
|
||||
store.commit("sidebarDragging", false);
|
||||
|
||||
touchStartPos.value = null;
|
||||
touchCurPos.value = null;
|
||||
touchStartTime.value = 0;
|
||||
menuIsMoving.value = false;
|
||||
|
||||
void nextTick(() => {
|
||||
if (sidebar.value) {
|
||||
sidebar.value.style.transform = "";
|
||||
}
|
||||
|
||||
if (props.overlay) {
|
||||
props.overlay.style.opacity = "";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (!sidebar.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartPos.value = touchCurPos.value = e.touches.item(0);
|
||||
|
||||
if (e.touches.length !== 1) {
|
||||
onTouchEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = window.getComputedStyle(sidebar.value);
|
||||
|
||||
menuWidth.value = parseFloat(styles.width);
|
||||
menuIsAbsolute.value = styles.position === "absolute";
|
||||
|
||||
if (
|
||||
!store.state.sidebarOpen ||
|
||||
(touchStartPos.value?.screenX && touchStartPos.value.screenX > menuWidth.value)
|
||||
) {
|
||||
touchStartTime.value = Date.now();
|
||||
|
||||
document.body.addEventListener("touchmove", onTouchMove, {passive: true});
|
||||
document.body.addEventListener("touchend", onTouchEnd, {passive: true});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.body.addEventListener("touchstart", onTouchStart, {passive: true});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.removeEventListener("touchstart", onTouchStart);
|
||||
});
|
||||
|
||||
const isPublic = () => document.body.classList.contains("public");
|
||||
|
||||
return {
|
||||
isDevelopment,
|
||||
store,
|
||||
route,
|
||||
sidebar,
|
||||
toggle,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPublic,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
19
client/components/SidebarToggle.vue
Normal file
19
client/components/SidebarToggle.vue
Normal file
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<button class="lt" aria-label="Toggle channel list" @click="store.commit('toggleSidebar')" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SidebarToggle",
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
45
client/components/Special/ListBans.vue
Normal file
45
client/components/Special/ListBans.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<table class="ban-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Banned</th>
|
||||
<th class="banned_by">Banned By</th>
|
||||
<th class="banned_at">Banned At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ban in channel.data" :key="ban.hostmask">
|
||||
<td class="hostmask"><ParsedMessage :network="network" :text="ban.hostmask" /></td>
|
||||
<td class="banned_by">{{ ban.banned_by }}</td>
|
||||
<td class="banned_at">{{ localetime(ban.banned_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import localeTime from "../../js/helpers/localetime";
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import type {ClientNetwork, ClientChan} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ListBans",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup() {
|
||||
const localetime = (date: number | Date) => {
|
||||
return localeTime(date);
|
||||
};
|
||||
|
||||
return {
|
||||
localetime,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
36
client/components/Special/ListChannels.vue
Normal file
36
client/components/Special/ListChannels.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<span v-if="channel.data.text">{{ channel.data.text }}</span>
|
||||
<table v-else class="channel-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="channel">Channel</th>
|
||||
<th class="users">Users</th>
|
||||
<th class="topic">Topic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="chan in channel.data" :key="chan.channel">
|
||||
<td class="channel"><ParsedMessage :network="network" :text="chan.channel" /></td>
|
||||
<td class="users">{{ chan.num_users }}</td>
|
||||
<td class="topic"><ParsedMessage :network="network" :text="chan.topic" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientChan, ClientNetwork} from "../../js/types";
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ListChannels",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
});
|
||||
</script>
|
39
client/components/Special/ListIgnored.vue
Normal file
39
client/components/Special/ListIgnored.vue
Normal file
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<table class="ignore-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Hostmask</th>
|
||||
<th class="when">Ignored At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in channel.data" :key="user.hostmask">
|
||||
<td class="hostmask"><ParsedMessage :network="network" :text="user.hostmask" /></td>
|
||||
<td class="when">{{ localetime(user.when) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientChan} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ListIgnored",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
localetime,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
43
client/components/Special/ListInvites.vue
Normal file
43
client/components/Special/ListInvites.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<table class="invite-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hostmask">Invited</th>
|
||||
<th class="invitened_by">Invited By</th>
|
||||
<th class="invitened_at">Invited At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="invite in channel.data" :key="invite.hostmask">
|
||||
<td class="hostmask">
|
||||
<ParsedMessage :network="network" :text="invite.hostmask" />
|
||||
</td>
|
||||
<td class="invitened_by">{{ invite.invited_by }}</td>
|
||||
<td class="invitened_at">{{ localetime(invite.invited_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ParsedMessage from "../ParsedMessage.vue";
|
||||
import localetime from "../../js/helpers/localetime";
|
||||
import {defineComponent, PropType} from "vue";
|
||||
import {ClientNetwork, ClientChan} from "../../js/types";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ListInvites",
|
||||
components: {
|
||||
ParsedMessage,
|
||||
},
|
||||
props: {
|
||||
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
localetime: (date: Date) => localetime(date),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
84
client/components/Username.vue
Normal file
84
client/components/Username.vue
Normal file
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<span
|
||||
:class="['user', {[nickColor]: store.state.settings.coloredNicks}, {active: active}]"
|
||||
:data-name="user.nick"
|
||||
role="button"
|
||||
v-on="onHover ? {mouseenter: hover} : {}"
|
||||
@click.prevent="openContextMenu"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
><slot>{{ mode }}{{ user.nick }}</slot></span
|
||||
>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {computed, defineComponent, PropType} from "vue";
|
||||
import {UserInMessage} from "../../shared/types/msg";
|
||||
import eventbus from "../js/eventbus";
|
||||
import colorClass from "../js/helpers/colorClass";
|
||||
import type {ClientChan, ClientNetwork} from "../js/types";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
type UsernameUser = Partial<UserInMessage> & {
|
||||
mode?: string;
|
||||
nick: string;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "Username",
|
||||
props: {
|
||||
user: {
|
||||
// TODO: UserInMessage shouldn't be necessary here.
|
||||
type: Object as PropType<UsernameUser | UserInMessage>,
|
||||
required: true,
|
||||
},
|
||||
active: Boolean,
|
||||
onHover: {
|
||||
type: Function as PropType<(user: UserInMessage) => void>,
|
||||
required: false,
|
||||
},
|
||||
channel: {type: Object as PropType<ClientChan>, required: false},
|
||||
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||
},
|
||||
setup(props) {
|
||||
const mode = computed(() => {
|
||||
// Message objects have a singular mode, but user objects have modes array
|
||||
if (props.user.modes) {
|
||||
return props.user.modes[0];
|
||||
}
|
||||
|
||||
return props.user.mode;
|
||||
});
|
||||
|
||||
// TODO: Nick must be ! because our user prop union includes UserInMessage
|
||||
const nickColor = computed(() => colorClass(props.user.nick!));
|
||||
|
||||
const hover = () => {
|
||||
if (props.onHover) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return props.onHover(props.user as UserInMessage);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const openContextMenu = (event: Event) => {
|
||||
eventbus.emit("contextmenu:user", {
|
||||
event: event,
|
||||
user: props.user,
|
||||
network: props.network,
|
||||
channel: props.channel,
|
||||
});
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
|
||||
return {
|
||||
mode,
|
||||
nickColor,
|
||||
hover,
|
||||
openContextMenu,
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
66
client/components/VersionChecker.vue
Normal file
66
client/components/VersionChecker.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div id="version-checker" :class="[store.state.versionStatus]">
|
||||
<p v-if="store.state.versionStatus === 'loading'">Checking for updates…</p>
|
||||
<p v-if="store.state.versionStatus === 'new-version'">
|
||||
The Lounge <b>{{ store.state.versionData?.latest.version }}</b>
|
||||
<template v-if="store.state.versionData?.latest.prerelease"> (pre-release) </template>
|
||||
is now available.
|
||||
<br />
|
||||
|
||||
<a :href="store.state.versionData?.latest.url" target="_blank" rel="noopener">
|
||||
Read more on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="store.state.versionStatus === 'new-packages'">
|
||||
The Lounge is up to date, but there are out of date packages Run
|
||||
<code>thelounge upgrade</code> on the server to upgrade packages.
|
||||
</p>
|
||||
<template v-if="store.state.versionStatus === 'up-to-date'">
|
||||
<p>The Lounge is up to date!</p>
|
||||
|
||||
<button
|
||||
v-if="store.state.versionDataExpired"
|
||||
id="check-now"
|
||||
class="btn btn-small"
|
||||
@click="checkNow"
|
||||
>
|
||||
Check now
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="store.state.versionStatus === 'error'">
|
||||
<p>Information about latest release could not be retrieved.</p>
|
||||
|
||||
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted} from "vue";
|
||||
import socket from "../js/socket";
|
||||
import {useStore} from "../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "VersionChecker",
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const checkNow = () => {
|
||||
store.commit("versionData", null);
|
||||
store.commit("versionStatus", "loading");
|
||||
socket.emit("changelog");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.state.versionData) {
|
||||
checkNow();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
checkNow,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
93
client/components/Windows/Changelog.vue
Normal file
93
client/components/Windows/Changelog.vue
Normal file
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div id="changelog" class="window" aria-label="Changelog">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<div class="container">
|
||||
<router-link id="back-to-help" to="/help">« Help</router-link>
|
||||
|
||||
<template
|
||||
v-if="store.state.versionData?.current && store.state.versionData?.current.version"
|
||||
>
|
||||
<h1 class="title">
|
||||
Release notes for {{ store.state.versionData.current.version }}
|
||||
</h1>
|
||||
|
||||
<template v-if="store.state.versionData.current.changelog">
|
||||
<h3>Introduction</h3>
|
||||
<div
|
||||
ref="changelog"
|
||||
class="changelog-text"
|
||||
v-html="store.state.versionData.current.changelog"
|
||||
></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Unable to retrieve changelog for current release from GitHub.</p>
|
||||
<p>
|
||||
<a
|
||||
v-if="store.state.serverConfiguration?.version"
|
||||
:href="`https://github.com/thelounge/thelounge/releases/tag/v${store.state.serverConfiguration?.version}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View release notes for this version on GitHub</a
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else>Loading changelog…</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted, onUpdated, ref} from "vue";
|
||||
import socket from "../../js/socket";
|
||||
import {useStore} from "../../js/store";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Changelog",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const changelog = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const patchChangelog = () => {
|
||||
if (!changelog.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = changelog.value.querySelectorAll("a");
|
||||
|
||||
links.forEach((link) => {
|
||||
// Make sure all links will open a new tab instead of exiting the application
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener");
|
||||
|
||||
if (link.querySelector("img")) {
|
||||
// Add required metadata to image links, to support built-in image viewer
|
||||
link.classList.add("toggle-thumbnail");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.state.versionData) {
|
||||
socket.emit("changelog");
|
||||
}
|
||||
|
||||
patchChangelog();
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
patchChangelog();
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
117
client/components/Windows/Connect.vue
Normal file
117
client/components/Windows/Connect.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<NetworkForm :handle-submit="handleSubmit" :defaults="defaults" :disabled="disabled" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
|
||||
import socket from "../../js/socket";
|
||||
import {useStore} from "../../js/store";
|
||||
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Connect",
|
||||
components: {
|
||||
NetworkForm,
|
||||
},
|
||||
props: {
|
||||
queryParams: Object,
|
||||
},
|
||||
setup(props) {
|
||||
const store = useStore();
|
||||
|
||||
const disabled = ref(false);
|
||||
|
||||
const handleSubmit = (data: Record<string, any>) => {
|
||||
disabled.value = true;
|
||||
socket.emit("network:new", data);
|
||||
};
|
||||
|
||||
const parseOverrideParams = (params?: Record<string, string>) => {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsedParams: Record<string, any> = {};
|
||||
|
||||
for (let key of Object.keys(params)) {
|
||||
let value = params[key];
|
||||
|
||||
// Param can contain multiple values in an array if its supplied more than once
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0];
|
||||
}
|
||||
|
||||
// Support `channels` as a compatibility alias with other clients
|
||||
if (key === "channels") {
|
||||
key = "join";
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
store.state.serverConfiguration?.defaults,
|
||||
key
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// When the network is locked, URL overrides should not affect disabled fields
|
||||
if (
|
||||
store.state.serverConfiguration?.lockNetwork &&
|
||||
["name", "host", "port", "tls", "rejectUnauthorized"].includes(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "join") {
|
||||
value = value
|
||||
.split(",")
|
||||
.map((chan) => {
|
||||
if (!chan.match(/^[#&!+]/)) {
|
||||
return `#${chan}`;
|
||||
}
|
||||
|
||||
return chan;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// Override server provided defaults with parameters passed in the URL if they match the data type
|
||||
switch (typeof store.state.serverConfiguration?.defaults[key]) {
|
||||
case "boolean":
|
||||
if (value === "0" || value === "false") {
|
||||
parsedParams[key] = false;
|
||||
} else {
|
||||
parsedParams[key] = !!value;
|
||||
}
|
||||
|
||||
break;
|
||||
case "number":
|
||||
parsedParams[key] = Number(value);
|
||||
break;
|
||||
case "string":
|
||||
parsedParams[key] = String(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedParams;
|
||||
};
|
||||
|
||||
const defaults = ref<Partial<NetworkFormDefaults>>(
|
||||
Object.assign(
|
||||
{},
|
||||
store.state.serverConfiguration?.defaults,
|
||||
parseOverrideParams(props.queryParams)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
defaults,
|
||||
disabled,
|
||||
handleSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
879
client/components/Windows/Help.vue
Normal file
879
client/components/Windows/Help.vue
Normal file
|
@ -0,0 +1,879 @@
|
|||
<template>
|
||||
<div id="help" class="window" role="tabpanel" aria-label="Help">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1 class="title">Help</h1>
|
||||
|
||||
<h2 class="help-version-title">
|
||||
<span>About The Lounge</span>
|
||||
<small>
|
||||
v{{ store.state.serverConfiguration?.version }} (<router-link
|
||||
id="view-changelog"
|
||||
to="/changelog"
|
||||
>release notes</router-link
|
||||
>)
|
||||
</small>
|
||||
</h2>
|
||||
|
||||
<div class="about">
|
||||
<VersionChecker />
|
||||
|
||||
<template v-if="store.state.serverConfiguration?.gitCommit">
|
||||
<p>
|
||||
The Lounge is running from source (<a
|
||||
:href="`https://github.com/thelounge/thelounge/tree/${store.state.serverConfiguration?.gitCommit}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>commit <code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
|
||||
>).
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Compare
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.gitCommit}...master`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>between
|
||||
<code>{{ store.state.serverConfiguration?.gitCommit }}</code> and
|
||||
<code>master</code></a
|
||||
>
|
||||
to see what you are missing
|
||||
</li>
|
||||
<li>
|
||||
Compare
|
||||
<a
|
||||
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.version}...${store.state.serverConfiguration?.gitCommit}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>between
|
||||
<code>{{ store.state.serverConfiguration?.version }}</code> and
|
||||
<code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
|
||||
>
|
||||
to see your local changes
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://thelounge.chat/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="website-link"
|
||||
>Website</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://thelounge.chat/docs/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="documentation-link"
|
||||
>Documentation</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://github.com/thelounge/thelounge/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="report-issue-link"
|
||||
>Report an issue…</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 v-if="isTouch">Gestures</h2>
|
||||
|
||||
<div v-if="isTouch" class="help-item">
|
||||
<div class="subject gesture">Single-Finger Swipe Left</div>
|
||||
<div class="description">
|
||||
<p>Hide sidebar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isTouch" class="help-item">
|
||||
<div class="subject gesture">Single-Finger Swipe Right</div>
|
||||
<div class="description">
|
||||
<p>Show sidebar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isTouch" class="help-item">
|
||||
<div class="subject gesture">Two-Finger Swipe Left</div>
|
||||
<div class="description">
|
||||
<p>Switch to the next window in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isTouch" class="help-item">
|
||||
<div class="subject gesture">Two-Finger Swipe Right</div>
|
||||
<div class="description">
|
||||
<p>Switch to the previous window in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>↓</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>↓</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the next lobby in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>↑</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>↑</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the previous lobby in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>←</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>←</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Collapse current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Shift</kbd> <kbd>→</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⇧</kbd> <kbd>→</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Expand current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>↓</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>↓</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the next window in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>↑</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>↑</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the previous window in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Ctrl</kbd> <kbd>↓</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⌘</kbd> <kbd>↓</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the next window with unread messages in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>Ctrl</kbd> <kbd>↑</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>⌘</kbd> <kbd>↑</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the previous window with unread messages in the channel list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>A</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>A</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the first window with unread messages.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>S</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>S</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle sidebar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>U</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>U</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle channel user list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>J</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>J</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle jump to channel switcher.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>M</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>M</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Toggle recent mentions popup.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>/</kbd></span>
|
||||
<span v-else><kbd>⌥</kbd> <kbd>/</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Switch to the help menu.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span><kbd>Esc</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Close current contextual window (context menu, image viewer, topic edit,
|
||||
etc) and remove focus from input.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Formatting Shortcuts</h2>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>K</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>K</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark any text typed after this shortcut to be colored. After hitting this
|
||||
shortcut, enter an integer in the range
|
||||
<code>0—15</code> to select the desired color, or use the autocompletion
|
||||
menu to choose a color name (see below).
|
||||
</p>
|
||||
<p>
|
||||
Background color can be specified by putting a comma and another integer in
|
||||
the range <code>0—15</code> after the foreground color number
|
||||
(autocompletion works too).
|
||||
</p>
|
||||
<p>
|
||||
A color reference can be found
|
||||
<a
|
||||
href="https://modern.ircdocs.horse/formatting.html#colors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>here</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>B</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>B</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-bold">bold</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>U</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>U</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-underline">underlined</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>I</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>I</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-italic">italics</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>S</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>S</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-strikethrough">struck through</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>M</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>M</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut as
|
||||
<span class="irc-monospace">monospaced</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<span v-if="!isApple"><kbd>Ctrl</kbd> <kbd>O</kbd></span>
|
||||
<span v-else><kbd>⌘</kbd> <kbd>O</kbd></span>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Mark all text typed after this shortcut to be reset to its original
|
||||
formatting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Autocompletion</h2>
|
||||
|
||||
<p>
|
||||
To auto-complete nicknames, channels, commands, and emoji, type one of the
|
||||
characters below to open a suggestion list. Use the <kbd>↑</kbd> and
|
||||
<kbd>↓</kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or
|
||||
<kbd>Enter</kbd> (or by clicking the desired item).
|
||||
</p>
|
||||
<p>Autocompletion can be disabled in settings.</p>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>@</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Nickname</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>#</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Channel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Commands (see list of commands below)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>:</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Emoji (note: requires two search characters, to avoid conflicting with
|
||||
common emoticons like <code>:)</code>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Commands</h2>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/away [message]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Mark yourself as away with an optional message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/back</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Remove your away status (set with <code>/away</code>).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ban nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Ban (<code>+b</code>) a user from the current channel. This can be a
|
||||
nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/banlist</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Load the banlist for the current channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/collapse</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Collapse all previews in the current channel (opposite of
|
||||
<code>/expand</code>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/connect host [port]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Connect to a new IRC network. If <code>port</code> starts with a
|
||||
<code>+</code> sign, the connection will be made secure using TLS.
|
||||
</p>
|
||||
<p>Alias: <code>/server</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ctcp target cmd [args]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Send a <abbr title="Client-to-client protocol">CTCP</abbr>
|
||||
request. Read more about this on
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Client-to-client_protocol"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>the dedicated Wikipedia article</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/deop nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Remove op (<code>-o</code>) from one or several users in the current
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/devoice nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Remove voice (<code>-v</code>) from one or several users in the current
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/disconnect [message]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Disconnect from the current network with an optionally-provided message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/expand</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Expand all previews in the current channel (opposite of
|
||||
<code>/collapse</code>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/invite nick [channel]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Invite a user to the specified channel. If
|
||||
<code>channel</code> is omitted, user will be invited to the current
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ignore nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Block any messages from the specified user on the current network. This can
|
||||
be a nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/ignorelist</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Load the list of ignored users for the current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/join channel [password]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Join a channel. Password is only needed in protected channels and can
|
||||
usually be omitted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/kick nick [reason]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Kick a user from the current channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/kickban nick [reason]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Kick and ban (<code>+b</code>) a user from the current channel. Unlike
|
||||
<code>/ban</code>, only nicknames (and not host masks) can be used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/list</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Retrieve a list of available channels on this network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/me message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Send an action message to the current channel. The Lounge will display it
|
||||
inline, as if the message was posted in the third person.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/mode flags [args]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Set the given flags to the current channel if the active window is a
|
||||
channel, another user if the active window is a private message window, or
|
||||
yourself if the current window is a server window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/msg channel message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Send a message to the specified channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/mute [...channel]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Prevent messages from generating any feedback for a channel. This turns off
|
||||
the highlight indicator, hides mentions and inhibits push notifications.
|
||||
Muting a network lobby mutes the entire network. Not specifying any channel
|
||||
target mutes the current channel. Revert with <code>/unmute</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/nick newnick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Change your nickname on the current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/notice channel message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Sends a notice message to the specified channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/op nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Give op (<code>+o</code>) to one or several users in the current channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/part [channel]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Close the specified channel or private message window, or the current
|
||||
channel if <code>channel</code> is omitted.
|
||||
</p>
|
||||
<p>Aliases: <code>/close</code>, <code>/leave</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/rejoin</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Leave and immediately rejoin the current channel. Useful to quickly get op
|
||||
from ChanServ in an empty channel, for example.
|
||||
</p>
|
||||
<p>Alias: <code>/cycle</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/query nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Send a private message to the specified user.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/quit [message]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Disconnect from the current network with an optional message.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/raw message</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Send a raw message to the current IRC network.</p>
|
||||
<p>Aliases: <code>/quote</code>, <code>/send</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/slap nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Slap someone in the current channel with a trout!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.state.settings.searchEnabled" class="help-item">
|
||||
<div class="subject">
|
||||
<code>/search query</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Search for messages in the current channel / user</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/topic [newtopic]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Get the topic in the current channel. If <code>newtopic</code> is specified,
|
||||
sets the topic in the current channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/unban nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Unban (<code>-b</code>) a user from the current channel. This can be a
|
||||
nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/unignore nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Unblock messages from the specified user on the current network. This can be
|
||||
a nickname or a hostmask.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/unmute [...channel]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Un-mutes the given channel(s) or the current channel if no channel is
|
||||
provided. See <code>/mute</code> for more information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/voice nick [...nick]</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>
|
||||
Give voice (<code>+v</code>) to one or several users in the current channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<div class="subject">
|
||||
<code>/whois nick</code>
|
||||
</div>
|
||||
<div class="description">
|
||||
<p>Retrieve information about the given user on the current network.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref} from "vue";
|
||||
import {useStore} from "../../js/store";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
import VersionChecker from "../VersionChecker.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Help",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
VersionChecker,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const isApple = navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false;
|
||||
const isTouch = navigator.maxTouchPoints > 0;
|
||||
|
||||
return {
|
||||
isApple,
|
||||
isTouch,
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
67
client/components/Windows/NetworkEdit.vue
Normal file
67
client/components/Windows/NetworkEdit.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<NetworkForm
|
||||
v-if="networkData"
|
||||
:handle-submit="handleSubmit"
|
||||
:defaults="networkData"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, onMounted, ref, watch} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {switchToChannel} from "../../js/router";
|
||||
import socket from "../../js/socket";
|
||||
import {useStore} from "../../js/store";
|
||||
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "NetworkEdit",
|
||||
components: {
|
||||
NetworkForm,
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const disabled = ref(false);
|
||||
const networkData = ref<NetworkFormDefaults | null>(null);
|
||||
|
||||
const setNetworkData = () => {
|
||||
socket.emit("network:get", String(route.params.uuid || ""));
|
||||
networkData.value = store.getters.findNetwork(String(route.params.uuid || ""));
|
||||
};
|
||||
|
||||
const handleSubmit = (data: {uuid: string; name: string}) => {
|
||||
disabled.value = true;
|
||||
socket.emit("network:edit", data);
|
||||
|
||||
// TODO: move networks to vuex and update state when the network info comes in
|
||||
const network = store.getters.findNetwork(data.uuid);
|
||||
|
||||
if (network) {
|
||||
network.name = network.channels[0].name = data.name;
|
||||
|
||||
switchToChannel(network.channels[0]);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.params.uuid,
|
||||
() => {
|
||||
setNetworkData();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
setNetworkData();
|
||||
});
|
||||
|
||||
return {
|
||||
disabled,
|
||||
networkData,
|
||||
handleSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
321
client/components/Windows/SearchResults.vue
Normal file
321
client/components/Windows/SearchResults.vue
Normal file
|
@ -0,0 +1,321 @@
|
|||
<template>
|
||||
<div id="chat-container" class="window">
|
||||
<div
|
||||
id="chat"
|
||||
:class="{
|
||||
'time-seconds': store.state.settings.showSeconds,
|
||||
'time-12h': store.state.settings.use12hClock,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="chat-view"
|
||||
data-type="search-results"
|
||||
aria-label="Search results"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div v-if="network && channel" class="header">
|
||||
<SidebarToggle />
|
||||
<span class="title"
|
||||
>Searching in <span class="channel-name">{{ channel.name }}</span> for</span
|
||||
>
|
||||
<span class="topic">{{ route.query.q }}</span>
|
||||
<MessageSearchForm :network="network" :channel="channel" />
|
||||
<button
|
||||
class="close"
|
||||
aria-label="Close search window"
|
||||
title="Close search window"
|
||||
@click="closeSearch"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="network && channel" class="chat-content">
|
||||
<div ref="chat" class="chat" tabindex="-1">
|
||||
<div v-show="moreResultsAvailable" class="show-more">
|
||||
<button
|
||||
ref="loadMoreButton"
|
||||
:disabled="
|
||||
!!store.state.messageSearchPendingQuery ||
|
||||
!store.state.isConnected
|
||||
"
|
||||
class="btn"
|
||||
@click="onShowMoreClick"
|
||||
>
|
||||
<span v-if="store.state.messageSearchPendingQuery">Loading…</span>
|
||||
<span v-else>Show older messages</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.state.messageSearchPendingQuery && !offset"
|
||||
class="search-status"
|
||||
>
|
||||
Searching…
|
||||
</div>
|
||||
<div v-else-if="!messages.length && !offset" class="search-status">
|
||||
No results found.
|
||||
</div>
|
||||
<div
|
||||
class="messages"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
<div
|
||||
v-for="(message, id) in messages"
|
||||
:key="message.id"
|
||||
class="result"
|
||||
@click="jump(message, id)"
|
||||
>
|
||||
<DateMarker
|
||||
v-if="shouldDisplayDateMarker(message, id)"
|
||||
:key="message.id + '-date'"
|
||||
:message="message"
|
||||
/>
|
||||
<Message
|
||||
:key="message.id"
|
||||
:channel="channel"
|
||||
:network="network"
|
||||
:message="message"
|
||||
:data-id="message.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.channel-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import socket from "../../js/socket";
|
||||
import eventbus from "../../js/eventbus";
|
||||
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
import Message from "../Message.vue";
|
||||
import MessageSearchForm from "../MessageSearchForm.vue";
|
||||
import DateMarker from "../DateMarker.vue";
|
||||
import {watch, computed, defineComponent, nextTick, ref, onMounted, onUnmounted} from "vue";
|
||||
import type {ClientMessage} from "../../js/types";
|
||||
|
||||
import {useStore} from "../../js/store";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {switchToChannel} from "../../js/router";
|
||||
import {SearchQuery} from "../../../shared/types/storage";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SearchResults",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
Message,
|
||||
DateMarker,
|
||||
MessageSearchForm,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const chat = ref<HTMLDivElement>();
|
||||
|
||||
const loadMoreButton = ref<HTMLButtonElement>();
|
||||
|
||||
const offset = ref(0);
|
||||
const moreResultsAvailable = ref(false);
|
||||
const oldScrollTop = ref(0);
|
||||
const oldChatHeight = ref(0);
|
||||
|
||||
const messages = computed(() => {
|
||||
const results = store.state.messageSearchResults?.results;
|
||||
|
||||
if (!results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
const chan = computed(() => {
|
||||
const chanId = parseInt(String(route.params.id || ""), 10);
|
||||
return store.getters.findChannel(chanId);
|
||||
});
|
||||
|
||||
const network = computed(() => {
|
||||
if (!chan.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chan.value.network;
|
||||
});
|
||||
|
||||
const channel = computed(() => {
|
||||
if (!chan.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chan.value.channel;
|
||||
});
|
||||
|
||||
const setActiveChannel = () => {
|
||||
if (!chan.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.commit("activeChannel", chan.value);
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
if (!channel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchToChannel(channel.value);
|
||||
};
|
||||
|
||||
const shouldDisplayDateMarker = (message: ClientMessage, id: number) => {
|
||||
const previousMessage = messages.value[id - 1];
|
||||
|
||||
if (!previousMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
|
||||
};
|
||||
|
||||
const clearSearchState = () => {
|
||||
offset.value = 0;
|
||||
store.commit("messageSearchResults", null);
|
||||
store.commit("messageSearchPendingQuery", null);
|
||||
};
|
||||
|
||||
const doSearch = () => {
|
||||
if (!network.value || !channel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSearchState(); // this is a new search, so we need to clear anything before that
|
||||
const query: SearchQuery = {
|
||||
networkUuid: network.value.uuid,
|
||||
channelName: channel.value.name,
|
||||
searchTerm: String(route.query.q || ""),
|
||||
offset: offset.value,
|
||||
};
|
||||
store.commit("messageSearchPendingQuery", query);
|
||||
socket.emit("search", query);
|
||||
};
|
||||
|
||||
const onShowMoreClick = () => {
|
||||
if (!chat.value || !network.value || !channel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
offset.value += 100;
|
||||
|
||||
oldScrollTop.value = chat.value.scrollTop;
|
||||
oldChatHeight.value = chat.value.scrollHeight;
|
||||
|
||||
const query: SearchQuery = {
|
||||
networkUuid: network.value.uuid,
|
||||
channelName: channel.value.name,
|
||||
searchTerm: String(route.query.q || ""),
|
||||
offset: offset.value,
|
||||
};
|
||||
store.commit("messageSearchPendingQuery", query);
|
||||
socket.emit("search", query);
|
||||
};
|
||||
|
||||
const jumpToBottom = async () => {
|
||||
await nextTick();
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
};
|
||||
|
||||
const jump = (message: ClientMessage, id: number) => {
|
||||
// TODO: Implement jumping to messages!
|
||||
// This is difficult because it means client will need to handle a potentially nonlinear message set
|
||||
// (loading IntersectionObserver both before AND after the messages)
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
doSearch();
|
||||
setActiveChannel();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
doSearch();
|
||||
setActiveChannel();
|
||||
}
|
||||
);
|
||||
|
||||
watch(messages, async () => {
|
||||
moreResultsAvailable.value = !!(
|
||||
messages.value.length && !(messages.value.length % 100)
|
||||
);
|
||||
|
||||
if (!offset.value) {
|
||||
await jumpToBottom();
|
||||
} else {
|
||||
await nextTick();
|
||||
|
||||
const el = chat.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentChatHeight = el.scrollHeight;
|
||||
el.scrollTop = oldScrollTop.value + currentChatHeight - oldChatHeight.value;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setActiveChannel();
|
||||
doSearch();
|
||||
|
||||
eventbus.on("escapekey", closeSearch);
|
||||
eventbus.on("re-search", doSearch);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
eventbus.off("escapekey", closeSearch);
|
||||
eventbus.off("re-search", doSearch);
|
||||
clearSearchState();
|
||||
});
|
||||
|
||||
return {
|
||||
chat,
|
||||
loadMoreButton,
|
||||
messages,
|
||||
moreResultsAvailable,
|
||||
network,
|
||||
channel,
|
||||
route,
|
||||
offset,
|
||||
store,
|
||||
setActiveChannel,
|
||||
closeSearch,
|
||||
shouldDisplayDateMarker,
|
||||
doSearch,
|
||||
onShowMoreClick,
|
||||
jumpToBottom,
|
||||
jump,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
56
client/components/Windows/Settings.vue
Normal file
56
client/components/Windows/Settings.vue
Normal file
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div id="settings" class="window" role="tabpanel" aria-label="Settings">
|
||||
<div class="header">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
<Navigation />
|
||||
|
||||
<div class="container">
|
||||
<form ref="settingsForm" autocomplete="off" @change="onChange" @submit.prevent>
|
||||
<router-view></router-view>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent} from "vue";
|
||||
import SidebarToggle from "../SidebarToggle.vue";
|
||||
import Navigation from "../Settings/Navigation.vue";
|
||||
import {useStore} from "../../js/store";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Settings",
|
||||
components: {
|
||||
SidebarToggle,
|
||||
Navigation,
|
||||
},
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const ignore = ["old_password", "new_password", "verify_password"];
|
||||
|
||||
const name = (event.target as HTMLInputElement).name;
|
||||
|
||||
if (ignore.includes(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value: boolean | string;
|
||||
|
||||
if ((event.target as HTMLInputElement).type === "checkbox") {
|
||||
value = (event.target as HTMLInputElement).checked;
|
||||
} else {
|
||||
value = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
void store.dispatch("settings/update", {name, value, sync: true});
|
||||
};
|
||||
|
||||
return {
|
||||
onChange,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
116
client/components/Windows/SignIn.vue
Normal file
116
client/components/Windows/SignIn.vue
Normal file
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div id="sign-in" class="window" role="tabpanel" aria-label="Sign-in">
|
||||
<form class="container" method="post" action="" @submit="onSubmit">
|
||||
<img
|
||||
src="img/logo-vertical-transparent-bg.svg"
|
||||
class="logo"
|
||||
alt="The Lounge"
|
||||
width="256"
|
||||
height="170"
|
||||
/>
|
||||
<img
|
||||
src="img/logo-vertical-transparent-bg-inverted.svg"
|
||||
class="logo-inverted"
|
||||
alt="The Lounge"
|
||||
width="256"
|
||||
height="170"
|
||||
/>
|
||||
|
||||
<label for="signin-username">Username</label>
|
||||
<input
|
||||
id="signin-username"
|
||||
v-model="username"
|
||||
class="input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
autocomplete="username"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div class="password-container">
|
||||
<label for="signin-password">Password</label>
|
||||
<RevealPassword v-slot:default="slotProps">
|
||||
<input
|
||||
id="signin-password"
|
||||
v-model="password"
|
||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||
class="input"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</RevealPassword>
|
||||
</div>
|
||||
|
||||
<div v-if="errorShown" class="error">Authentication failed.</div>
|
||||
|
||||
<button :disabled="inFlight" type="submit" class="btn">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import storage from "../../js/localStorage";
|
||||
import socket from "../../js/socket";
|
||||
import RevealPassword from "../RevealPassword.vue";
|
||||
import {defineComponent, onBeforeUnmount, onMounted, ref} from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SignIn",
|
||||
components: {
|
||||
RevealPassword,
|
||||
},
|
||||
setup() {
|
||||
const inFlight = ref(false);
|
||||
const errorShown = ref(false);
|
||||
|
||||
const username = ref(storage.get("user") || "");
|
||||
const password = ref("");
|
||||
|
||||
const onAuthFailed = () => {
|
||||
inFlight.value = false;
|
||||
errorShown.value = true;
|
||||
};
|
||||
|
||||
const onSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!username.value || !password.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight.value = true;
|
||||
errorShown.value = false;
|
||||
|
||||
const values = {
|
||||
user: username.value,
|
||||
password: password.value,
|
||||
};
|
||||
|
||||
storage.set("user", values.user);
|
||||
|
||||
socket.emit("auth:perform", values);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
socket.on("auth:failed", onAuthFailed);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
socket.off("auth:failed", onAuthFailed);
|
||||
});
|
||||
|
||||
return {
|
||||
inFlight,
|
||||
errorShown,
|
||||
username,
|
||||
password,
|
||||
onSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
1185
client/css/bootstrap.css
vendored
1185
client/css/bootstrap.css
vendored
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue