mirror of
https://github.com/thelounge/thelounge.git
synced 2024-06-08 08:42:17 +02:00
Compare commits
1316 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 | |||
8d119630eb | |||
5233fb2dbb | |||
234938ed4b | |||
3630ab8519 | |||
c463d1ddd3 | |||
44a8925b8c | |||
7216b8124b | |||
eb7f9ab298 | |||
6aabd9bacb |
|
@ -1,2 +1,3 @@
|
||||||
public/
|
public/
|
||||||
coverage/
|
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,69 +0,0 @@
|
||||||
---
|
|
||||||
root: true
|
|
||||||
|
|
||||||
parserOptions:
|
|
||||||
ecmaVersion: 2018
|
|
||||||
|
|
||||||
env:
|
|
||||||
es6: true
|
|
||||||
browser: true
|
|
||||||
mocha: true
|
|
||||||
node: true
|
|
||||||
|
|
||||||
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-shadow: error
|
|
||||||
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
|
|
||||||
vue/require-default-prop: off
|
|
||||||
vue/no-v-html: off
|
|
||||||
vue/no-use-v-if-with-v-for: off
|
|
||||||
|
|
||||||
plugins:
|
|
||||||
- vue
|
|
||||||
|
|
||||||
extends:
|
|
||||||
- eslint:recommended
|
|
||||||
- plugin:vue/recommended
|
|
||||||
- prettier
|
|
||||||
- prettier/vue
|
|
2
.github/ISSUE_TEMPLATE/Bug_Report.md
vendored
2
.github/ISSUE_TEMPLATE/Bug_Report.md
vendored
|
@ -4,7 +4,7 @@ about: Create a bug report
|
||||||
labels: "Type: Bug"
|
labels: "Type: Bug"
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Have a question? Join #thelounge on freenode -->
|
<!-- Have a question? Join #thelounge on Libera.Chat -->
|
||||||
|
|
||||||
- _Node version:_
|
- _Node version:_
|
||||||
- _Browser version:_
|
- _Browser version:_
|
||||||
|
|
2
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
2
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
|
@ -4,7 +4,7 @@ about: Request a new feature
|
||||||
labels: "Type: Feature"
|
labels: "Type: Feature"
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Have a question? Join #thelounge on freenode. -->
|
<!-- Have a question? Join #thelounge on Libera.Chat. -->
|
||||||
<!-- Make sure to check the existing issues prior to submitting your suggestion. -->
|
<!-- Make sure to check the existing issues prior to submitting your suggestion. -->
|
||||||
|
|
||||||
### Feature Description
|
### 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
.github/SUPPORT.md
vendored
2
.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)
|
- 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)
|
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).
|
(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
|
right away, but this channel is full of nice people who will be happy to
|
||||||
help you.
|
help you.
|
||||||
|
|
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
|
@ -1,5 +1,8 @@
|
||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -8,11 +11,20 @@ jobs:
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
include:
|
||||||
node_version: [
|
# EOL: April 2025
|
||||||
10.x, # EOL: April 2021
|
- os: macOS-latest
|
||||||
12.x, # EOL: April 2022
|
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 }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
@ -20,7 +32,7 @@ jobs:
|
||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
|
|
||||||
|
|
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
@ -1,5 +1,9 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: v*
|
tags: v*
|
||||||
|
@ -14,8 +18,9 @@ jobs:
|
||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
node-version: "latest"
|
||||||
registry-url: "https://registry.npmjs.org/"
|
registry-url: "https://registry.npmjs.org/"
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
|
@ -31,13 +36,13 @@ jobs:
|
||||||
|
|
||||||
- name: Publish latest
|
- name: Publish latest
|
||||||
if: "!contains(github.ref, '-')"
|
if: "!contains(github.ref, '-')"
|
||||||
run: npm publish --tag latest
|
run: npm publish --tag latest --provenance
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: Publish next
|
- name: Publish next
|
||||||
if: contains(github.ref, '-')
|
if: contains(github.ref, '-')
|
||||||
run: npm publish --tag next
|
run: npm publish --tag next --provenance
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -6,3 +6,4 @@ package-lock.json
|
||||||
|
|
||||||
coverage/
|
coverage/
|
||||||
public/
|
public/
|
||||||
|
dist/
|
||||||
|
|
24
.npmignore
24
.npmignore
|
@ -1,24 +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/constants.js
|
|
||||||
!client/js/helpers/ircmessageparser/findLinks.js
|
|
||||||
!client/js/helpers/ircmessageparser/cleanIrcMessage.js
|
|
||||||
!client/index.html.tpl
|
|
||||||
|
|
||||||
public/js/bundle.vendor.js.map
|
|
||||||
coverage/
|
|
||||||
scripts/
|
|
||||||
test/
|
|
||||||
appveyor.yml
|
|
||||||
webpack.config*.js
|
|
||||||
postcss.config.js
|
|
||||||
renovate.json
|
|
|
@ -1,8 +1,10 @@
|
||||||
coverage/
|
coverage/
|
||||||
public/
|
public/
|
||||||
|
dist/
|
||||||
test/fixtures/.thelounge/logs/
|
test/fixtures/.thelounge/logs/
|
||||||
|
test/fixtures/.thelounge/certificates/
|
||||||
test/fixtures/.thelounge/storage/
|
test/fixtures/.thelounge/storage/
|
||||||
|
test/fixtures/.thelounge/sts-policies.json
|
||||||
*.log
|
*.log
|
||||||
*.png
|
*.png
|
||||||
*.svg
|
*.svg
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
arrowParens: always
|
|
||||||
bracketSpacing: false
|
|
||||||
printWidth: 100
|
|
||||||
trailingComma: "es5"
|
|
||||||
overrides:
|
|
||||||
- files: "*.webmanifest"
|
|
||||||
options:
|
|
||||||
parser: json
|
|
|
@ -1,18 +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:
|
|
||||||
|
|
||||||
# we have autoprefixer
|
|
||||||
at-rule-no-vendor-prefix: true
|
|
||||||
media-feature-name-no-vendor-prefix: true
|
|
||||||
property-no-vendor-prefix: true
|
|
||||||
selector-no-vendor-prefix: true
|
|
||||||
value-no-vendor-prefix: true
|
|
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"}
|
||||||
|
}
|
897
CHANGELOG.md
897
CHANGELOG.md
|
@ -4,6 +4,903 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
<!-- New entries go after this line -->
|
<!-- New entries go after this line -->
|
||||||
|
|
||||||
|
## v4.4.3 - 2024-04-01
|
||||||
|
|
||||||
|
The Lounge finally gains the ability to automatically clean up sqlite databases.
|
||||||
|
Note that cleaning existing, large databases can take a significant amount of time
|
||||||
|
and running a database `VACUUM` will use up ~2x the current DB disc space for a short period.
|
||||||
|
|
||||||
|
If you enable the storagePolicy, stop the running instance and run `thelounge storage clean`.
|
||||||
|
This will force a full cleanup once, rather than doing so incrementally and will release all the
|
||||||
|
disc space back to the OS.
|
||||||
|
|
||||||
|
As usual, we follow the Node.js release schedule, so the minimum Node.js version required is now 18.
|
||||||
|
|
||||||
|
Many thanks to all the contributors to this release, be that documentation, code or maintaining the packages.
|
||||||
|
Your help is greatly appreciated!
|
||||||
|
|
||||||
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.1...v4.4.3)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Sign in: use v-model ([`c5326e8`](https://github.com/thelounge/thelounge/commit/c5326e87958b1e99ca9405da5c8d17e3f45c983c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Add comments explaining behavior when echo-message is not available ([`43a2b39`](https://github.com/thelounge/thelounge/commit/43a2b397a2efc65c7214893846831376bb880138) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix semver for prerelease versions #4744 ([`8aa5e33`](https://github.com/thelounge/thelounge/commit/8aa5e33b1d9e0a56e51481c227bf7d61fdd7b21f) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: add migrations support and introduce primary key ([`2ef8b37`](https://github.com/thelounge/thelounge/commit/2ef8b3700945deb9a113ddf4e3010ad36556deef) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- test/link: use helper for url creation ([`c6b1913`](https://github.com/thelounge/thelounge/commit/c6b1913b919421ab2b70093218422a390d822c75) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- test/storage: use helper for url creation ([`79fae26`](https://github.com/thelounge/thelounge/commit/79fae26f396081b6f557ae7b4f0c8fd4649b6a74) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Respect bind setting for all outgoing requests ([`3af4ad1`](https://github.com/thelounge/thelounge/commit/3af4ad1076330428da41f4205bb069d714b2a4e2) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- bump emoji-regex to latest ([`ed0a47f`](https://github.com/thelounge/thelounge/commit/ed0a47fe2c10a2512832c9365a863967f9fc1ee0) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- use shebang for generate-emoji script ([`1a1153a`](https://github.com/thelounge/thelounge/commit/1a1153aed638de0e5e2ca4089cb7656bbfa4394a) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Respect bind setting for all outgoing requests ([`2878f87`](https://github.com/thelounge/thelounge/commit/2878f87879cab30eabedbe2376507dae33295f22) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- store: use return type over a type cast ([#4770](https://github.com/thelounge/thelounge/pull/4770) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- don't crash on rDNS failure ([`8c54cd5`](https://github.com/thelounge/thelounge/commit/8c54cd50d8431481a70dec26a66a5343f2bbbd2c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: fix typo fetch_rollbacks ([`884a92c`](https://github.com/thelounge/thelounge/commit/884a92c74bb669ff9a94c5a1c164912a9bd9891b) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: don't modify global array during tests ([`ec75ff0`](https://github.com/thelounge/thelounge/commit/ec75ff00cb8fdcef1857749ce6d033860e1ca157) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: return new version in downgrade() ([`d1561f8`](https://github.com/thelounge/thelounge/commit/d1561f8ebccacd0277d185626f3737bfd23bc99e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- cli: don't fail if stderr is not in json format ([`97f553e`](https://github.com/thelounge/thelounge/commit/97f553eea8ed4a57f6d760a767425159f6451e08) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: use variadic function for serialize_run ([`60ddf17`](https://github.com/thelounge/thelounge/commit/60ddf17124af8e451412b14a11910ded894979d8) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: accept db connection string ([`aec8d0b`](https://github.com/thelounge/thelounge/commit/aec8d0b03341691a0211d172538afc61560a919c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: implement deleteMessages ([`14d9ff2`](https://github.com/thelounge/thelounge/commit/14d9ff247d51e77640bc0f37464804eadc822dd7) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- introduce storage cleaner ([`74aff7e`](https://github.com/thelounge/thelounge/commit/74aff7ee5a9440a653859879390191031f81153e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- cleaner: expose cli task to do cleaning + vacuum ([`21b1152`](https://github.com/thelounge/thelounge/commit/21b1152f5357f47586456949cadfb9876a0613da) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- wire up storage cleaner upon server start ([`b0ca8e5`](https://github.com/thelounge/thelounge/commit/b0ca8e51fb21b23859f95406f41dfe1ce273f419) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: add msg type index to speed up cleaner ([`edb1226`](https://github.com/thelounge/thelounge/commit/edb1226b474e9dc74d096201220d8e675821ac21) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- add storage cleaner ([`7f0b721`](https://github.com/thelounge/thelounge/commit/7f0b7217906abf90343f5b91dc7ceaa650dd058f) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- scripts: fix generate-config-doc, handle usage errors ([#4807](https://github.com/thelounge/thelounge/pull/4807) by [@flotwig](https://github.com/flotwig))
|
||||||
|
- router: don't use next() in router guards ([#4783](https://github.com/thelounge/thelounge/pull/4783) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- linkify: Add web+ schema support ([`ae6bae6`](https://github.com/thelounge/thelounge/commit/ae6bae69ac2c915c3dcac4262168da46f8eddf39) by [@SoniEx2](https://github.com/SoniEx2))
|
||||||
|
- linkify: simplify noscheme detection logic ([`dd24cb1`](https://github.com/thelounge/thelounge/commit/dd24cb13002b76ba0a67abfa11faedaa455df828) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Add shortcut to navigate between channels with undread msgs ([`daabb76`](https://github.com/thelounge/thelounge/commit/daabb7678172fc6b6d7c6eebc6fad40b6f84ea39) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Remove husky, add githooks-install ([#4826](https://github.com/thelounge/thelounge/pull/4826) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Testing setup ([#4825](https://github.com/thelounge/thelounge/pull/4825) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Remove Node.js 16 from package.json and testing matrix ([`113e9bd`](https://github.com/thelounge/thelounge/commit/113e9bd2fb9a5154c048234d8ebbd8c0a61070d1) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- server: remove version from CTCP response ([`45563d9`](https://github.com/thelounge/thelounge/commit/45563d9a5938ae4fa46da8a2d6c51fc829ebb910) by [@flotwig](https://github.com/flotwig))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
On the [website repository](https://github.com/thelounge/thelounge.github.io):
|
||||||
|
|
||||||
|
- Merge branch 'localInstall' ([`8c0d5a5`](https://github.com/thelounge/thelounge.github.io/commit/8c0d5a58075fc1035f5c71675847823751e1f98d) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- docs: update docker image to point to the new ghcr.io repository ([`5d7c993`](https://github.com/thelounge/thelounge.github.io/commit/5d7c993b9e26050b482550cb3f16aa11e0b99d9e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Add "Hide all chat messages containing a link in a specific channel" … ([`993cf8b`](https://github.com/thelounge/thelounge.github.io/commit/993cf8b00e35ffeff1c20d122defc32d09e236b3) by [@zDEFz](https://github.com/zDEFz))
|
||||||
|
- ctcp: remove stale link to code (#273) ([`379c34d`](https://github.com/thelounge/thelounge.github.io/commit/379c34d88aa73dd86078af7757a4536bb9958e02) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- docs: sync config.js.md (add prefetchTimeout, update ldap) (#275) ([`51dfc80`](https://github.com/thelounge/thelounge.github.io/commit/51dfc803415946e985c36317ea362ba625c67a3c) by [@flotwig](https://github.com/flotwig))
|
||||||
|
- Removing #thelounge-scandinavia due to inactivity (#278) ([`403cc6a`](https://github.com/thelounge/thelounge.github.io/commit/403cc6aa05cd30a0f9a86b81369ec0c9f1ffd24f) by [@fnutt](https://github.com/fnutt))
|
||||||
|
- Nodejs documentation link update (#277) ([`06e4725`](https://github.com/thelounge/thelounge.github.io/commit/06e47254cc6b98eabe4d527b1ce6be6f7ea7b9eb) by [@xfisbest](https://github.com/xfisbest))
|
||||||
|
- Add installation instructions for Gentoo (#276) ([`52be432`](https://github.com/thelounge/thelounge.github.io/commit/52be432b36cabc7a9d393a07e7702e3aebff8075) by [@rahilarious](https://github.com/rahilarious))
|
||||||
|
|
||||||
|
### Dependency updates
|
||||||
|
|
||||||
|
- chore(deps): update dependency webpack-hot-middleware to v2.25.4 ([`06f1387`](https://github.com/thelounge/thelounge/commit/06f1387f7b5ff374b52bc4aeac06d6e936bc00f4) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @vue/test-utils to v2.4.0 ([`303f53f`](https://github.com/thelounge/thelounge/commit/303f53fe72a6cde53410821b2d59c81db90d308a) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency postcss to v8.4.26 ([`54ff563`](https://github.com/thelounge/thelounge/commit/54ff56324714bd5c6221250d02491f20b7ede6df) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/linkify-it to v3.0.3 ([`2985727`](https://github.com/thelounge/thelounge/commit/2985727996c1e84fefce06e5c2a0da02a8b6ccb6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/bcryptjs to v2.4.4 ([`48301b1`](https://github.com/thelounge/thelounge/commit/48301b1ca31f0eb145695f320c81d0047e6883e6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- build(deps): bump word-wrap from 1.2.3 to 1.2.5 ([`08413c7`](https://github.com/thelounge/thelounge/commit/08413c7b6b78f460bdee31239a87e6f86e14dda2) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency postcss to v8.4.31 [security] ([`ff77a33`](https://github.com/thelounge/thelounge/commit/ff77a3366305c23180e6e509f5f39d285edca8d1) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/cheerio to v0.22.33 ([`b686059`](https://github.com/thelounge/thelounge/commit/b686059c6bf2f2014497d7dceb093422c5fb8fc2) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/content-disposition to v0.5.7 ([`bcca111`](https://github.com/thelounge/thelounge/commit/bcca111a4dd42e8b648acee1da9548a0c677d056) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/lodash to v4.14.200 ([`d4d5a8e`](https://github.com/thelounge/thelounge/commit/d4d5a8e386df60c69826fb9b1c63c138a1503640) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/mousetrap to v1.6.13 ([`250433c`](https://github.com/thelounge/thelounge/commit/250433c87549b59f34cd4d3933364a3766cf587e) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update actions/setup-node action to v4 ([`785ec0a`](https://github.com/thelounge/thelounge/commit/785ec0a0e26f2233ddea6f51ef16cd5cc5e14e40) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/bcryptjs to v2.4.5 ([`b506966`](https://github.com/thelounge/thelounge/commit/b506966b08fba11ab9b8b88268c9371dac78c314) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/is-utf8 to v0.2.2 ([`59de6af`](https://github.com/thelounge/thelounge/commit/59de6afd3fdbeb894e8cf39321c786220bbcf66b) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/bcryptjs to v2.4.6 ([`2f40d9d`](https://github.com/thelounge/thelounge/commit/2f40d9dbcca6fff43f1a66a2e0efb826e22cd4b4) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/cheerio to v0.22.35 ([`73a529a`](https://github.com/thelounge/thelounge/commit/73a529acea765705c1903762106d8f8f3221e6fc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/content-disposition to v0.5.8 ([`aa95032`](https://github.com/thelounge/thelounge/commit/aa95032760761cc7e28d802ed9bec93d4a807335) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/is-utf8 to v0.2.3 ([`eaa70ca`](https://github.com/thelounge/thelounge/commit/eaa70caad7e578af4bf5f1603c5008b9159a04e6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/linkify-it to v3.0.5 ([`1d2fdd9`](https://github.com/thelounge/thelounge/commit/1d2fdd95b0ee698bbdc85eb70fd02f47d46e86da) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/lodash to v4.14.202 ([`fe50a90`](https://github.com/thelounge/thelounge/commit/fe50a9023509412b8c6d981053b469e27b5a49c0) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/mousetrap to v1.6.15 ([`a77fbb8`](https://github.com/thelounge/thelounge/commit/a77fbb894ff550cabf7d6f54e06296babdeb2b67) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/node to v17.0.45 ([`e2fda1f`](https://github.com/thelounge/thelounge/commit/e2fda1fb84da9cdbb445d6ebfe0f9795cb83633d) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- build(deps): bump semver from 7.3.5 to 7.5.2 ([`447a237`](https://github.com/thelounge/thelounge/commit/447a237fc6d54e59e563e982a406e16011c57b7a) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- build(deps): bump get-func-name from 2.0.0 to 2.0.2 ([`d308e74`](https://github.com/thelounge/thelounge/commit/d308e7418367e880f1b5454ade8267f5996bd035) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- build(deps): bump @babel/traverse from 7.18.9 to 7.23.6 ([`20227b1`](https://github.com/thelounge/thelounge/commit/20227b174c4bf375af1168c60ef57e6124c199f4) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- update emoji ([`607b9fc`](https://github.com/thelounge/thelounge/commit/607b9fc96a9ca933154dcc082fb2bb6dd545a2db) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency cheerio to v1.0.0-rc.12 ([`3e21bfc`](https://github.com/thelounge/thelounge/commit/3e21bfcbea579c08f0c02d692e59242653b553b3) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency webpack-hot-middleware to v2.25.4 ([`57c4d55`](https://github.com/thelounge/thelounge/commit/57c4d5513cfe6f0770a89330932dc07623c35e26) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency @vue/test-utils to v2.4.0 ([`4f9ca3e`](https://github.com/thelounge/thelounge/commit/4f9ca3e1923837f2886a58df4605255229b200b2) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency @types/lodash to v4.14.195 ([`2e019a2`](https://github.com/thelounge/thelounge/commit/2e019a2fdba684ad4cef15f55e514ae7a1bc8edf) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency @types/chai to v4.3.5 ([`816b768`](https://github.com/thelounge/thelounge/commit/816b7686e36aaac36371a5bfbcd2648443bc4e48) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency postcss to v8.4.26 ([`430a865`](https://github.com/thelounge/thelounge/commit/430a865e9fd7218ac8b0deaa6fc0841341b823ab) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update @types/mousetrap ([`139ce47`](https://github.com/thelounge/thelounge/commit/139ce47b73a4907da0e2737dbb245bc686330ec1) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- bump caniuse-lite ([`22ae594`](https://github.com/thelounge/thelounge/commit/22ae594cc3d6905c82aa2238f4cd68506acf79a3) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
## v4.4.2-rc.1 - 2024-02-19 [Pre-release]
|
||||||
|
|
||||||
|
The Lounge finally gains the ability to automatically clean up sqlite databases.
|
||||||
|
Note that cleaning existing, large databases can take a significant amount of time
|
||||||
|
and running a database `VACUUM` will use up ~2x the current DB disc space for a short period.
|
||||||
|
If you enable the storagePolicy, stop the running instance and run `thelounge storage clean`.
|
||||||
|
This will force a full cleanup once, rather than doing so incrementally and will release all the
|
||||||
|
disc space back to the OS.
|
||||||
|
|
||||||
|
As usual, we follow the Node.js release schedule, so the minimum Node.js version required is now 18.
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.1...v4.4.2-rc.1)
|
||||||
|
|
||||||
|
This is a release candidate (RC) for v4.4.2 to ensure maximum stability for public release.
|
||||||
|
Bugs may be fixed, but no further features will be added until the next stable version.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.4.1 - 2023-06-13
|
||||||
|
|
||||||
|
Small bug fix release that addresses the bugs reported since v4.4.0
|
||||||
|
|
||||||
|
- fixes the image preview buttons disappearing.
|
||||||
|
- Restores the ability to change the password via the user interface.
|
||||||
|
|
||||||
|
Following the [Node.js maintenance schedule](https://nodejs.dev/en/about/releases/), The Lounge now needs at least Node.js 16 to run.
|
||||||
|
|
||||||
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0...v4.4.1) and [milestone](https://github.com/thelounge/thelounge/milestone/43?closed=1).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- linkPreview: Pass channel prop ([`9388960`](https://github.com/thelounge/thelounge/commit/93889604973eeefb3a875e3ad5c9de737638888c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- client: fix password change input ([`8f08cf3`](https://github.com/thelounge/thelounge/commit/8f08cf3d0bd5b839016000afca1c700c74193f39) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
On the [website repository](https://github.com/thelounge/thelounge.github.io):
|
||||||
|
|
||||||
|
- Document local installation of packages ([`c72092e`](https://github.com/thelounge/thelounge.github.io/commit/c72092e2f8feab66f912b2c63c5a0572b123ea29) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- docs: update docker image to point to the new ghcr.io repository ([`b43d002`](https://github.com/thelounge/thelounge.github.io/commit/b43d002584757709fff19dfdcf558c9d378f3d61) by [@williamboman](https://github.com/williamboman))
|
||||||
|
- Fix deb link ([`485570d`](https://github.com/thelounge/thelounge.github.io/commit/485570d4c4027296c546c2773272e4b44b0db06a) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- deb: directly link to latest ([`c9a8ad9`](https://github.com/thelounge/thelounge.github.io/commit/c9a8ad95bbfc62f9ef704581fc742b069ff605fe) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
- Remove unused code ([`7bce779`](https://github.com/thelounge/thelounge/commit/7bce77925449e2bcfa2db5d66dc5f808e04058c7) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- settings: make missing_field msg descriptive ([`7a9ddc0`](https://github.com/thelounge/thelounge/commit/7a9ddc01e1819da8d28860548a82736f35283ab0) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Dependency updates
|
||||||
|
|
||||||
|
- build(deps): bump socket.io-parser from 4.2.1 to 4.2.3 ([`af49ef2`](https://github.com/thelounge/thelounge/commit/af49ef21ea3fed54c0807a4d87f9c0f9f70017c3) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- bump socket.io-parser from 4.2.1 to 4.2.3 ([`4d60d9c`](https://github.com/thelounge/thelounge/commit/4d60d9c282490ad63a1ff61e57e9a6c7a5fb9684) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
## v4.4.1-rc.2 - 2023-05-27 [Pre-release]
|
||||||
|
|
||||||
|
Restore the ability to change the password via the user interface.
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0-rc.1...v4.4.1-rc.2)
|
||||||
|
|
||||||
|
This is a release candidate (RC) for v4.4.1 to ensure maximum stability for public release.
|
||||||
|
Bugs may be fixed, but no further features will be added until the next stable version.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
## v4.4.1-rc.1 - 2023-05-20 [Pre-release]
|
||||||
|
|
||||||
|
Small bug fix release that addresses the image preview buttons disappearing.
|
||||||
|
|
||||||
|
Following the [Node.js maintenance schedule](https://nodejs.dev/en/about/releases/), The Lounge now needs at least Node.js 16 to run.
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0...v4.4.1-rc.1)
|
||||||
|
|
||||||
|
This is a release candidate (RC) for v4.4.1 to ensure maximum stability for public release.
|
||||||
|
Bugs may be fixed, but no further features will be added until the next stable version.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.4.0 - 2023-04-22
|
||||||
|
|
||||||
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.1...v4.4.0) and [milestone](https://github.com/thelounge/thelounge/milestone/42?closed=1).
|
||||||
|
|
||||||
|
This is mostly a developer focused release. Max, Eric and others rewrote the whole thing in TypeScript / Vue 3,
|
||||||
|
which should make it much easier to add features and find bugs in the future. So huge kudos from the rest of the team!
|
||||||
|
|
||||||
|
Additionally, there's the obvious grab bag of fixes, dependency updates and improvements.
|
||||||
|
Settings are now grouped and easier to navigate for new users.
|
||||||
|
|
||||||
|
Following the Node.js maintenance schedule, The Lounge now needs at least Node.js 14 to run.
|
||||||
|
|
||||||
|
A big thanks to everyone who contributed in any way to this release, your help is much appreciated.
|
||||||
|
|
||||||
|
Considering that a bunch of our dependencies had security issues assigned to them, all users are advised to update to the new version.
|
||||||
|
|
||||||
|
Packagers: Considering the switch to TypeScript, the server build now emits to the dist/ folder.
|
||||||
|
You might need to adapt your build scripts.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add prefetchTimeout ([`aa7db1e`](https://github.com/thelounge/thelounge/commit/aa7db1e7f787350f4102f98b85a2e8173173f92a) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactor settings to their own tabs and routes ([#4489](https://github.com/thelounge/thelounge/pull/4489) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- allow away and back to be collapsed ([#4669](https://github.com/thelounge/thelounge/pull/4669) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Kill TL when ident can't start up (#4512) ([`37d7de7`](https://github.com/thelounge/thelounge/commit/37d7de7671cf07f8a7fb3a8b3ea32122a738b646) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- enable znc/playback even without message storage ([`c8115e2`](https://github.com/thelounge/thelounge/commit/c8115e22acf4a6e34a1546fd2fc273c76cbb7e86) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Remove node 12, add node 18. Bump minimum node version 14 (#4552) ([`9dbb6e5`](https://github.com/thelounge/thelounge/commit/9dbb6e5e1923dc1a2d3d69b0eac2778ff8cf5d3b) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- linkPreviews: Enforce TLS validity ([`621fa92`](https://github.com/thelounge/thelounge/commit/621fa92036d59aa6558df828a1ff48136eed19ce) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Use nick as a realname fallback ([`30e9f45`](https://github.com/thelounge/thelounge/commit/30e9f45fac5b675ddadf5f904f0d0f05a7cdb5f9) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Plugins: include pre-releases in compatibility lookup (#4506) ([`e4840b4`](https://github.com/thelounge/thelounge/commit/e4840b4d75ff4dc79083955ebd9dfbdd7dd7ea8a) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- install: expand ~ for local paths ([`e221e70`](https://github.com/thelounge/thelounge/commit/e221e708c1237eaa3088d97aebf8bf4869843dc6) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix the alignment of the header buttons ([#4539](https://github.com/thelounge/thelounge/pull/4539) by [@ronilaukkarinen](https://github.com/ronilaukkarinen))
|
||||||
|
- Fix user commands not working ([#4594](https://github.com/thelounge/thelounge/pull/4594) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Don't crash on oidentd socket race condition ([#4695](https://github.com/thelounge/thelounge/pull/4695) by [@maxpoulin64](https://github.com/maxpoulin64))
|
||||||
|
- cli: don't error if the user folder doesn't exist (#4508) ([`8153198`](https://github.com/thelounge/thelounge/commit/815319810c28ffe17119a5dc62f7eac33eba12f5) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix user file permissions on create (#4507) ([`d7bba32`](https://github.com/thelounge/thelounge/commit/d7bba325a73b1898edfa4299c4525749e174bbac) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: Escape '%' and '\_' in search queries. (#4487) ([`20ed3e6`](https://github.com/thelounge/thelounge/commit/20ed3e6dc5cf482e38d537444163e98b2bae0879) by [@progval](https://github.com/progval))
|
||||||
|
- set 'video/quicktime' to 'video/mp4' (#4495) ([`57b1e51`](https://github.com/thelounge/thelounge/commit/57b1e51e9f0f65e0866f5a809b12efaaf277536a) by [@xnaas](https://github.com/xnaas))
|
||||||
|
- Preserve client certificate ([`c9c8cad`](https://github.com/thelounge/thelounge/commit/c9c8cadb1a00f01d00920792cc129077aa6934fd) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Remove uploading event listeners on ChatInput unmount (#4600) ([`80f65c5`](https://github.com/thelounge/thelounge/commit/80f65c5b7276c466d2032fb3a7822fa39df3c685) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Potentially fix saving new networks (#4599) ([`d72d869`](https://github.com/thelounge/thelounge/commit/d72d8694bbea9fde7bf86275fb77b4c4c8a168ec) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Fix regex escape for prefix patterns ([`d6e1af0`](https://github.com/thelounge/thelounge/commit/d6e1af0e7dedb34dcd9932105ee4f2ddbe98e221) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix ctcp request message (#4603) ([`c8cd405`](https://github.com/thelounge/thelounge/commit/c8cd4057bc4ef19271720fc6b893b9c74e690457) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- connect: Trim white space from user input fields (#4623) ([`0fa2035`](https://github.com/thelounge/thelounge/commit/0fa203569a62ee6bc6062b781729c7d801ccb8ba) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Search: Clear earlier searches when a new one is executed ([`83e11b0`](https://github.com/thelounge/thelounge/commit/83e11b0143e599a40924cab856636beeca6df27c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix previous-source calculation (#4656) ([`073a38e`](https://github.com/thelounge/thelounge/commit/073a38ef1ef3c46740a028d4cbe7ebe4c7a08526) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix sidebar swipe flicker after letting go ([`502780c`](https://github.com/thelounge/thelounge/commit/502780c5a3e3455d977d8873506f1be51946fa68) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- search: ignore searchResults if it isn't the active query ([`0ebc3a5`](https://github.com/thelounge/thelounge/commit/0ebc3a574c42185c818ca8795a56d8eb58a20f4e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- fix motd display to match settings ([#4726])(https://github.com/thelounge/thelounge/pull/4726) by [@SpaceLenore](https://github.com/SpaceLenore))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Fix misleading LDAP filiter in default config ([`f785acb`](https://github.com/thelounge/thelounge/commit/f785acb07d78ae791a24a39821a93afb81616934) by [@goodspeed34](https://github.com/goodspeed34))
|
||||||
|
- Use correct option name (filter instead of ldapFilter) in config.js c… ([`4af5fc6`](https://github.com/thelounge/thelounge/commit/4af5fc6f33b43d64adcebcbf5aa8c4dceaad493f) by [@murph](https://github.com/murph))
|
||||||
|
- Add password param to /join docs ([`8b1a4f7`](https://github.com/thelounge/thelounge/commit/8b1a4f72fa79e12b43ff3073f0d48b13d93008e7) by [@aab12345](https://github.com/aab12345))
|
||||||
|
- install: Document file: prefix in cli help ([`31739b8`](https://github.com/thelounge/thelounge/commit/31739b8ac9ff95a03c374b32cc9bce2163d05d1e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
On the [website repository](https://github.com/thelounge/thelounge.github.io):
|
||||||
|
|
||||||
|
- Link directly to themes on npm (#261) ([`410f5d0`](https://github.com/thelounge/thelounge.github.io/commit/410f5d077676cf597397b01acdc81414cc3dbc01) by [@jeremiah-rs](https://github.com/jeremiah-rs))
|
||||||
|
- Don't use yarn link for source installs ([#262](https://github.com/thelounge/thelounge.github.io/pull/262) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Add Insecure Warning CSS (#264) ([`95efa48`](https://github.com/thelounge/thelounge.github.io/commit/95efa482668af7997c7058cf01dff611efdea644) by [@aab12345](https://github.com/aab12345))
|
||||||
|
- Add custom nick colors section to custom css guide (#265) ([`63847c3`](https://github.com/thelounge/thelounge.github.io/commit/63847c346b6e49ddcdb34f5b733b57e3db8cc2df) by [@xnaas](https://github.com/xnaas))
|
||||||
|
- Fix Apache configuration syntax ([`41cb84e`](https://github.com/thelounge/thelounge.github.io/commit/41cb84ee70f5dc4a6920dfd1916fdf5eb00f190c) by [@lucaswerkmeister](https://github.com/lucaswerkmeister))
|
||||||
|
- Be more explicit about needing Yarn 1 (Classic) (#268) ([`1eff267`](https://github.com/thelounge/thelounge.github.io/commit/1eff26768a437e2bac1b62982da5ae02fdbda950) by [@SyntaxColoring](https://github.com/SyntaxColoring))
|
||||||
|
- Don't mention `npm` command for installation ([`7e936c2`](https://github.com/thelounge/thelounge.github.io/commit/7e936c2814b2902855570e928e0f13a40e17fce7) by [@SyntaxColoring](https://github.com/SyntaxColoring))
|
||||||
|
- Update reverse-proxies.md ([`afc7e29`](https://github.com/thelounge/thelounge.github.io/commit/afc7e2957211f0fa9a4f986fb4a0a03547384a6d) by [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder))
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
- Decouple server ([#4686](https://github.com/thelounge/thelounge/pull/4686) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Tests/server: Tear down test fixtures in the order they were setup ([#4715](https://github.com/thelounge/thelounge/pull/4715) by [@progval](https://github.com/progval))
|
||||||
|
- Refactor config out of Helper (#4558) ([`d4cc2dd`](https://github.com/thelounge/thelounge/commit/d4cc2dd361bd2f166924dd18efdc57634d67bc19) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Convert configs to cjs, move babel to own file, combine webpack confi… ([`c205b89`](https://github.com/thelounge/thelounge/commit/c205b895233f5d7c58ef44bad31ccee777f3b95d) by [@nemchik](https://github.com/nemchik))
|
||||||
|
- Fix yarn dev (#4574) ([`2e3d9a6`](https://github.com/thelounge/thelounge/commit/2e3d9a6265d4c0d0168729a60b319bea236e098b) by [@nemchik](https://github.com/nemchik))
|
||||||
|
- TypeScript and Vue 3 (#4559) ([`dd05ee3`](https://github.com/thelounge/thelounge/commit/dd05ee3a656cb5eb5d0ab7620dbc7a1cfa4102ab) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Added client type checking to webpack (#4619) ([`117c5fa`](https://github.com/thelounge/thelounge/commit/117c5fa3fdbd2787bc1df521627b7b07fc1522c6) by [@antoniomika](https://github.com/antoniomika))
|
||||||
|
- don't call search on a disabled msg provider ([`bea4545`](https://github.com/thelounge/thelounge/commit/bea4545abffe738dfeb025b36817490c1b5fa61d) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- extract migrations ([`f04a066`](https://github.com/thelounge/thelounge/commit/f04a06682d3690b571dc0b9720baa79b687b9465) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: error if sqlite isn't enabled but search() is called ([`cebc6d0`](https://github.com/thelounge/thelounge/commit/cebc6d069fa609de918881854414768fadc87fed) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: move export to bottom of the file ([`f6b2921`](https://github.com/thelounge/thelounge/commit/f6b292107ee4e627562d170babcb272cfa102a1e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: fix docstring ([`e62b169`](https://github.com/thelounge/thelounge/commit/e62b169a6abab4b2a0df34a5da21c92136ba3790) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: add run helper function ([`89ee537`](https://github.com/thelounge/thelounge/commit/89ee5373643d1c5cb664401de745109bf7bcb77c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: create serialize_fetchall helper function ([`cc3302e`](https://github.com/thelounge/thelounge/commit/cc3302e8743633b3b87e15fb54a964510b2466d1) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: use serialize_fetchall in getMessages ([`ee8223c`](https://github.com/thelounge/thelounge/commit/ee8223c2006ad31fc746824b495125b321da4bf8) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: use serialize_fetchall in search ([`5e1cbe3`](https://github.com/thelounge/thelounge/commit/5e1cbe32f95aca776fe4dff550a0c8c369460417) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: add serialize_get ([`bbe81bb`](https://github.com/thelounge/thelounge/commit/bbe81bb2fa9001762df90c1a267afa0239ebb7c7) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: convert migrations to async ([`f068fd4`](https://github.com/thelounge/thelounge/commit/f068fd429012c47648faf8c4d751f972062709bd) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- messageStorage: convert to async ([`d62dd3e`](https://github.com/thelounge/thelounge/commit/d62dd3e62d106009cbded2fd9af13fe9fae35ae5) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- SearchResults: remove computed search prop ([`6b617f8`](https://github.com/thelounge/thelounge/commit/6b617f893d73fb9e8304d228336cf574c29992a3) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- SearchResults: Fix search progess upon search ([`dca2024`](https://github.com/thelounge/thelounge/commit/dca202427aa543d43d18fb72ae10ffa51b3b6c60) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- SearchResults: remove dead code (#4639) ([`53f6041`](https://github.com/thelounge/thelounge/commit/53f6041f42ac36b5d69fc05cc66618ea0fe67a88) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- SearchQuery: offset is always a number ([`8095d9e`](https://github.com/thelounge/thelounge/commit/8095d9e88a0018d2ac559ab01488d2736b4fe5e6) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Search: fix off by one offset error ([`51c9ce0`](https://github.com/thelounge/thelounge/commit/51c9ce078d15efafd677cff525b681dcec51fdd5) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- keybinds: Fix invalid return ([`0765d20`](https://github.com/thelounge/thelounge/commit/0765d209f2ce204e2a3e86c56a7c2108a0487a6f) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- server: the http{,s} server can't be null ([`1597c2c`](https://github.com/thelounge/thelounge/commit/1597c2c56ec932859ebc77e31eda8c164f196388) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- make getClientConfiguration type safe ([`fd14b4a`](https://github.com/thelounge/thelounge/commit/fd14b4a17203bc043b8c9c1f371c2c5ced96eef7) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- remove VueApp from router ([`dfb4217`](https://github.com/thelounge/thelounge/commit/dfb4217167bd20232bf2bdc443454a7ea9cc1094) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- search: fix order of result merging ([`8204c34`](https://github.com/thelounge/thelounge/commit/8204c3481ad1e5eb3f59cabdb5c3c52936094b48) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- store: addMessageSearchResults shouldn't accept null ([`982816f`](https://github.com/thelounge/thelounge/commit/982816ff2015077fe2903180df6420005c73b33e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: synchronize enable() internally ([`2d4143b`](https://github.com/thelounge/thelounge/commit/2d4143b7798c9cf0600280a5a79cb9061585be0e) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- messagestorage: remove implementation details from interface ([`661d5cb`](https://github.com/thelounge/thelounge/commit/661d5cb5b0d6c3aebb9a83ac4c5115d0411b3f39) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- textStorage: rip out client instance ([`52b8a2a`](https://github.com/thelounge/thelounge/commit/52b8a2a78e62dfdcdd2313e8c7e81a7b07f383e2) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- sqlite: Remove client from sqlitestorage ([`958a948`](https://github.com/thelounge/thelounge/commit/958a948456d1a0c3c97bb60e8759e8f9f5578ac8) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix uploader mount/unmount lifecycle ([`2ce374f`](https://github.com/thelounge/thelounge/commit/2ce374fe858992c5c930b0c49bf40cba2928f839) by [@maxpoulin64](https://github.com/maxpoulin64))
|
||||||
|
- Fix git commit not being available in dist build ([`2f04150`](https://github.com/thelounge/thelounge/commit/2f04150461fbd538b09e58d8c1beb33ee0db18ce) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- network: add getLobby accessor ([`fade6a8`](https://github.com/thelounge/thelounge/commit/fade6a8d2ec5d621d761e2f6a716c5e59f4a9770) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- pluginCommand: type it and guard against bad input ([`4023323`](https://github.com/thelounge/thelounge/commit/402332340b727d7f4087b1f24dcd4eecf16b0891) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- packaging: Use an include list in package.json ([`efd24fd`](https://github.com/thelounge/thelounge/commit/efd24fd12cad9192d6f333c5a3c01c33ad23b0c6) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix incorrect typing of dehydrated networks and channels ([`76098d7`](https://github.com/thelounge/thelounge/commit/76098d7e766ad074eb6278ee487410f1f02817c3) [@progval](https://github.com/progval))
|
||||||
|
- Client: move socket connection out of the constructor ([`a049a01`](https://github.com/thelounge/thelounge/commit/a049a01aeb2b09edaaf46411bb764c14a607b343) [@progval](https://github.com/progval))
|
||||||
|
- Fix test wording ([`d58fb84`](https://github.com/thelounge/thelounge/commit/d58fb845651fe2859313c05a80cdcdebc27a8c68) [@progval](https://github.com/progval))
|
||||||
|
- Remove override of UserConfig ([`320075e`](https://github.com/thelounge/thelounge/commit/320075e376eecc0843f57b2f9b3207f8f245930e) [@progval](https://github.com/progval))
|
||||||
|
- Fix sqlite query invocation in test ([`845daba`](https://github.com/thelounge/thelounge/commit/845dabad53c4a47b6c39f7529ad02ec810c5ed48) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix config typing and make Client easier to test ([`eb509f7`](https://github.com/thelounge/thelounge/commit/eb509f7100869427d3f8b4dbd54692bf12630e67) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- server/client: refactor command input ([`4e954b9`](https://github.com/thelounge/thelounge/commit/4e954b919c86ad17f6c7f934de4aa8d6fe5b9b1d) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Clean up command input code ([`e8b6434`](https://github.com/thelounge/thelounge/commit/e8b6434144998693532ce2853c049e878f158d63) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Inline logger into changelog script ([#4717](https://github.com/thelounge/thelounge/pull/4717) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix load of channels from user config ([`0c7cc85`](https://github.com/thelounge/thelounge/commit/0c7cc85184d9f90987000ffcddfa2b9581bb96cb) Val Lorentz)
|
||||||
|
- style: Put user colors into the smallest possible scope ([`f55f772`](https://github.com/thelounge/thelounge/commit/f55f772659a505ceb8751d8728c22c810afed018) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix Morning theme nick colors ([#4690](https://github.com/thelounge/thelounge/pull/4690) by [@maxpoulin64](https://github.com/maxpoulin64))
|
||||||
|
- Publish to npm with provenance ([#4724])(https://github.com/thelounge/thelounge/pull/4724) by [@xPaw](https://github.com/xPaw))
|
||||||
|
|
||||||
|
### Dependency updates
|
||||||
|
|
||||||
|
_Aka the boring bits... It's the last section too, so feel free to gloss over it_
|
||||||
|
|
||||||
|
- fix(deps): update dependency got to v11.8.5 [security] ([#4596](https://github.com/thelounge/thelounge/pull/4596) by [@renovate](https://github.com/apps/renovate))
|
||||||
|
- `sqlite3` ([#4541](https://github.com/thelounge/thelounge/pull/4541))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.0.6 ([`da02350`](https://github.com/thelounge/thelounge/commit/da02350725291be79c0d6c5d15261a2e0ef72313) by [@renovate-bot](https://github.com/renovate-bot))
|
||||||
|
- chore(deps): update dependency @textcomplete/core to v0.1.11 (#4555) ([`99c48db`](https://github.com/thelounge/thelounge/commit/99c48dbcea2ebe08d64a38946d81301fbfe66ee2) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update babel monorepo (#4554) ([`38f1352`](https://github.com/thelounge/thelounge/commit/38f13525e6104ee332c64d2df20bfe2694bc7fe5) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency mocha to v9.2.2 (#4581) ([`194b85b`](https://github.com/thelounge/thelounge/commit/194b85be4d93813f763b06264124d5545ba8aa27) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.0.8 (#4564) ([`ddcee53`](https://github.com/thelounge/thelounge/commit/ddcee5371acfe960c53e85e97405d005953dec3c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @textcomplete/textarea to v0.1.12 ([`e972165`](https://github.com/thelounge/thelounge/commit/e97216518adb9ac7d6ef458c362a591a0f56ed14) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/content-disposition to v0.5.5 ([`740618c`](https://github.com/thelounge/thelounge/commit/740618ca499aeb2efb8ffd4f0363b5cf841a49dc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @textcomplete/core to v0.1.12 ([`0cb4791`](https://github.com/thelounge/thelounge/commit/0cb4791cd02c0fd2e578edc1366124117529ac10) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.0.10 ([`520646a`](https://github.com/thelounge/thelounge/commit/520646a212e08f971c870e6f464712a90e198d66) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- fix(deps): update dependency file-type to v16.5.4 [security] ([`0495761`](https://github.com/thelounge/thelounge/commit/0495761c4485ac86b43ced638a361b905e7ddc60) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): lock file maintenance ([`57ed37c`](https://github.com/thelounge/thelounge/commit/57ed37c1fda4024ae655de2defdf4af68ade69fe) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- Revert "chore(deps): update dependency @textcomplete/core to v0.1.12" ([`3240997`](https://github.com/thelounge/thelounge/commit/32409973478ecb88290447faa7f2639a6d5c4d1f) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.1.2 ([`5a803cc`](https://github.com/thelounge/thelounge/commit/5a803ccd239e42fe8853b4c615e82ef2c64bbc14) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @vue/test-utils to v2.2.1 ([`cb17f8d`](https://github.com/thelounge/thelounge/commit/cb17f8d87f9eac3b3449455d47c5ddaec09c0c5d) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency postcss to v8.4.18 ([`5a4a39b`](https://github.com/thelounge/thelounge/commit/5a4a39b9d1f4a49ddc2f9c5551f9fd28d0307a4b) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency chai to v4.3.7 ([`0ad033f`](https://github.com/thelounge/thelounge/commit/0ad033fe0aac01e0f4512428fda0e93ddefdcfb6) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/is-utf8 to v0.2.1 ([`b5ea7cc`](https://github.com/thelounge/thelounge/commit/b5ea7cceb3ff6a13f0ee20f4ed1c017b983d7d8c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/lodash to v4.14.188 ([`dfe288e`](https://github.com/thelounge/thelounge/commit/dfe288ef166a0ac07f538ee5a07c2f7b65ee15f9) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/chai to v4.3.4 ([`19307d0`](https://github.com/thelounge/thelounge/commit/19307d05e70f8b7ed9ab3d6177c7c9ae6c93a438) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency postcss to v8.4.19 ([`2218841`](https://github.com/thelounge/thelounge/commit/221884166df61feb43513205c982b271b299f074) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/lodash to v4.14.191 ([`d61ab7e`](https://github.com/thelounge/thelounge/commit/d61ab7e7a084018d68444c4b0ef8d14702142d84) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.1.4 ([`c854d27`](https://github.com/thelounge/thelounge/commit/c854d27d3d8451ea25051dc356dc8f101542f9a1) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/ws to v8.5.4 ([`502fb7a`](https://github.com/thelounge/thelounge/commit/502fb7a7050edbecd8e34b6c30664e0bdcfc4a6c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @vue/test-utils to v2.2.7 ([`6b23b87`](https://github.com/thelounge/thelounge/commit/6b23b87063c893ce588321929598e579401e16ee) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency sinon to v13.0.2 ([`90d17ca`](https://github.com/thelounge/thelounge/commit/90d17cacc155a3a6bafd76411b2e00997347a24b) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency vue-loader to v17.0.1 ([`2f8dc01`](https://github.com/thelounge/thelounge/commit/2f8dc01930f921f4de23dff29abfc703fdbefdbc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency webpack-dev-middleware to v5.3.3 ([`4742a07`](https://github.com/thelounge/thelounge/commit/4742a077211229191867033320c0efc876a9404c) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @vue/test-utils to v2.3.1 ([`50e8d2a`](https://github.com/thelounge/thelounge/commit/50e8d2a8903b1c1c826208850f46a5d98dbf6458) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency postcss to v8.4.21 ([`8e249d4`](https://github.com/thelounge/thelounge/commit/8e249d46afb234a4a1def2cbcc0204c4edd52bdc) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.1.5 [security] ([`bc4c308`](https://github.com/thelounge/thelounge/commit/bc4c3082b852e175e55003c8b91b2a69a7d8283f) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency webpack to v5.76.0 [security] ([`a67cee1`](https://github.com/thelounge/thelounge/commit/a67cee1ee43da01afd8c7584b44d46e6e8dc990d) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency sqlite3 to v5.1.6 ([`34a01c2`](https://github.com/thelounge/thelounge/commit/34a01c2dd164b60d7470b588f7c0e0ed3d3b7647) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- chore(deps): update dependency @types/mousetrap to v1.6.11 ([`5037383`](https://github.com/thelounge/thelounge/commit/5037383c4c9a87a53eaa358ffbe7492ab6ad6365) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- Autocomplete: update to @textcomplete package and close on blur (#4493) ([`bdd6e71`](https://github.com/thelounge/thelounge/commit/bdd6e71049a4ddc65eca8d6acc52ce5c7eb3f6fd) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Update sqlite3 to 5.0.3 ([`7db0d46`](https://github.com/thelounge/thelounge/commit/7db0d4619d98ad473eff7a1dbdf41c8b0167d0dd) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Merge sqlite3 upgrade to v5.0.6 ([`abf8906`](https://github.com/thelounge/thelounge/commit/abf89067575810339fa3c723af54a7ea670fe4e5) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- bump socket.io to 4.5.2 ([`d4bbd91`](https://github.com/thelounge/thelounge/commit/d4bbd9191cd78f065386fe25c7e8e90b1171a159) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- bump socket.io-client to 4.5.0 ([`4c7337b`](https://github.com/thelounge/thelounge/commit/4c7337b6257af2428e6e9f8af570126da094d266) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Bump engine.io from 6.2.0 to 6.2.1 ([`f8eb0eb`](https://github.com/thelounge/thelounge/commit/f8eb0ebafdf8824bfe316fd2ad8adb3b8beda2d2) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- Bump loader-utils from 2.0.2 to 2.0.4 ([`8924545`](https://github.com/thelounge/thelounge/commit/89245455ceceba157821437a3f8f4e80f3b03268) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- Bump loader-utils from 2.0.2 to 2.0.4 ([`21c8b0d`](https://github.com/thelounge/thelounge/commit/21c8b0d17fc7e09d1cad77990fa833fdcad62927) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency @types/mousetrap to v1.6.11 ([`7ee4b80`](https://github.com/thelounge/thelounge/commit/7ee4b80a6e744b09385fc686cdca1fbf0e7784ac) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency @types/lodash to v4.14.191 ([`c67df36`](https://github.com/thelounge/thelounge/commit/c67df36a29a04bacc9e3197a32368493ae0a2ae9) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- caniuse-lite: update db ([`efd3b64`](https://github.com/thelounge/thelounge/commit/efd3b645642ff75639ecb27a8ff9d6f6e1c0ccab) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- build(deps): bump json5 from 2.2.1 to 2.2.3 ([`ce3ad56`](https://github.com/thelounge/thelounge/commit/ce3ad56ced3b498def5bb65065b4185a46a20995) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- fix(deps): update dependency ua-parser-js to v1.0.33 [security] ([`bde5c3d`](https://github.com/thelounge/thelounge/commit/bde5c3d443dc1e965bdd2641abb94b526600ddec) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
- build(deps): bump http-cache-semantics from 4.1.0 to 4.1.1 ([`7304acd`](https://github.com/thelounge/thelounge/commit/7304acd8e072af33dfdd1ea2f108b91a6e449f65) by [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D))
|
||||||
|
- update dependency postcss to v8.4.21 ([`95e5630`](https://github.com/thelounge/thelounge/commit/95e56300db48bbb75b3463267eb0809ee9739686) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency sinon to v13.0.2 ([`0183d89`](https://github.com/thelounge/thelounge/commit/0183d89384405ad944863ecffd783c99f0c36517) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency vue-loader to v17.0.1 ([`eddcbcc`](https://github.com/thelounge/thelounge/commit/eddcbcc7660e5f51d9b794ab0302abb9790c6b3c) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency webpack-dev-middleware to v5.3.3 ([`4831c20`](https://github.com/thelounge/thelounge/commit/4831c2080415a72492e97d55be8512c86c4324b3) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- update dependency webpack to v5.76.0 ([`6b00ccf`](https://github.com/thelounge/thelounge/commit/6b00ccf82b60503b31e4fee1e32f2765c234d8cc) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
## v4.4.0-pre.2 - 2023-03-19 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.4.0-pre.1...v4.4.0-pre.2)
|
||||||
|
|
||||||
|
Hot fix for a bug that lead to channel loss upon restart of TL.
|
||||||
|
|
||||||
|
## v4.4.0-pre.1 - 2023-03-19 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.1...v4.4.0-pre.1)
|
||||||
|
|
||||||
|
This is a pre-release for v4.4.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
This is mostly a developer focused release. Max, Eric and others rewrote the whole thing in typescript / vue3,
|
||||||
|
which should make it much easier to add features and find bugs in the future. So huge kudos from the rest of the team!
|
||||||
|
|
||||||
|
Besides that, there's the obvious grab bag of fixes, dependency updates and improvements.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.1 - 2022-04-11
|
||||||
|
|
||||||
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0...v4.3.1) and [milestone](https://github.com/thelounge/thelounge/milestone/39?closed=1).
|
||||||
|
|
||||||
|
4.3.1 closes numerous bugs and introduces one prominent new feature closing [one of our most voted-on issues](https://github.com/thelounge/thelounge/issues/2490): muting! Users now have the ability to mute channels, networks, and private messages. Muted channels are dimmed in the channel list and notifications from them (including nick mentions) are disabled.
|
||||||
|
|
||||||
|
Also note that the npm package manager is no longer officially supported by The Lounge and we now only support using [yarn](https://yarnpkg.com).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add context menu when clicking inline channel name ([#4376](https://github.com/thelounge/thelounge/pull/4376) by [@sfan5](https://github.com/sfan5))
|
||||||
|
- Add /kickban ([#4361](https://github.com/thelounge/thelounge/pull/4361) by [@supertassu](https://github.com/supertassu))
|
||||||
|
- Add the option to mute channels, queries, and networks ([#4282](https://github.com/thelounge/thelounge/pull/4282) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Handle RPL_UMODEIS ([#4427](https://github.com/thelounge/thelounge/pull/4427) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Don't download image contents during prefetch if not needed ([#4363](https://github.com/thelounge/thelounge/pull/4363) by [@sfan5](https://github.com/sfan5))
|
||||||
|
- Emit a message for SASL loggedin/loggedout events ([`1e3a7b1`](https://github.com/thelounge/thelounge/commit/1e3a7b12500d8898500eaf54c01e52f8d5a0b3fd) by [@progval](https://github.com/progval))
|
||||||
|
- Log when file permissions should be changed ([#4373](https://github.com/thelounge/thelounge/pull/4373) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Count number of mode changes, not mode messages in condensed messages ([#4438](https://github.com/thelounge/thelounge/pull/4438) by [@supertassu](https://github.com/supertassu))
|
||||||
|
- upload: improve error message ([#4435](https://github.com/thelounge/thelounge/pull/4435) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Use non 0 exit code in abnormal shutdown ([#4423](https://github.com/thelounge/thelounge/pull/4423) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Show a nicer error in Chan.loadMessages() when network is misconfigured ([#4476](https://github.com/thelounge/thelounge/pull/4476) by [@progval](https://github.com/progval))
|
||||||
|
- Remove uses of window.event. ([#4434](https://github.com/thelounge/thelounge/pull/4434) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Upload m4a as audio/mp4; embed audio/mp4, x-flac, and x-m4a ([#4470](https://github.com/thelounge/thelounge/pull/4470) by [@xnaas](https://github.com/xnaas))
|
||||||
|
- Use the DNS result order returned by the OS ([#4484](https://github.com/thelounge/thelounge/pull/4484) by [@sfan5](https://github.com/sfan5))
|
||||||
|
- Update dependencies to their latest versions:
|
||||||
|
- Production: `irc-framework` ([#4425](https://github.com/thelounge/thelounge/pull/4425)), `got` ([#4377](https://github.com/thelounge/thelounge/commit/cb404cd986416a9202a8d452bb29960520703b44)), `mime-types` ([#4378](https://github.com/thelounge/thelounge/commit/b54cdf7880a45387561125d1702a539ec0dca36b)), `yarn` ([#4380](https://github.com/thelounge/thelounge/pull/4380)), `file-type` ([#4384](https://github.com/thelounge/thelounge/pull/4384)), `css-loader` ([#4381](https://github.com/thelounge/thelounge/pull/4381)), `ua-parser-js` ([#4389](https://github.com/thelounge/thelounge/pull/4389)), `filenamify` ([#4391](https://github.com/thelounge/thelounge/pull/4391)), `irc-framework` ([#4392](https://github.com/thelounge/thelounge/pull/4392)), `tlds` ([#4397](https://github.com/thelounge/thelounge/pull/4397)), `vue monorepo` ([#4403](https://github.com/thelounge/thelounge/pull/4403)), `package-json` ([#4414](https://github.com/thelounge/thelounge/pull/4414)), `express` ([#4520](https://github.com/thelounge/thelounge/pull/4520)), `sqlite3` ([#4446](https://github.com/thelounge/thelounge/pull/4446))
|
||||||
|
- Development: `babel`, `babel-plugin-istanbul`, `cssnano`, `dayjs`, `mini-css-extract-plugin`, `mocha`, `postcss`, `postcss-preset-env`, `posscss-loader`, `webpack`, `webpack-cli`,
|
||||||
|
- Bump most deps ([#4453](https://github.com/thelounge/thelounge/pull/4453) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Switch busboy implementation to `@fastify/busboy` ([#4428](https://github.com/thelounge/thelounge/pull/4428) by [@maxpoulin64](https://github.com/maxpoulin64))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Clear obsolete mentions upon channel part ([#4436](https://github.com/thelounge/thelounge/pull/4436) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- clientCert: fix up error message ([#4462](https://github.com/thelounge/thelounge/pull/4462) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- getGitCommit: allow git worktrees ([#4426](https://github.com/thelounge/thelounge/pull/4426) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Make sure the leading '<' is select when copypasting a message ([#4473](https://github.com/thelounge/thelounge/pull/4473) by [@progval](https://github.com/progval))
|
||||||
|
- Mentions window: filter list when we part a chan ([#4436](https://github.com/thelounge/thelounge/pull/4436) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix /collapse and /expand from interacting with the server in public mode ([#4488](https://github.com/thelounge/thelounge/pull/4488) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
In the main repository:
|
||||||
|
|
||||||
|
- Remove extra 'be' in default config.js LDAP comment ([#4430](https://github.com/thelounge/thelounge/pull/4430) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Adding 'to' in a sentence in config.js ([#4459](https://github.com/thelounge/thelounge/pull/4459) by [@fnutt](https://github.com/fnutt))
|
||||||
|
- Remove downloads badge and add thelounge/thelounge-docker link to README ([#4371](https://github.com/thelounge/thelounge/pull/4371) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- README: suggest running 'yarn format:prettier' when linting fails ([#4467](https://github.com/thelounge/thelounge/pull/4467) by [@progval](https://github.com/progval))
|
||||||
|
|
||||||
|
On the [website repository](https://github.com/thelounge/thelounge.github.io):
|
||||||
|
|
||||||
|
- update lsio link ([#255](https://github.com/thelounge/thelounge.github.io/pull/255) by [@xnaas](https://github.com/xnaas))
|
||||||
|
- Document prefetchMaxSearchSize config option ([#256](https://github.com/thelounge/thelounge.github.io/pull/256) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Update custom-css.md (#258) ([`de8c020`](https://github.com/thelounge/thelounge.github.io/commit/de8c02017cdd8c9bd46e60b899a3bd6a2d8977ec) by [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder))
|
||||||
|
- Remove analytics ([`3eb7fdc`](https://github.com/thelounge/thelounge.github.io/commit/3eb7fdc0bf07ade96829bcfe858e06a47e796ab2) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Remove star button ([`eec5b9c`](https://github.com/thelounge/thelounge.github.io/commit/eec5b9c99ec48a28b6ccfc5de7f7273eb284f558) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Bump addressable from 2.5.2 to 2.8.0 ([#246](https://github.com/thelounge/thelounge.github.io/pull/246) by [@dependabot](https://github.com/apps/dependabot))
|
||||||
|
- Update to Jekyll ~> 4.2.1 (#259) ([`db06e52`](https://github.com/thelounge/thelounge.github.io/commit/db06e524fdd2c55a929b0751abeaa761c8550882) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Update config documentation for 4.3.1 (#260) ([`94a1179`](https://github.com/thelounge/thelounge.github.io/commit/94a1179e7fa513ee6c1006455d4cdd9729033429) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
- Remove node 15.x from build matrix ([#4449](https://github.com/thelounge/thelounge/pull/4449) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Fix vue/this-in-template linter warning ([#4418](https://github.com/thelounge/thelounge/pull/4418) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Update actions/setup-node action to v3 ([#4496](https://github.com/thelounge/thelounge/pull/4496) by [@renovate[bot]](https://github.com/renovate%5Bbot%5D))
|
||||||
|
|
||||||
|
## v4.3.1-rc.1 - 2022-03-02 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0...v4.3.1-rc.1)
|
||||||
|
|
||||||
|
This is a release candidate (RC) for v4.3.1 to ensure maximum stability for public release.
|
||||||
|
Bugs may be fixed, but no further features will be added until the next stable version.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0 - 2021-11-22
|
||||||
|
|
||||||
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0...v4.3.0) and [milestone](https://github.com/thelounge/thelounge/milestone/37?closed=1).
|
||||||
|
|
||||||
|
4.3 is a smaller release with one major feature: message search! A big thank you to [richrd](https://github.com/richrd) and [Nachtalb](https://github.com/Nachtalb) for working on this. Note that it is somewhat limited at the moment — you cannot jump to messages or see context around them, but this was a major hurdle and we can improve upon it. You can try it out by using `/search` or by clicking or tapping the new icon in the topic bar above channels or queries as long as your `messageStorage` server setting includes `sqlite`. Some other additions are an improved ordering of elements for screen reader users, more context menu options, and new gestures for touchscreen users. You can learn about the gestures and new commands by navigating to the Help page with the `?` button in the bottom of your channel sidebar.
|
||||||
|
|
||||||
|
Additionally, support for Node 10 has been removed as it reached its end-of-life and the new minimum supported version is Node 12.0.0.
|
||||||
|
|
||||||
|
A huge thank you to the 32 contributors who made this release possible!
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Classes for channels in list with unread counts and highlights ([#4214](https://github.com/thelounge/thelounge/pull/4214) by [@sha1sum](https://github.com/sha1sum))
|
||||||
|
- Add proper filename to the content-disposition header ([#4187](https://github.com/thelounge/thelounge/pull/4187) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Add HTML lang and labelled-by field to upload ([#4051](https://github.com/thelounge/thelounge/pull/4051) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Improve inline audio file support ([#4210](https://github.com/thelounge/thelounge/pull/4210) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Show give/revoke modes and kick in context menu on other modes than +o ([#4176](https://github.com/thelounge/thelounge/pull/4176) by [@mitaka8](https://github.com/mitaka8), [#4181](https://github.com/thelounge/thelounge/pull/4181) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Add prefetchMaxSearchSize to override limit for link previews ([#4135](https://github.com/thelounge/thelounge/pull/4135) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Skip video/audio embeds if og:type exists but does not specify it ([#4040](https://github.com/thelounge/thelounge/pull/4040) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add version support for packages. ([#4041](https://github.com/thelounge/thelounge/pull/4041) by [@McInkay](https://github.com/McInkay))
|
||||||
|
- Add enterkeyhint on chat input and topic save ([#4055](https://github.com/thelounge/thelounge/pull/4055) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Make `add` and `reset` CLI commands scriptable ([#4090](https://github.com/thelounge/thelounge/pull/4090) by [@supertassu](https://github.com/supertassu))
|
||||||
|
- Add extended join information to join message ([#4105](https://github.com/thelounge/thelounge/pull/4105) by [@GewoonYorick](https://github.com/GewoonYorick))
|
||||||
|
- Add ignore option to contextmenu ([#4104](https://github.com/thelounge/thelounge/pull/4104) by [@GewoonYorick](https://github.com/GewoonYorick))
|
||||||
|
- Add gopher and gemini to the commonSchemes ([#4151](https://github.com/thelounge/thelounge/pull/4151) by [@Willamin](https://github.com/Willamin))
|
||||||
|
- Add network specific leave message ([#4116](https://github.com/thelounge/thelounge/pull/4116) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Message Search ([#4197](https://github.com/thelounge/thelounge/pull/4197) by [@Nachtalb](https://github.com/Nachtalb), [`69c37a5`](https://github.com/thelounge/thelounge/commit/69c37a535b91226ad744068fb38cdfdea5be167e), [`521426b`](https://github.com/thelounge/thelounge/commit/521426bb05ada1784bc61d157fd0d965fbe5fffc) by [@JeDaYoshi](https://github.com/JeDaYoshi), [`40a5ee7`](https://github.com/thelounge/thelounge/commit/40a5ee70b6b5eaaef8380b430172491a6ae4f7bb) by [@MaxLeiter](https://github.com/MaxLeiter), [#3664](https://github.com/thelounge/thelounge/pull/4197) by [@richrd](https://github.com/richrd))
|
||||||
|
- Fill inputhistory on channel load and more message load ([#4206](https://github.com/thelounge/thelounge/pull/4206) by [@Nachtalb](https://github.com/Nachtalb), [`af96f77`](https://github.com/thelounge/thelounge/commit/af96f7771cd067b71a9fbe92b7de5640fe9f2087) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Allow installation of local packages ([#4251](https://github.com/thelounge/thelounge/pull/4251) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Toggle recent mentions popup with ctrl/alt+m ([#4258](https://github.com/thelounge/thelounge/pull/4258) by [@bl1nk](https://github.com/bl1nk))
|
||||||
|
- Add support for SOCKS ([#4211](https://github.com/thelounge/thelounge/pull/4211) by [@Mstrodl](https://github.com/Mstrodl))
|
||||||
|
- Accessibility improvements (re-order, hide, and label certain DOM elements)([#4201](https://github.com/thelounge/thelounge/pull/4201) by [@MaxLeiter](https://github.com/MaxLeiter), [#4279](https://github.com/thelounge/thelounge/pull/4279) by [@JeDaYoshi](https://github.com/JeDaYoshi))
|
||||||
|
- Add /umode support ([#4274](https://github.com/thelounge/thelounge/pull/4274) by [@JeDaYoshi](https://github.com/JeDaYoshi))
|
||||||
|
- Add warning for HTTPS requirement on notifications ([#4280](https://github.com/thelounge/thelounge/pull/4280) by [@JeDaYoshi](https://github.com/JeDaYoshi))
|
||||||
|
- Allow network list reordering via touch. ([#4326](https://github.com/thelounge/thelounge/pull/4326), [#4332](https://github.com/thelounge/thelounge/pull/4332) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Two-finger swipe now switches windows (#3901) ([#4324](https://github.com/thelounge/thelounge/pull/4324) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Improve responsiveness of channel name and topic. ([#4340](https://github.com/thelounge/thelounge/pull/4340) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Add more plugin functionality ([#4329](https://github.com/thelounge/thelounge/pull/4329) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Add keyboard shortcut for help screen (#4315) ([`9a0ba1d`](https://github.com/thelounge/thelounge/commit/9a0ba1da6c318e74545d931ec67c67e87071285a) by [@NoahvdAa](https://github.com/NoahvdAa))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Vertically center topic editing input in Safari. (#4325) ([`2ab6716`](https://github.com/thelounge/thelounge/commit/2ab671664e1ac550fbb22b81284c665f72eee1d9) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Do not condense single messages (#4313) ([`7873847`](https://github.com/thelounge/thelounge/commit/7873847a7ebb4c26c0c380c6304f55a431a3872e) by [@supertassu](https://github.com/supertassu))
|
||||||
|
- MessageSearchForm: do not focus input if search is closed ([#4242](https://github.com/thelounge/thelounge/pull/4242) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Add new "/search query" command to open the search window ([#4213](https://github.com/thelounge/thelounge/pull/4213) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Add support for JPEG XL image previews ([#4219](https://github.com/thelounge/thelounge/pull/4219) by [@TheDecryptor](https://github.com/TheDecryptor))
|
||||||
|
- Make esc key close mentions window (#4365) ([`9dbf647`](https://github.com/thelounge/thelounge/commit/9dbf647f7e3248eedd0f237be55ef7244647a005) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Display server-originated notices to channels in the channel window ([#4260](https://github.com/thelounge/thelounge/pull/4260) by [@BradleyShaw](https://github.com/BradleyShaw))
|
||||||
|
- Optimise modes based on ISUPPORT ([#4275](https://github.com/thelounge/thelounge/pull/4275) by [@JeDaYoshi](https://github.com/JeDaYoshi))
|
||||||
|
- Allow wildcards in hostmask ([#4351](https://github.com/thelounge/thelounge/pull/4351) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Only scroll history when cursor is on first or last row ([#4205](https://github.com/thelounge/thelounge/pull/4205) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Cleanup of SQLite message storage ([#4345](https://github.com/thelounge/thelounge/pull/4345) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Do not generate and send client certificate unless SASL EXTERNAL is requested ([#4093](https://github.com/thelounge/thelounge/pull/4093) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- NetworkForm: s/away message/leave message/ ([#4193](https://github.com/thelounge/thelounge/pull/4193) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Settings: show label for nick autocompletion postfix ([#4195](https://github.com/thelounge/thelounge/pull/4195) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Move font assignment of password reveal icon ([#4342](https://github.com/thelounge/thelounge/pull/4342) by [@deejayy](https://github.com/deejayy))
|
||||||
|
- Prevent round and white search styling in iOS 15. ([#4352](https://github.com/thelounge/thelounge/pull/4352) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Allow escape key to close search bar and search page ([#4364](https://github.com/thelounge/thelounge/pull/4364) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Use SortableJS 1.14.0. (#4330) ([`2b634a6`](https://github.com/thelounge/thelounge/commit/2b634a6ba61bfc4c3b45f620b11396497f2f77a5) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Switch to thelounge/Sortable fork for Sortable.js (#4368) ([`315198a`](https://github.com/thelounge/thelounge/commit/315198ac0ba07400a33e8949ba50cddb774695c4) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Update production dependencies to their latest versions:
|
||||||
|
- `tlds` ([#4046](https://github.com/thelounge/thelounge/pull/4046))
|
||||||
|
- `commander` ([#4168](https://github.com/thelounge/thelounge/pull/4168), [#4185](https://github.com/thelounge/thelounge/pull/4185))
|
||||||
|
- `sqlite3` ([#4142](https://github.com/thelounge/thelounge/pull/4142))
|
||||||
|
- `chalk` ([#4208](https://github.com/thelounge/thelounge/pull/4208))
|
||||||
|
- `mime-types` ([#4349](https://github.com/thelounge/thelounge/pull/4349))
|
||||||
|
- `linkify-it` ([#4348](https://github.com/thelounge/thelounge/pull/4348))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Differentiate WALLOPS from NOTICE ([#4264](https://github.com/thelounge/thelounge/pull/4264) by [@BradleyShaw](https://github.com/BradleyShaw))
|
||||||
|
- Fix sporadic rounding on message search bar. ([#4333]((https://github.com/thelounge/thelounge/pull/4333), [#4328](<(https://github.com/thelounge/thelounge/pull/4328)>) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Fix missing users in userlist after removing searchinput ([#4221](https://github.com/thelounge/thelounge/pull/4221) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Always use multi-prefix modes ([#4060](https://github.com/thelounge/thelounge/pull/4060) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix breaking GIFs while removing metadata ([#4110](https://github.com/thelounge/thelounge/pull/4110) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Improved handling of empty userdata ([#4190](https://github.com/thelounge/thelounge/pull/4190) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Restrict what the browser should try to autocomplete ([#4192](https://github.com/thelounge/thelounge/pull/4192) by [@Nachtalb](https://github.com/Nachtalb), [#4337](https://github.com/thelounge/thelounge/commit/3ba7fb6de4270db1310b8624c9f308e858352f4a) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Render styling for colored host masks ([#4235](https://github.com/thelounge/thelounge/pull/4235) by [@angerson](https://github.com/angerson))
|
||||||
|
- Fix not overriding config options with -c ([#4262](https://github.com/thelounge/thelounge/pull/4262) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Fix nick-less messages from servers ([#4277](https://github.com/thelounge/thelounge/pull/4277) by [@JeDaYoshi](https://github.com/JeDaYoshi))
|
||||||
|
- Fix authenticated proxy ([#4341](https://github.com/thelounge/thelounge/pull/4341) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
- Allow text drag & drop into input text field ([#4212](https://github.com/thelounge/thelounge/pull/4212) by [@Nachtalb](https://github.com/Nachtalb))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependency ua-parser-js to v0.7.24 ([#4216](https://github.com/thelounge/thelounge/pull/4216) by [@renovate](https://github.com/apps/renovate))
|
||||||
|
- Update dependency postcss to v8.2.10 ([#4223](https://github.com/thelounge/thelounge/pull/4223) by [@renovate](https://github.com/apps/renovate))
|
||||||
|
- CSP adjustments ([#4344](https://github.com/thelounge/thelounge/pull/4344) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Bump required node version to 12.x and add 16.x builds ([#4356](https://github.com/thelounge/thelounge/pull/4356) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
In the main repository:
|
||||||
|
|
||||||
|
- Clarify description of prefetchMaxSearchSize. (#4338) ([`21c6abd`](https://github.com/thelounge/thelounge/commit/21c6abdd1d9e7ab09612250857ea418beb2885ec) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- `client/views` -> `client/components` in README ([#4196](https://github.com/thelounge/thelounge/pull/4196) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
|
||||||
|
On the [website repository](https://github.com/thelounge/thelounge.github.io):
|
||||||
|
|
||||||
|
- Update commands API docs (#217) ([`9c6a9e4`](https://github.com/thelounge/thelounge.github.io/commit/9c6a9e4b7d31efa37708a2796254f6cbe6e9abdf) by [@McInkay](https://github.com/McInkay))
|
||||||
|
- Add Caddy v2 examples (#230) ([`5554338`](https://github.com/thelounge/thelounge.github.io/commit/55543386feaf1f41dd845d500458a49be417da39) by [@Jay2k1](https://github.com/Jay2k1))
|
||||||
|
- Add self hosted pod to community.md (#231) ([`9e658c6`](https://github.com/thelounge/thelounge.github.io/commit/9e658c618daa144c8d757826c54d9bd67c53a133) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- reword note on daemonizing when installing from npm (#232) ([`6fab4fe`](https://github.com/thelounge/thelounge.github.io/commit/6fab4fe456abed6343b84f21f7caf5a3a0c6fed3) by [@igalic](https://github.com/igalic))
|
||||||
|
- Add css snippets for hiding account and realname from join messages ([#233](https://github.com/thelounge/thelounge.github.io/pull/233) by [@GewoonYorick](https://github.com/GewoonYorick))
|
||||||
|
- Add macOS Instructions ([#237](https://github.com/thelounge/thelounge.github.io/pull/237) by [@xnaas](https://github.com/xnaas))
|
||||||
|
- add "Hide unread counters in sidebar, just show a highlight indicator" ([#235](https://github.com/thelounge/thelounge.github.io/pull/235) by [@Jay2k1](https://github.com/Jay2k1))
|
||||||
|
- Clarify enabling Advanced settings to access custom CSS ([`cb0a427`](https://github.com/thelounge/thelounge.github.io/commit/cb0a427f49a313d7fc0eb56b0e422c14eb234574) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Update outdated CSS snippets in custom-css.md (#238) ([`fe9d09c`](https://github.com/thelounge/thelounge.github.io/commit/fe9d09c5062dd7dbe3563c7e72f82ef0c1a9eeb9) by [@EliteOfGods](https://github.com/EliteOfGods))
|
||||||
|
- Change the IRC server to Libera.Chat (#242) ([`7b8c010`](https://github.com/thelounge/thelounge.github.io/commit/7b8c0100fc66e368e02ece5e8a62e40f0817b3ae) by [@mhajder](https://github.com/mhajder))
|
||||||
|
- Fix spaces ([`3a41b12`](https://github.com/thelounge/thelounge.github.io/commit/3a41b121ec0d5e0b93694438dec8a4758b88627b) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Update custom-css.md ([#240](https://github.com/thelounge/thelounge.github.io/pull/240) by [@PeGaSuS-Coder](https://github.com/PeGaSuS-Coder))
|
||||||
|
- Alphabetically sorted unofficial install methods, added Swizzin ([#236](https://github.com/thelounge/thelounge.github.io/pull/236) by [@flying-sausages](https://github.com/flying-sausages))
|
||||||
|
- Update dependencies and community page (#245) ([`0762606`](https://github.com/thelounge/thelounge.github.io/commit/0762606c3bbfe55a4b053d6a6bddd0129ba1fff8) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Update config.js.md (#247) ([`3036977`](https://github.com/thelounge/thelounge.github.io/commit/3036977f3ea7c521cd22f29bfb3425f079ce5ed3) by [@ledakis](https://github.com/ledakis))
|
||||||
|
- Docs - Adding plugins section on main website (#248) ([`1fbaa17`](https://github.com/thelounge/thelounge.github.io/commit/1fbaa17cd9baa74e8d4c3dfab91b445105a503e5) by [@aab12345](https://github.com/aab12345))
|
||||||
|
- Docs - Change header links on main website (#249) ([`52eb866`](https://github.com/thelounge/thelounge.github.io/commit/52eb8668577ba9e7a4813831c77440be64c5aac8) by [@aab12345](https://github.com/aab12345))
|
||||||
|
- Extend theming guide with "files" section (#252) ([`94b8c8d`](https://github.com/thelounge/thelounge.github.io/commit/94b8c8dacea0d8b5941e35ca9a6b0ed30eaa7b2d) by [@deejayy](https://github.com/deejayy))
|
||||||
|
- Protect The Lounge with HTTPS (#253) ([`c4cfe60`](https://github.com/thelounge/thelounge.github.io/commit/c4cfe60421dc19e530119f63b637991ac0c465d8) by [@aab12345](https://github.com/aab12345))
|
||||||
|
- Plugin docs (#254) ([`45b32c5`](https://github.com/thelounge/thelounge.github.io/commit/45b32c5bf5282fc207427e9c22bdbc622b947eb0) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
- Clean up global listener in Sidebar component. (#4331) ([`5d76ed8`](https://github.com/thelounge/thelounge/commit/5d76ed888ce8d328913c15fde0b1026f0d60eb54) by [@itsjohncs](https://github.com/itsjohncs))
|
||||||
|
- Properly track user modes for context menu (#4267) ([`8fcd079`](https://github.com/thelounge/thelounge/commit/8fcd079204f6c44cadf7fff95c00a44242a61c68) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Optimise commands processing ([`0d839c5`](https://github.com/thelounge/thelounge/commit/0d839c501efa0cf56bce72263ab5c93535e34cd1) by [@JeDaYoshi](https://github.com/JeDaYoshi))
|
||||||
|
- Fix linter warnings for aria-label placement ([`d05cf5f`](https://github.com/thelounge/thelounge/commit/d05cf5fe628596a55a8aebda03e5692488890d94) by [@MaxLeiter](https://github.com/MaxLeiter))
|
||||||
|
- Configure server ping timeout to 60 seconds ([#4171](https://github.com/thelounge/thelounge/pull/4171) by [@emilyst](https://github.com/emilyst))
|
||||||
|
- Fix test for production build ([`c2e8eaf`](https://github.com/thelounge/thelounge/commit/c2e8eaf9dfed3720657b80619397f6d037d1c835) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add node 15 to test matrix ([`69986b3`](https://github.com/thelounge/thelounge/commit/69986b3ee5727cee9ecd274efcfcfe5137116857) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add .vscode settings and suggested extensions ([#4042](https://github.com/thelounge/thelounge/pull/4042) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Change the IRC server to Libera.Chat ([#4238](https://github.com/thelounge/thelounge/pull/4238) by [@mhajder](https://github.com/mhajder))
|
||||||
|
- Update prettier and apply formatting ([`b74b692`](https://github.com/thelounge/thelounge/commit/b74b6923912ec7c498a8fbcd0a6f53c44c7a3f25) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Update dependencies ([[`#4155`](https://github.com/thelounge/thelounge/pulls/4155), [`#4252`](https://github.com/thelounge/thelounge/pulls/4252), [`#4265`](https://github.com/thelounge/thelounge/pulls/4265), [`#4281`](https://github.com/thelounge/thelounge/pulls/4281), [`#4312`](https://github.com/thelounge/thelounge/pulls/4312) by [@MaxLeiter], [#4087](https://github.com/thelounge/thelounge/pulls/4087) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Change renovate to monthly ([`7ee0732`](https://github.com/thelounge/thelounge/commit/7ee0732f56644f4f337cfdc5244f44e3e27dc8bc) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add depTypeList to renovate ([`61ebd65`](https://github.com/thelounge/thelounge/commit/61ebd65367fa4d829b84ef2a48ad185cb2c8a385) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Update mini-css-extract-plugin ([`a9fb563`](https://github.com/thelounge/thelounge/commit/a9fb563c01a3c4ff9520e5017c42b28911eda38f) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Upgrade to webpack 5 ([`41831d1`](https://github.com/thelounge/thelounge/commit/41831d18b1507275de61bf79bb32cb25a3b590eb) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Update development dependencies to their latest versions:
|
||||||
|
- `pretty-quick` ([#4045](https://github.com/thelounge/thelounge/pull/4045))
|
||||||
|
- `@babel/core` ([#4043](https://github.com/thelounge/thelounge/pull/4043), [#4167](https://github.com/thelounge/thelounge/pull/4167), [#4182](https://github.com/thelounge/thelounge/pull/4182), [#4207](https://github.com/thelounge/thelounge/pull/4207))
|
||||||
|
- `@vue/server-test-utils` ([#4094](https://github.com/thelounge/thelounge/pull/4094))
|
||||||
|
- `@vue/test-utils` ([#4094](https://github.com/thelounge/thelounge/pull/4094))
|
||||||
|
- `vue-loader` ([#4094](https://github.com/thelounge/thelounge/pull/4094))
|
||||||
|
- `eslint-plugin-vue` ([#4141](https://github.com/thelounge/thelounge/pull/4141))
|
||||||
|
- `eslint` ([#4140](https://github.com/thelounge/thelounge/pull/4140), [#4170](https://github.com/thelounge/thelounge/pull/4170), [#4076](https://github.com/thelounge/thelounge/pull/4076))
|
||||||
|
- `dayjs` ([#4139](https://github.com/thelounge/thelounge/pull/4139))
|
||||||
|
- `copy-webpack-plugin` ([#4138](https://github.com/thelounge/thelounge/pull/4138))
|
||||||
|
- `css-loader` ([#4169](https://github.com/thelounge/thelounge/pull/4169))
|
||||||
|
- `@babel/preset-env` ([#4167](https://github.com/thelounge/thelounge/pull/4167), [#4182](https://github.com/thelounge/thelounge/pull/4182), [#4207](https://github.com/thelounge/thelounge/pull/4207))
|
||||||
|
- `@fortawesome/fontawesome-free` ([#4183](https://github.com/thelounge/thelounge/pull/4183))
|
||||||
|
- `chai` ([#4184](https://github.com/thelounge/thelounge/pull/4184))
|
||||||
|
|
||||||
|
In the [deb repository](https://github.com/thelounge/thelounge-deb):
|
||||||
|
|
||||||
|
- Add node 14 to GitHub action ([`56c7ba6`](https://github.com/thelounge/thelounge-deb/commit/56c7ba6cc598ccf9da1e04876b4e107f98cc3ed2) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Upgrade TravisCI to Bionic ([#77](https://github.com/thelounge/thelounge-deb/pull/77) by [@maxpoulin64](https://github.com/maxpoulin64))
|
||||||
|
- systemd: Don't force enable units ([#74](https://github.com/thelounge/thelounge-deb/pull/74) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Use dedicated npm cache dir ([#76](https://github.com/thelounge/thelounge-deb/pull/76) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
- Make all files root owned ([#75](https://github.com/thelounge/thelounge-deb/pull/75) by [@brunnre8](https://github.com/brunnre8))
|
||||||
|
|
||||||
|
## v4.3.0-rc.2 - 2021-11-18 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-rc.1...v4.3.0-rc.2)
|
||||||
|
|
||||||
|
This is a release candidate (RC) for v4.3.0 to ensure maximum stability for public release.
|
||||||
|
Bugs may be fixed, but no further features will be added until the next stable version.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-rc.1 - 2021-11-17 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.6...v4.3.0-rc.1)
|
||||||
|
|
||||||
|
This is a release candidate (RC) for v4.3.0 to ensure maximum stability for public release.
|
||||||
|
Bugs may be fixed, but no further features will be added until the next stable version.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-pre.6 - 2021-11-04 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.5...v4.3.0-pre.6)
|
||||||
|
|
||||||
|
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-pre.5 - 2021-11-03 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.4...v4.3.0-pre.5)
|
||||||
|
|
||||||
|
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-pre.4 - 2021-07-01 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.3...v4.3.0-pre.4)
|
||||||
|
|
||||||
|
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-pre.3 - 2021-06-29 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.2...v4.3.0-pre.3)
|
||||||
|
|
||||||
|
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-pre.2 - 2021-06-07 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.3.0-pre.1...v4.3.0-pre.2)
|
||||||
|
|
||||||
|
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.3.0-pre.1 - 2021-03-02 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0...v4.3.0-pre.1)
|
||||||
|
|
||||||
|
This is a pre-release for v4.3.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.2.0 - 2020-08-19
|
||||||
|
|
||||||
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.1.0...v4.2.0) and [milestone](https://github.com/thelounge/thelounge/milestone/36?closed=1).
|
||||||
|
|
||||||
|
This is a minor release with one significant new feature: a mentions panel!
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img width="466" alt="Mentions panel" src="https://user-images.githubusercontent.com/613331/90796491-0fadf380-e318-11ea-8fda-51613a9a3221.png">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Other notable additions include custom highlight exceptions, a new configuration option to not send preview requests to 3rd party websites, and uploaded images will have [EXIF](https://en.wikipedia.org/wiki/Exif) data automatically removed.
|
||||||
|
|
||||||
|
There's also a new section for configuring SASL on the Connect screen, and `SASL EXTERNAL` is now supported.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img width="489" alt="SASL authentication" src="https://user-images.githubusercontent.com/613331/90796501-15a3d480-e318-11ea-9dab-c225816a6685.png">
|
||||||
|
<img width="474" alt="SASL external (certfp)" src="https://user-images.githubusercontent.com/613331/90796504-15a3d480-e318-11ea-9636-c1025c9d2306.png">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Along with other bugs, a Chrome bug causing lag when typing has been fixed. Additionally, the `node-sqlite3` dependency has been updated, and you no longer need to re-install The Lounge when you update Node.js.
|
||||||
|
|
||||||
|
And as an update for our Docker users, `thelounge-docker` now has support for ARM images; thanks [@williamboman](https://github.com/williamboman) and [@klausenbusk](https://github.com/klausenbusk)!
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Track mentions/highlights and add a window to view them ([#3858](https://github.com/thelounge/thelounge/pull/3858), [#3993](https://github.com/thelounge/thelounge/pull/3993), [#3862](https://github.com/thelounge/thelounge/pull/3862), [#3868](https://github.com/thelounge/thelounge/pull/3868), [#4003](https://github.com/thelounge/thelounge/pull/4003) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add an option to display 12-hour times ([#3787](https://github.com/thelounge/thelounge/pull/3787) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add clear channel history (available in channel context menu)([#3778](https://github.com/thelounge/thelounge/pull/3778) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add CertFP support; separate SASL configuration; merge `displayNetwork` and `lockNetwork` in The Lounge configuration file ([#3844](https://github.com/thelounge/thelounge/pull/3844) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add an indicator to `STATUSMSG` messages ([#3875](https://github.com/thelounge/thelounge/pull/3875) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add native app badges for highlights (Chrome 81+) ([#3845](https://github.com/thelounge/thelounge/pull/3845) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add generic monospace blocks for `INFO` and `HELP` numerics ([#3962](https://github.com/thelounge/thelounge/pull/3962) by [@xPaw](https://github.com/xPaw), [#4032](https://github.com/thelounge/thelounge/pull/4032) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add option to disable media preview ([#3983](https://github.com/thelounge/thelounge/pull/3983) by [@dalcde](https://github.com/dalcde))
|
||||||
|
- Add custom highlight exceptions ([#3998](https://github.com/thelounge/thelounge/pull/3998) by [@Jay2k1](https://github.com/Jay2k1))
|
||||||
|
- Add navigation in image viewer ([#3798](https://github.com/thelounge/thelounge/pull/3798) by [@richrd](https://github.com/richrd))
|
||||||
|
- Render images in canvas before upload to remove EXIF data ([#3764](https://github.com/thelounge/thelounge/pull/3764) by [@xPaw](https://github.com/xPaw))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Disable link prefetching for urls with no schema specified ([#4014](https://github.com/thelounge/thelounge/pull/4014) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Disable settings sync for browser notifications and notification sound ([#4028](https://github.com/thelounge/thelounge/pull/4028) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Make usernames case-insensitive when logging in ([#3918](https://github.com/thelounge/thelounge/pull/3918) by [@ashwinikammar](https://github.com/ashwinikammar))
|
||||||
|
- Separate active sessions section ([#3817](https://github.com/thelounge/thelounge/pull/3817) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Add `role=group` to status messages setting ([#3790](https://github.com/thelounge/thelounge/pull/3790) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Filter user loading at startup for "advanced" LDAP ([#3871](https://github.com/thelounge/thelounge/pull/3871) by [@ebardie](https://github.com/ebardie))
|
||||||
|
- Reconnects now use exponential backoff
|
||||||
|
- Update production dependencies to their latest versions:
|
||||||
|
- `uuid` ([#3791](https://github.com/thelounge/thelounge/pull/3791), [#3837](https://github.com/thelounge/thelounge/pull/3837), [#3890](https://github.com/thelounge/thelounge/pull/3890), [#3919](https://github.com/thelounge/thelounge/pull/3919), [#3957](https://github.com/thelounge/thelounge/pull/3957), [#4004](https://github.com/thelounge/thelounge/pull/4004))
|
||||||
|
- `yarn` ([#3792](https://github.com/thelounge/thelounge/pull/3792), [#3800](https://github.com/thelounge/thelounge/pull/3800))
|
||||||
|
- `file-type` ([#3801](https://github.com/thelounge/thelounge/pull/3801), [#3896](https://github.com/thelounge/thelounge/pull/3896), [#3909](https://github.com/thelounge/thelounge/pull/3909), [#3920](https://github.com/thelounge/thelounge/pull/3920), [#3934](https://github.com/thelounge/thelounge/pull/3934), [#3940](https://github.com/thelounge/thelounge/pull/3940))
|
||||||
|
- `commander` ([#3807](https://github.com/thelounge/thelounge/pull/3807), [#3992](https://github.com/thelounge/thelounge/pull/3992))
|
||||||
|
- `got` ([#3829](https://github.com/thelounge/thelounge/pull/3829), [#3869](https://github.com/thelounge/thelounge/pull/3869), [#3898](https://github.com/thelounge/thelounge/pull/3898), [#3905](https://github.com/thelounge/thelounge/pull/3905), [#3932](https://github.com/thelounge/thelounge/pull/3932), [#3935](https://github.com/thelounge/thelounge/pull/3935), [#3972](https://github.com/thelounge/thelounge/pull/3972), [#3988](https://github.com/thelounge/thelounge/pull/3988))
|
||||||
|
- `irc-framework` ([#3838](https://github.com/thelounge/thelounge/pull/3838), [#3984](https://github.com/thelounge/thelounge/pull/3984))
|
||||||
|
- `chalk` ([#3839](https://github.com/thelounge/thelounge/pull/3839))
|
||||||
|
- `semver` ([#3843](https://github.com/thelounge/thelounge/pull/3843), [#3863](https://github.com/thelounge/thelounge/pull/3863))
|
||||||
|
- `web-push` ([#3904](https://github.com/thelounge/thelounge/pull/3904))
|
||||||
|
- `linkify-it` ([#3917](https://github.com/thelounge/thelounge/pull/3917))
|
||||||
|
- `sqlite3` ([#3886](https://github.com/thelounge/thelounge/pull/3886))
|
||||||
|
- `ldapjs` ([#3931](https://github.com/thelounge/thelounge/pull/3931), [#3996](https://github.com/thelounge/thelounge/pull/3996))
|
||||||
|
- `tlds` ([#4015](https://github.com/thelounge/thelounge/pull/4015))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix sending unhandled numerics to target channel ([#3789](https://github.com/thelounge/thelounge/pull/3789) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix up first argument not being used as part message ([#3808](https://github.com/thelounge/thelounge/pull/3808) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Pass in client manager object in update checker ([#3797](https://github.com/thelounge/thelounge/pull/3797) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Do not handle navigation keybinds in inputs if not empty ([#3814](https://github.com/thelounge/thelounge/pull/3814) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix body overscroll and overflow on iOS Safari ([#3828](https://github.com/thelounge/thelounge/pull/3828) by [@stevenengler](https://github.com/stevenengler))
|
||||||
|
- Fix off-by-one color error in webmanifest ([#3867](https://github.com/thelounge/thelounge/pull/3867) by [@maxpoulin64](https://github.com/maxpoulin64))
|
||||||
|
- Support multiple arguments in eventbus emit ([#3885](https://github.com/thelounge/thelounge/pull/3885) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix msg id order when loading from sqlite ([#3888](https://github.com/thelounge/thelounge/pull/3888) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Reply to the server if that's where CTCP VERSION originated ([#3906](https://github.com/thelounge/thelounge/pull/3906) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix date marker not displaying sometimes ([#3978](https://github.com/thelounge/thelounge/pull/3978) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Allow changing network name in private mode with lockNetwork ([#3977](https://github.com/thelounge/thelounge/pull/3977) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix upload tokens expiring while uploading when TL is proxied ([#3986](https://github.com/thelounge/thelounge/pull/3986) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Refresh notification permission state when push is enabled ([#3987](https://github.com/thelounge/thelounge/pull/3987) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix mode message only making last nick clickable ([#4005](https://github.com/thelounge/thelounge/pull/4005) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Sync changed network name to open clients ([#4038](https://github.com/thelounge/thelounge/pull/4038) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fix layout trashing in Chrome causing typing lag ([#3999](https://github.com/thelounge/thelounge/pull/3999) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Fixed a rare bug in `irc-framework` that caused duplicate messages
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
- Optimize user list updates for quit/part/kick events ([#3857](https://github.com/thelounge/thelounge/pull/3857) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Remove "The Lounge" from connect in public ([#3816](https://github.com/thelounge/thelounge/pull/3816) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Replace all uses of `fs-extra` with native methods ([#3810](https://github.com/thelounge/thelounge/pull/3810) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Upgrade to `mocha@7` and remove `mochapack` ([#3826](https://github.com/thelounge/thelounge/pull/3826) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Remove `intersection-observer` polyfill ([#3864](https://github.com/thelounge/thelounge/pull/3864) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Safeguard nick randomizer up to allowed length ([#3870](https://github.com/thelounge/thelounge/pull/3870) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Replace vue events with our own event bus ([#3872](https://github.com/thelounge/thelounge/pull/3872) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Cleanup vue router route guards ([#3995](https://github.com/thelounge/thelounge/pull/3995) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Use lodash where possible ([#4020](https://github.com/thelounge/thelounge/pull/4020) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Replace dashes to underscores in emoji autocompletion ([#4029](https://github.com/thelounge/thelounge/pull/4029) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Changes required for vue 3 ([#3889](https://github.com/thelounge/thelounge/pull/3889) by [@timmw](https://github.com/timmw))
|
||||||
|
- Test node v14 ([#3976](https://github.com/thelounge/thelounge/pull/3976) by [@xPaw](https://github.com/xPaw))
|
||||||
|
- Update development dependencies to their latest versions.
|
||||||
|
|
||||||
|
## v4.2.0-pre.2 - 2020-07-28 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.2.0-pre.1...v4.2.0-pre.2)
|
||||||
|
|
||||||
|
This is a pre-release for v4.2.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
|
## v4.2.0-pre.1 - 2020-05-17 [Pre-release]
|
||||||
|
|
||||||
|
[See the full changelog](https://github.com/thelounge/thelounge/compare/v4.1.0...v4.2.0-pre.1)
|
||||||
|
|
||||||
|
This is a pre-release for v4.2.0 to offer latest changes without having to wait for a stable release.
|
||||||
|
At this stage, features may still be added or modified until the first release candidate for this version gets released.
|
||||||
|
|
||||||
|
Please refer to the commit list given above for a complete list of changes, or wait for the stable release to get a thoroughly prepared change log entry.
|
||||||
|
|
||||||
|
As with all pre-releases, this version requires explicit use of the `next` tag to be installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn global add thelounge@next
|
||||||
|
```
|
||||||
|
|
||||||
## v4.1.0 - 2020-03-09
|
## v4.1.0 - 2020-03-09
|
||||||
|
|
||||||
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.0.0...v4.1.0) and [milestone](https://github.com/thelounge/thelounge/milestone/35?closed=1).
|
For more details, [see the full changelog](https://github.com/thelounge/thelounge/compare/v4.0.0...v4.1.0) and [milestone](https://github.com/thelounge/thelounge/milestone/35?closed=1).
|
||||||
|
|
22
README.md
22
README.md
|
@ -16,21 +16,20 @@
|
||||||
<a href="https://thelounge.chat/docs">Docs</a>
|
<a href="https://thelounge.chat/docs">Docs</a>
|
||||||
•
|
•
|
||||||
<a href="https://demo.thelounge.chat/">Demo</a>
|
<a href="https://demo.thelounge.chat/">Demo</a>
|
||||||
|
•
|
||||||
|
<a href="https://github.com/thelounge/thelounge-docker">Docker</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://demo.thelounge.chat/"><img
|
<a href="https://demo.thelounge.chat/"><img
|
||||||
alt="#thelounge IRC channel on freenode"
|
alt="#thelounge IRC channel on Libera.Chat"
|
||||||
src="https://img.shields.io/badge/freenode-%23thelounge-415364.svg?colorA=ff9e18"></a>
|
src="https://img.shields.io/badge/Libera.Chat-%23thelounge-415364.svg?colorA=ff9e18"></a>
|
||||||
<a href="https://yarn.pm/thelounge"><img
|
<a href="https://yarn.pm/thelounge"><img
|
||||||
alt="npm version"
|
alt="npm version"
|
||||||
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a>
|
src="https://img.shields.io/npm/v/thelounge.svg?colorA=333a41&maxAge=3600"></a>
|
||||||
<a href="https://github.com/thelounge/thelounge/actions"><img
|
<a href="https://github.com/thelounge/thelounge/actions"><img
|
||||||
alt="Build Status"
|
alt="Build Status"
|
||||||
src="https://github.com/thelounge/thelounge/workflows/Build/badge.svg"></a>
|
src="https://github.com/thelounge/thelounge/workflows/Build/badge.svg"></a>
|
||||||
<a href="https://npm-stat.com/charts.html?package=thelounge&from=2016-02-12"><img
|
|
||||||
alt="Total downloads on npm"
|
|
||||||
src="https://img.shields.io/npm/dy/thelounge.svg?colorA=333a41&colorB=007dc7&maxAge=3600&label=Downloads"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
@ -52,7 +51,7 @@ The Lounge is the official and community-managed fork of [Shout](https://github.
|
||||||
## Installation and usage
|
## Installation and usage
|
||||||
|
|
||||||
The Lounge requires latest [Node.js](https://nodejs.org/) LTS version or more recent.
|
The Lounge requires latest [Node.js](https://nodejs.org/) LTS version or more recent.
|
||||||
[Yarn package manager](https://yarnpkg.com/) is also recommended.
|
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.
|
If you want to install with npm, `--unsafe-perm` is required for a correct install.
|
||||||
|
|
||||||
### Running stable releases
|
### Running stable releases
|
||||||
|
@ -84,6 +83,13 @@ fork.
|
||||||
Before submitting any change, make sure to:
|
Before submitting any change, make sure to:
|
||||||
|
|
||||||
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
|
- Read the [Contributing instructions](https://github.com/thelounge/thelounge/blob/master/.github/CONTRIBUTING.md#contributing)
|
||||||
- Run `yarn test` to execute linters and test suite
|
- Run `yarn test` to execute linters and the test suite
|
||||||
- Run `yarn build` if you change or add anything in `client/js` or `client/views`
|
- 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
|
- `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
|
- Contact us privately first, in a
|
||||||
[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure)
|
[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure)
|
||||||
manner.
|
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`.
|
`#thelounge`.
|
||||||
- By email, send us your report at <security@thelounge.chat>.
|
- By email, send us your report at <security@thelounge.chat>.
|
||||||
|
|
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"],
|
||||||
|
};
|
|
@ -1,115 +1,132 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="viewport" :class="viewportClasses" role="tablist">
|
<div id="viewport" :class="viewportClasses" role="tablist">
|
||||||
<Sidebar v-if="$store.state.appLoaded" :overlay="$refs.overlay" />
|
<Sidebar v-if="store.state.appLoaded" :overlay="overlay" />
|
||||||
<div id="sidebar-overlay" ref="overlay" @click="$store.commit('sidebarOpen', false)" />
|
<div
|
||||||
<router-view ref="window"></router-view>
|
id="sidebar-overlay"
|
||||||
|
ref="overlay"
|
||||||
|
aria-hidden="true"
|
||||||
|
@click="store.commit('sidebarOpen', false)"
|
||||||
|
/>
|
||||||
|
<router-view ref="loungeWindow"></router-view>
|
||||||
|
<Mentions />
|
||||||
<ImageViewer ref="imageViewer" />
|
<ImageViewer ref="imageViewer" />
|
||||||
<ContextMenu ref="contextMenu" />
|
<ContextMenu ref="contextMenu" />
|
||||||
|
<ConfirmDialog ref="confirmDialog" />
|
||||||
<div id="upload-overlay"></div>
|
<div id="upload-overlay"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
const constants = require("../js/constants");
|
import constants from "../js/constants";
|
||||||
import Mousetrap from "mousetrap";
|
import eventbus from "../js/eventbus";
|
||||||
|
import Mousetrap, {ExtendedKeyboardEvent} from "mousetrap";
|
||||||
import throttle from "lodash/throttle";
|
import throttle from "lodash/throttle";
|
||||||
import storage from "../js/localStorage";
|
import storage from "../js/localStorage";
|
||||||
|
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
|
||||||
|
|
||||||
import Sidebar from "./Sidebar.vue";
|
import Sidebar from "./Sidebar.vue";
|
||||||
import ImageViewer from "./ImageViewer.vue";
|
import ImageViewer from "./ImageViewer.vue";
|
||||||
import ContextMenu from "./ContextMenu.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 default {
|
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",
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
ImageViewer,
|
ImageViewer,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
|
ConfirmDialog,
|
||||||
|
Mentions,
|
||||||
},
|
},
|
||||||
computed: {
|
setup() {
|
||||||
viewportClasses() {
|
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 {
|
return {
|
||||||
notified: this.$store.getters.highlightCount > 0,
|
notified: store.getters.highlightCount > 0,
|
||||||
"menu-open": this.$store.state.appLoaded && this.$store.state.sidebarOpen,
|
"menu-open": store.state.appLoaded && store.state.sidebarOpen,
|
||||||
"menu-dragging": this.$store.state.sidebarDragging,
|
"menu-dragging": store.state.sidebarDragging,
|
||||||
"userlist-open": this.$store.state.userlistOpen,
|
"userlist-open": store.state.userlistOpen,
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.prepareOpenStates();
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
Mousetrap.bind("esc", this.escapeKey);
|
|
||||||
Mousetrap.bind("alt+u", this.toggleUserList);
|
|
||||||
Mousetrap.bind("alt+s", this.toggleSidebar);
|
|
||||||
|
|
||||||
// Make a single throttled resize listener available to all components
|
const debouncedResize = ref<DebouncedFunc<() => void>>();
|
||||||
this.debouncedResize = throttle(() => {
|
const dayChangeTimeout = ref<any>();
|
||||||
this.$root.$emit("resize");
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
window.addEventListener("resize", this.debouncedResize, {passive: true});
|
const escapeKey = () => {
|
||||||
|
eventbus.emit("escapekey");
|
||||||
// Emit a daychange event every time the day changes so date markers know when to update themselves
|
|
||||||
const emitDayChange = () => {
|
|
||||||
this.$root.$emit("daychange");
|
|
||||||
// This should always be 24h later but re-computing exact value just in case
|
|
||||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
|
const toggleSidebar = (e: ExtendedKeyboardEvent) => {
|
||||||
},
|
if (isIgnoredKeybind(e)) {
|
||||||
beforeDestroy() {
|
|
||||||
Mousetrap.unbind("esc", this.escapeKey);
|
|
||||||
Mousetrap.unbind("alt+u", this.toggleUserList);
|
|
||||||
Mousetrap.unbind("alt+s", this.toggleSidebar);
|
|
||||||
|
|
||||||
window.removeEventListener("resize", this.debouncedResize);
|
|
||||||
clearTimeout(this.dayChangeTimeout);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
escapeKey() {
|
|
||||||
this.$root.$emit("escapekey");
|
|
||||||
},
|
|
||||||
toggleSidebar(e) {
|
|
||||||
// Do not handle this keybind in the chat input because
|
|
||||||
// it can be used to type letters with umlauts
|
|
||||||
if (e.target.tagName === "TEXTAREA") {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit("toggleSidebar");
|
store.commit("toggleSidebar");
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
};
|
||||||
toggleUserList(e) {
|
|
||||||
// Do not handle this keybind in the chat input because
|
const toggleUserList = (e: ExtendedKeyboardEvent) => {
|
||||||
// it can be used to type letters with umlauts
|
if (isIgnoredKeybind(e)) {
|
||||||
if (e.target.tagName === "TEXTAREA") {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit("toggleUserlist");
|
store.commit("toggleUserlist");
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
};
|
||||||
msUntilNextDay() {
|
|
||||||
|
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
|
// Compute how many milliseconds are remaining until the next day starts
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tommorow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
const tommorow = new Date(
|
||||||
|
today.getFullYear(),
|
||||||
|
today.getMonth(),
|
||||||
|
today.getDate() + 1
|
||||||
|
).getTime();
|
||||||
|
|
||||||
return tommorow - today;
|
return tommorow - today.getTime();
|
||||||
},
|
};
|
||||||
prepareOpenStates() {
|
|
||||||
|
const prepareOpenStates = () => {
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
let isUserlistOpen = storage.get("thelounge.state.userlist");
|
let isUserlistOpen = storage.get("thelounge.state.userlist");
|
||||||
|
|
||||||
if (viewportWidth > constants.mobileViewportPixels) {
|
if (viewportWidth > constants.mobileViewportPixels) {
|
||||||
this.$store.commit(
|
store.commit("sidebarOpen", storage.get("thelounge.state.sidebar") !== "false");
|
||||||
"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
|
// If The Lounge is opened on a small screen (less than 1024px), and we don't have stored
|
||||||
|
@ -118,8 +135,61 @@ export default {
|
||||||
isUserlistOpen = "true";
|
isUserlistOpen = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit("userlistOpen", 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>
|
</script>
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
|
<!-- TODO: investigate -->
|
||||||
<ChannelWrapper ref="wrapper" v-bind="$props">
|
<ChannelWrapper ref="wrapper" v-bind="$props">
|
||||||
<span class="name">{{ channel.name }}</span>
|
<span class="name">{{ channel.name }}</span>
|
||||||
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
|
<span
|
||||||
unreadCount
|
v-if="channel.unread"
|
||||||
}}</span>
|
:class="{highlight: channel.highlight && !channel.muted}"
|
||||||
|
class="badge"
|
||||||
|
>{{ unreadCount }}</span
|
||||||
|
>
|
||||||
<template v-if="channel.type === 'channel'">
|
<template v-if="channel.type === 'channel'">
|
||||||
<span
|
<span
|
||||||
v-if="channel.state === 0"
|
v-if="channel.state === 0"
|
||||||
|
@ -24,30 +28,38 @@
|
||||||
</ChannelWrapper>
|
</ChannelWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {PropType, defineComponent, computed} from "vue";
|
||||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
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";
|
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "Channel",
|
name: "Channel",
|
||||||
components: {
|
components: {
|
||||||
ChannelWrapper,
|
ChannelWrapper,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
channel: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: Object as PropType<ClientChan>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
isFiltering: Boolean,
|
isFiltering: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
unreadCount() {
|
const unreadCount = computed(() => roundBadgeNumber(props.channel.unread));
|
||||||
return roundBadgeNumber(this.channel.unread);
|
const close = useCloseChannel(props.channel);
|
||||||
},
|
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
close,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
});
|
||||||
close() {
|
|
||||||
this.$root.closeChannel(this.channel);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -8,11 +8,14 @@
|
||||||
{active: active},
|
{active: active},
|
||||||
{'parted-channel': channel.type === 'channel' && channel.state === 0},
|
{'parted-channel': channel.type === 'channel' && channel.state === 0},
|
||||||
{'has-draft': channel.pendingMessage},
|
{'has-draft': channel.pendingMessage},
|
||||||
|
{'has-unread': channel.unread},
|
||||||
|
{'has-highlight': channel.highlight},
|
||||||
{
|
{
|
||||||
'not-secure':
|
'not-secure':
|
||||||
channel.type === 'lobby' && network.status.connected && !network.status.secure,
|
channel.type === 'lobby' && network.status.connected && !network.status.secure,
|
||||||
},
|
},
|
||||||
{'not-connected': channel.type === 'lobby' && !network.status.connected},
|
{'not-connected': channel.type === 'lobby' && !network.status.connected},
|
||||||
|
{'is-muted': channel.muted},
|
||||||
]"
|
]"
|
||||||
:aria-label="getAriaLabel()"
|
:aria-label="getAriaLabel()"
|
||||||
:title="getAriaLabel()"
|
:title="getAriaLabel()"
|
||||||
|
@ -20,66 +23,90 @@
|
||||||
:data-type="channel.type"
|
:data-type="channel.type"
|
||||||
:aria-controls="'#chan-' + channel.id"
|
:aria-controls="'#chan-' + channel.id"
|
||||||
:aria-selected="active"
|
:aria-selected="active"
|
||||||
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
|
:style="channel.closed ? {transition: 'none', opacity: 0.4} : undefined"
|
||||||
role="tab"
|
role="tab"
|
||||||
@click="click"
|
@click="click"
|
||||||
@contextmenu.prevent="openContextMenu"
|
@contextmenu.prevent="openContextMenu"
|
||||||
>
|
>
|
||||||
<slot :network="network" :channel="channel" :activeChannel="activeChannel" />
|
<slot :network="network" :channel="channel" :active-channel="activeChannel" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import eventbus from "../js/eventbus";
|
||||||
import isChannelCollapsed from "../js/helpers/isChannelCollapsed";
|
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 {
|
export default defineComponent({
|
||||||
name: "ChannelWrapper",
|
name: "ChannelWrapper",
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
channel: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
type: Object as PropType<ClientChan>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
isFiltering: Boolean,
|
isFiltering: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
activeChannel() {
|
const store = useStore();
|
||||||
return this.$store.state.activeChannel;
|
const activeChannel = computed(() => store.state.activeChannel);
|
||||||
},
|
const isChannelVisible = computed(
|
||||||
isChannelVisible() {
|
() => props.isFiltering || !isChannelCollapsed(props.network, props.channel)
|
||||||
return this.isFiltering || !isChannelCollapsed(this.network, this.channel);
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getAriaLabel() {
|
|
||||||
const extra = [];
|
|
||||||
|
|
||||||
if (this.channel.unread > 0) {
|
const getAriaLabel = () => {
|
||||||
extra.push(`${this.channel.unread} unread`);
|
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 (this.channel.highlight > 0) {
|
if (props.channel.highlight > 0) {
|
||||||
extra.push(`${this.channel.highlight} mention`);
|
if (props.channel.highlight > 1) {
|
||||||
|
extra.push(`${props.channel.highlight} mentions`);
|
||||||
|
} else {
|
||||||
|
extra.push(`${props.channel.highlight} mention`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extra.length > 0) {
|
return `${type}: ${props.channel.name} ${extra.length ? `(${extra.join(", ")})` : ""}`;
|
||||||
return `${this.channel.name} (${extra.join(", ")})`;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return this.channel.name;
|
const click = () => {
|
||||||
},
|
if (props.isFiltering) {
|
||||||
click() {
|
|
||||||
if (this.isFiltering) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$root.switchToChannel(this.channel);
|
switchToChannel(props.channel);
|
||||||
},
|
};
|
||||||
openContextMenu(event) {
|
|
||||||
this.$root.$emit("contextmenu:channel", {
|
const openContextMenu = (event: MouseEvent) => {
|
||||||
|
eventbus.emit("contextmenu:channel", {
|
||||||
event: event,
|
event: event,
|
||||||
channel: this.channel,
|
channel: props.channel,
|
||||||
network: this.network,
|
network: props.network,
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeChannel,
|
||||||
|
isChannelVisible,
|
||||||
|
getAriaLabel,
|
||||||
|
click,
|
||||||
|
openContextMenu,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="chat-container" class="window" :data-current-channel="channel.name">
|
<div id="chat-container" class="window" :data-current-channel="channel.name" lang="">
|
||||||
<div
|
<div
|
||||||
id="chat"
|
id="chat"
|
||||||
:class="{
|
:class="{
|
||||||
'hide-motd': !$store.state.settings.motd,
|
'hide-motd': !store.state.settings.motd,
|
||||||
'colored-nicks': $store.state.settings.coloredNicks,
|
'time-seconds': store.state.settings.showSeconds,
|
||||||
'show-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
|
<div
|
||||||
|
@ -17,13 +18,16 @@
|
||||||
>
|
>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<SidebarToggle />
|
<SidebarToggle />
|
||||||
<span class="title">{{ channel.name }}</span>
|
<span class="title" :aria-label="'Currently open ' + channel.type">{{
|
||||||
|
channel.name
|
||||||
|
}}</span>
|
||||||
<div v-if="channel.editTopic === true" class="topic-container">
|
<div v-if="channel.editTopic === true" class="topic-container">
|
||||||
<input
|
<input
|
||||||
ref="topicInput"
|
ref="topicInput"
|
||||||
:value="channel.topic"
|
:value="channel.topic"
|
||||||
class="topic-input"
|
class="topic-input"
|
||||||
placeholder="Set channel topic"
|
placeholder="Set channel topic"
|
||||||
|
enterkeyhint="done"
|
||||||
@keyup.enter="saveTopic"
|
@keyup.enter="saveTopic"
|
||||||
@keyup.esc="channel.editTopic = false"
|
@keyup.esc="channel.editTopic = false"
|
||||||
/>
|
/>
|
||||||
|
@ -31,12 +35,29 @@
|
||||||
<span type="button" aria-label="Save topic"></span>
|
<span type="button" aria-label="Save topic"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else :title="channel.topic" class="topic" @dblclick="editTopic"
|
<span
|
||||||
|
v-else
|
||||||
|
:title="channel.topic"
|
||||||
|
:class="{topic: true, empty: !channel.topic}"
|
||||||
|
@dblclick="editTopic"
|
||||||
><ParsedMessage
|
><ParsedMessage
|
||||||
v-if="channel.topic"
|
v-if="channel.topic"
|
||||||
:network="network"
|
:network="network"
|
||||||
:text="channel.topic"
|
:text="channel.topic"
|
||||||
/></span>
|
/></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
|
<button
|
||||||
class="menu"
|
class="menu"
|
||||||
aria-label="Open the context menu"
|
aria-label="Open the context menu"
|
||||||
|
@ -50,7 +71,7 @@
|
||||||
<button
|
<button
|
||||||
class="rt"
|
class="rt"
|
||||||
aria-label="Toggle user list"
|
aria-label="Toggle user list"
|
||||||
@click="$store.commit('toggleUserlist')"
|
@click="store.commit('toggleUserlist')"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -58,7 +79,7 @@
|
||||||
<div class="chat">
|
<div class="chat">
|
||||||
<div class="messages">
|
<div class="messages">
|
||||||
<div class="msg">
|
<div class="msg">
|
||||||
<Component
|
<component
|
||||||
:is="specialComponent"
|
:is="specialComponent"
|
||||||
:network="network"
|
:network="network"
|
||||||
:channel="channel"
|
:channel="channel"
|
||||||
|
@ -74,39 +95,50 @@
|
||||||
{'scroll-down-shown': !channel.scrolledToBottom},
|
{'scroll-down-shown': !channel.scrolledToBottom},
|
||||||
]"
|
]"
|
||||||
aria-label="Jump to recent messages"
|
aria-label="Jump to recent messages"
|
||||||
@click="$refs.messageList.jumpToBottom()"
|
@click="messageList?.jumpToBottom()"
|
||||||
>
|
>
|
||||||
<div class="scroll-down-arrow" />
|
<div class="scroll-down-arrow" />
|
||||||
</div>
|
</div>
|
||||||
<MessageList ref="messageList" :network="network" :channel="channel" />
|
|
||||||
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
|
<ChatUserList v-if="channel.type === 'channel'" :channel="channel" />
|
||||||
|
<MessageList
|
||||||
|
ref="messageList"
|
||||||
|
:network="network"
|
||||||
|
:channel="channel"
|
||||||
|
:focused="focused"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="this.$store.state.currentUserVisibleError"
|
v-if="store.state.currentUserVisibleError"
|
||||||
id="user-visible-error"
|
id="user-visible-error"
|
||||||
@click="hideUserVisibleError"
|
@click="hideUserVisibleError"
|
||||||
>
|
>
|
||||||
{{ this.$store.state.currentUserVisibleError }}
|
{{ store.state.currentUserVisibleError }}
|
||||||
</div>
|
</div>
|
||||||
<ChatInput :network="network" :channel="channel" />
|
<ChatInput :network="network" :channel="channel" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
|
import eventbus from "../js/eventbus";
|
||||||
import ParsedMessage from "./ParsedMessage.vue";
|
import ParsedMessage from "./ParsedMessage.vue";
|
||||||
import MessageList from "./MessageList.vue";
|
import MessageList from "./MessageList.vue";
|
||||||
import ChatInput from "./ChatInput.vue";
|
import ChatInput from "./ChatInput.vue";
|
||||||
import ChatUserList from "./ChatUserList.vue";
|
import ChatUserList from "./ChatUserList.vue";
|
||||||
import SidebarToggle from "./SidebarToggle.vue";
|
import SidebarToggle from "./SidebarToggle.vue";
|
||||||
|
import MessageSearchForm from "./MessageSearchForm.vue";
|
||||||
import ListBans from "./Special/ListBans.vue";
|
import ListBans from "./Special/ListBans.vue";
|
||||||
import ListInvites from "./Special/ListInvites.vue";
|
import ListInvites from "./Special/ListInvites.vue";
|
||||||
import ListChannels from "./Special/ListChannels.vue";
|
import ListChannels from "./Special/ListChannels.vue";
|
||||||
import ListIgnored from "./Special/ListIgnored.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 {
|
export default defineComponent({
|
||||||
name: "Chat",
|
name: "Chat",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
|
@ -114,89 +146,129 @@ export default {
|
||||||
ChatInput,
|
ChatInput,
|
||||||
ChatUserList,
|
ChatUserList,
|
||||||
SidebarToggle,
|
SidebarToggle,
|
||||||
|
MessageSearchForm,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
|
focused: Number,
|
||||||
},
|
},
|
||||||
computed: {
|
emits: ["channel-changed"],
|
||||||
specialComponent() {
|
setup(props, {emit}) {
|
||||||
switch (this.channel.special) {
|
const store = useStore();
|
||||||
case "list_bans":
|
|
||||||
return ListBans;
|
const messageList = ref<typeof MessageList>();
|
||||||
case "list_invites":
|
const topicInput = ref<HTMLInputElement | null>(null);
|
||||||
return ListInvites;
|
|
||||||
case "list_channels":
|
const specialComponent = computed(() => {
|
||||||
return ListChannels;
|
switch (props.channel.special) {
|
||||||
case "list_ignored":
|
case SpecialChanType.BANLIST:
|
||||||
return ListIgnored;
|
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;
|
return undefined;
|
||||||
},
|
});
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
channel() {
|
|
||||||
this.channelChanged();
|
|
||||||
},
|
|
||||||
"channel.editTopic"(newValue) {
|
|
||||||
if (newValue) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.topicInput.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.channelChanged();
|
|
||||||
|
|
||||||
if (this.channel.editTopic) {
|
const channelChanged = () => {
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.topicInput.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
channelChanged() {
|
|
||||||
// Triggered when active channel is set or changed
|
// Triggered when active channel is set or changed
|
||||||
this.channel.highlight = 0;
|
emit("channel-changed", props.channel);
|
||||||
this.channel.unread = 0;
|
|
||||||
|
|
||||||
socket.emit("open", this.channel.id);
|
socket.emit("open", props.channel.id);
|
||||||
|
|
||||||
if (this.channel.usersOutdated) {
|
if (props.channel.usersOutdated) {
|
||||||
this.channel.usersOutdated = false;
|
props.channel.usersOutdated = false;
|
||||||
|
|
||||||
socket.emit("names", {
|
socket.emit("names", {
|
||||||
target: this.channel.id,
|
target: props.channel.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
hideUserVisibleError() {
|
|
||||||
this.$store.commit("currentUserVisibleError", null);
|
|
||||||
},
|
|
||||||
editTopic() {
|
|
||||||
if (this.channel.type === "channel") {
|
|
||||||
this.channel.editTopic = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveTopic() {
|
|
||||||
this.channel.editTopic = false;
|
|
||||||
const newTopic = this.$refs.topicInput.value;
|
|
||||||
|
|
||||||
if (this.channel.topic !== newTopic) {
|
const hideUserVisibleError = () => {
|
||||||
const target = this.channel.id;
|
store.commit("currentUserVisibleError", null);
|
||||||
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
|
};
|
||||||
|
|
||||||
|
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});
|
socket.emit("input", {target, text});
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
openContextMenu(event) {
|
|
||||||
this.$root.$emit("contextmenu:channel", {
|
const openContextMenu = (event: any) => {
|
||||||
|
eventbus.emit("contextmenu:channel", {
|
||||||
event: event,
|
event: event,
|
||||||
channel: this.channel,
|
channel: props.channel,
|
||||||
network: this.network,
|
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>
|
</script>
|
||||||
|
|
|
@ -7,14 +7,16 @@
|
||||||
ref="input"
|
ref="input"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
class="mousetrap"
|
class="mousetrap"
|
||||||
|
enterkeyhint="send"
|
||||||
:value="channel.pendingMessage"
|
:value="channel.pendingMessage"
|
||||||
:placeholder="getInputPlaceholder(channel)"
|
:placeholder="getInputPlaceholder(channel)"
|
||||||
:aria-label="getInputPlaceholder(channel)"
|
:aria-label="getInputPlaceholder(channel)"
|
||||||
@input="setPendingMessage"
|
@input="setPendingMessage"
|
||||||
@keypress.enter.exact.prevent="onSubmit"
|
@keypress.enter.exact.prevent="onSubmit"
|
||||||
|
@blur="onBlur"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="$store.state.serverConfiguration.fileUpload"
|
v-if="store.state.serverConfiguration?.fileUpload"
|
||||||
id="upload-tooltip"
|
id="upload-tooltip"
|
||||||
class="tooltipped tooltipped-w tooltipped-no-touch"
|
class="tooltipped tooltipped-w tooltipped-no-touch"
|
||||||
aria-label="Upload file"
|
aria-label="Upload file"
|
||||||
|
@ -24,6 +26,7 @@
|
||||||
id="upload-input"
|
id="upload-input"
|
||||||
ref="uploadInput"
|
ref="uploadInput"
|
||||||
type="file"
|
type="file"
|
||||||
|
aria-labelledby="upload"
|
||||||
multiple
|
multiple
|
||||||
@change="onUploadInputChange"
|
@change="onUploadInputChange"
|
||||||
/>
|
/>
|
||||||
|
@ -31,7 +34,7 @@
|
||||||
id="upload"
|
id="upload"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Upload file"
|
aria-label="Upload file"
|
||||||
:disabled="!$store.state.isConnected"
|
:disabled="!store.state.isConnected"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
@ -43,19 +46,24 @@
|
||||||
id="submit"
|
id="submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
:disabled="!$store.state.isConnected"
|
:disabled="!store.state.isConnected"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import {wrapCursor} from "undate";
|
import {wrapCursor} from "undate";
|
||||||
import autocompletion from "../js/autocompletion";
|
import autocompletion from "../js/autocompletion";
|
||||||
import commands from "../js/commands/index";
|
import {commands} from "../js/commands/index";
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
import upload from "../js/upload";
|
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 = {
|
const formattingHotkeys = {
|
||||||
"mod+k": "\x03",
|
"mod+k": "\x03",
|
||||||
|
@ -82,186 +90,269 @@ const bracketWraps = {
|
||||||
_: "_",
|
_: "_",
|
||||||
};
|
};
|
||||||
|
|
||||||
let autocompletionRef = null;
|
export default defineComponent({
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "ChatInput",
|
name: "ChatInput",
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
watch: {
|
setup(props) {
|
||||||
"channel.id"() {
|
const store = useStore();
|
||||||
if (autocompletionRef) {
|
const input = ref<HTMLTextAreaElement>();
|
||||||
autocompletionRef.hide();
|
const uploadInput = ref<HTMLInputElement>();
|
||||||
}
|
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
|
||||||
},
|
|
||||||
"channel.pendingMessage"() {
|
|
||||||
this.setInputSize();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$root.$on("escapekey", this.blurInput);
|
|
||||||
|
|
||||||
if (this.$store.state.settings.autocomplete) {
|
const setInputSize = () => {
|
||||||
autocompletionRef = autocompletion(this.$refs.input);
|
void nextTick(() => {
|
||||||
}
|
if (!input.value) {
|
||||||
|
return;
|
||||||
const inputTrap = Mousetrap(this.$refs.input);
|
|
||||||
|
|
||||||
inputTrap.bind(Object.keys(formattingHotkeys), function(e, key) {
|
|
||||||
const modifier = formattingHotkeys[key];
|
|
||||||
|
|
||||||
wrapCursor(
|
|
||||||
e.target,
|
|
||||||
modifier,
|
|
||||||
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
inputTrap.bind(Object.keys(bracketWraps), function(e, key) {
|
|
||||||
if (e.target.selectionStart !== e.target.selectionEnd) {
|
|
||||||
wrapCursor(e.target, key, bracketWraps[key]);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
inputTrap.bind(["up", "down"], (e, key) => {
|
|
||||||
if (
|
|
||||||
this.$store.state.isAutoCompleting ||
|
|
||||||
e.target.selectionStart !== e.target.selectionEnd
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {channel} = this;
|
|
||||||
|
|
||||||
if (channel.inputHistoryPosition === 0) {
|
|
||||||
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "up") {
|
|
||||||
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
|
||||||
channel.inputHistoryPosition++;
|
|
||||||
}
|
}
|
||||||
} else if (channel.inputHistoryPosition > 0) {
|
|
||||||
channel.inputHistoryPosition--;
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
|
const style = window.getComputedStyle(input.value);
|
||||||
this.$refs.input.value = channel.pendingMessage;
|
const lineHeight = parseFloat(style.lineHeight) || 1;
|
||||||
this.setInputSize();
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.$store.state.serverConfiguration.fileUpload) {
|
|
||||||
upload.mounted();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroyed() {
|
|
||||||
this.$root.$off("escapekey", this.blurInput);
|
|
||||||
|
|
||||||
if (autocompletionRef) {
|
|
||||||
autocompletionRef.destroy();
|
|
||||||
autocompletionRef = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
upload.abort();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setPendingMessage(e) {
|
|
||||||
this.channel.pendingMessage = e.target.value;
|
|
||||||
this.channel.inputHistoryPosition = 0;
|
|
||||||
this.setInputSize();
|
|
||||||
},
|
|
||||||
setInputSize() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const style = window.getComputedStyle(this.$refs.input);
|
|
||||||
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
|
|
||||||
|
|
||||||
// Start by resetting height before computing as scrollHeight does not
|
// Start by resetting height before computing as scrollHeight does not
|
||||||
// decrease when deleting characters
|
// decrease when deleting characters
|
||||||
this.$refs.input.style.height = "";
|
input.value.style.height = "";
|
||||||
|
|
||||||
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
// 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
|
// because some browsers tend to incorrently round the values when using high density
|
||||||
// displays or using page zoom feature
|
// displays or using page zoom feature
|
||||||
this.$refs.input.style.height =
|
input.value.style.height = `${
|
||||||
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
|
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
|
||||||
|
}px`;
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
getInputPlaceholder(channel) {
|
|
||||||
if (channel.type === "channel" || channel.type === "query") {
|
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 `Write to ${channel.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
},
|
};
|
||||||
onSubmit() {
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (!input.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Triggering click event opens the virtual keyboard on mobile
|
// Triggering click event opens the virtual keyboard on mobile
|
||||||
// This can only be called from another interactive event (e.g. button click)
|
// This can only be called from another interactive event (e.g. button click)
|
||||||
this.$refs.input.click();
|
input.value.click();
|
||||||
this.$refs.input.focus();
|
input.value.focus();
|
||||||
|
|
||||||
if (!this.$store.state.isConnected) {
|
if (!store.state.isConnected) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = this.channel.id;
|
const target = props.channel.id;
|
||||||
const text = this.channel.pendingMessage;
|
const text = props.channel.pendingMessage;
|
||||||
|
|
||||||
if (text.length === 0) {
|
if (text.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autocompletionRef) {
|
if (autocompletionRef.value) {
|
||||||
autocompletionRef.hide();
|
autocompletionRef.value.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channel.inputHistoryPosition = 0;
|
props.channel.inputHistoryPosition = 0;
|
||||||
this.channel.pendingMessage = "";
|
props.channel.pendingMessage = "";
|
||||||
this.$refs.input.value = "";
|
input.value.value = "";
|
||||||
this.setInputSize();
|
setInputSize();
|
||||||
|
|
||||||
// Store new message in history if last message isn't already equal
|
// Store new message in history if last message isn't already equal
|
||||||
if (this.channel.inputHistory[1] !== text) {
|
if (props.channel.inputHistory[1] !== text) {
|
||||||
this.channel.inputHistory.splice(1, 0, text);
|
props.channel.inputHistory.splice(1, 0, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit input history to a 100 entries
|
// Limit input history to a 100 entries
|
||||||
if (this.channel.inputHistory.length > 100) {
|
if (props.channel.inputHistory.length > 100) {
|
||||||
this.channel.inputHistory.pop();
|
props.channel.inputHistory.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text[0] === "/") {
|
if (text[0] === "/") {
|
||||||
const args = text.substr(1).split(" ");
|
const args = text.substring(1).split(" ");
|
||||||
const cmd = args.shift().toLowerCase();
|
const cmd = args.shift()?.toLowerCase();
|
||||||
|
|
||||||
if (
|
if (!cmd) {
|
||||||
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
return false;
|
||||||
commands[cmd].input(args)
|
}
|
||||||
) {
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(commands, cmd) && commands[cmd](args)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit("input", {target, text});
|
socket.emit("input", {target, text});
|
||||||
},
|
};
|
||||||
onUploadInputChange() {
|
|
||||||
const files = Array.from(this.$refs.uploadInput.files);
|
const onUploadInputChange = () => {
|
||||||
|
if (!uploadInput.value || !uploadInput.value.files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(uploadInput.value.files);
|
||||||
upload.triggerUpload(files);
|
upload.triggerUpload(files);
|
||||||
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file
|
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
|
||||||
},
|
};
|
||||||
openFileUpload() {
|
|
||||||
this.$refs.uploadInput.click();
|
const openFileUpload = () => {
|
||||||
},
|
uploadInput.value?.click();
|
||||||
blurInput() {
|
};
|
||||||
this.$refs.input.blur();
|
|
||||||
},
|
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>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<aside ref="userlist" class="userlist" @mouseleave="removeHoverUser">
|
<aside
|
||||||
|
ref="userlist"
|
||||||
|
class="userlist"
|
||||||
|
:aria-label="'User list for ' + channel.name"
|
||||||
|
@mouseleave="removeHoverUser"
|
||||||
|
>
|
||||||
<div class="count">
|
<div class="count">
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
|
@ -23,17 +28,19 @@
|
||||||
<div
|
<div
|
||||||
v-for="(users, mode) in groupedUsers"
|
v-for="(users, mode) in groupedUsers"
|
||||||
:key="mode"
|
:key="mode"
|
||||||
:class="['user-mode', getModeClass(mode)]"
|
:class="['user-mode', getModeClass(String(mode))]"
|
||||||
>
|
>
|
||||||
<template v-if="userSearchInput.length > 0">
|
<template v-if="userSearchInput.length > 0">
|
||||||
|
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
|
||||||
<Username
|
<Username
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.original.nick"
|
:key="user.original.nick + '-search'"
|
||||||
:on-hover="hoverUser"
|
:on-hover="hoverUser"
|
||||||
:active="user.original === activeUser"
|
:active="user.original === activeUser"
|
||||||
:user="user.original"
|
:user="user.original"
|
||||||
v-html="user.original.mode + user.string"
|
v-html="user.string"
|
||||||
/>
|
/>
|
||||||
|
<!-- eslint-enable -->
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Username
|
<Username
|
||||||
|
@ -49,8 +56,11 @@
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import {filter as fuzzyFilter} from "fuzzy";
|
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";
|
import Username from "./Username.vue";
|
||||||
|
|
||||||
const modes = {
|
const modes = {
|
||||||
|
@ -63,75 +73,89 @@ const modes = {
|
||||||
"": "normal",
|
"": "normal",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "ChatUserList",
|
name: "ChatUserList",
|
||||||
components: {
|
components: {
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
data() {
|
setup(props) {
|
||||||
return {
|
const userSearchInput = ref("");
|
||||||
userSearchInput: "",
|
const activeUser = ref<UserInMessage | null>();
|
||||||
activeUser: null,
|
const userlist = ref<HTMLDivElement>();
|
||||||
};
|
const filteredUsers = computed(() => {
|
||||||
},
|
if (!userSearchInput.value) {
|
||||||
computed: {
|
|
||||||
// filteredUsers is computed, to avoid unnecessary filtering
|
|
||||||
// as it is shared between filtering and keybindings.
|
|
||||||
filteredUsers() {
|
|
||||||
if (!this.userSearchInput) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fuzzyFilter(this.userSearchInput, this.channel.users, {
|
return fuzzyFilter(userSearchInput.value, props.channel.users, {
|
||||||
pre: "<b>",
|
pre: "<b>",
|
||||||
post: "</b>",
|
post: "</b>",
|
||||||
extract: (u) => u.nick,
|
extract: (u) => u.nick,
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
groupedUsers() {
|
|
||||||
|
const groupedUsers = computed(() => {
|
||||||
const groups = {};
|
const groups = {};
|
||||||
|
|
||||||
if (this.userSearchInput) {
|
if (userSearchInput.value && filteredUsers.value) {
|
||||||
const result = this.filteredUsers;
|
const result = filteredUsers.value;
|
||||||
|
|
||||||
for (const user of result) {
|
for (const user of result) {
|
||||||
if (!groups[user.original.mode]) {
|
const mode: string = user.original.modes[0] || "";
|
||||||
groups[user.original.mode] = [];
|
|
||||||
|
if (!groups[mode]) {
|
||||||
|
groups[mode] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
groups[user.original.mode].push(user);
|
// Prepend user mode to search result
|
||||||
|
user.string = mode + user.string;
|
||||||
|
|
||||||
|
groups[mode].push(user);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const user of this.channel.users) {
|
for (const user of props.channel.users) {
|
||||||
if (!groups[user.mode]) {
|
const mode = user.modes[0] || "";
|
||||||
groups[user.mode] = [user];
|
|
||||||
|
if (!groups[mode]) {
|
||||||
|
groups[mode] = [user];
|
||||||
} else {
|
} else {
|
||||||
groups[user.mode].push(user);
|
groups[mode].push(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups as {
|
||||||
},
|
[mode: string]: (ClientUser & {
|
||||||
},
|
original: UserInMessage;
|
||||||
methods: {
|
string: string;
|
||||||
setUserSearchInput(e) {
|
})[];
|
||||||
this.userSearchInput = e.target.value;
|
};
|
||||||
},
|
});
|
||||||
getModeClass(mode) {
|
|
||||||
return modes[mode];
|
const setUserSearchInput = (e: Event) => {
|
||||||
},
|
userSearchInput.value = (e.target as HTMLInputElement).value;
|
||||||
selectUser() {
|
};
|
||||||
|
|
||||||
|
const getModeClass = (mode: string) => {
|
||||||
|
return modes[mode] as typeof modes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectUser = () => {
|
||||||
// Simulate a click on the active user to open the context menu.
|
// Simulate a click on the active user to open the context menu.
|
||||||
// Coordinates are provided to position the menu correctly.
|
// Coordinates are provided to position the menu correctly.
|
||||||
if (!this.activeUser) {
|
if (!activeUser.value || !userlist.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = userlist.value.querySelector(".active");
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = this.$refs.userlist.querySelector(".active");
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const ev = new MouseEvent("click", {
|
const ev = new MouseEvent("click", {
|
||||||
view: window,
|
view: window,
|
||||||
|
@ -141,38 +165,58 @@ export default {
|
||||||
clientY: rect.top + rect.height,
|
clientY: rect.top + rect.height,
|
||||||
});
|
});
|
||||||
el.dispatchEvent(ev);
|
el.dispatchEvent(ev);
|
||||||
},
|
};
|
||||||
hoverUser(user) {
|
|
||||||
this.activeUser = user;
|
const hoverUser = (user: UserInMessage) => {
|
||||||
},
|
activeUser.value = user;
|
||||||
removeHoverUser() {
|
};
|
||||||
this.activeUser = null;
|
|
||||||
},
|
const removeHoverUser = () => {
|
||||||
navigateUserList(event, direction) {
|
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
|
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||||
// and redirecting it to the message list container for scrolling
|
// and redirecting it to the message list container for scrolling
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
let users = this.channel.users;
|
let users = props.channel.users;
|
||||||
|
|
||||||
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
// Only using filteredUsers when we have to avoids filtering when it's not needed
|
||||||
if (this.userSearchInput) {
|
if (userSearchInput.value && filteredUsers.value) {
|
||||||
users = this.filteredUsers.map((result) => result.original);
|
users = filteredUsers.value.map((result) => result.original);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bail out if there's no users to select
|
// Bail out if there's no users to select
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
this.activeUser = null;
|
activeUser.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentIndex = users.indexOf(this.activeUser);
|
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 there's no active user select the first or last one depending on direction
|
||||||
if (!this.activeUser || currentIndex === -1) {
|
if (!activeUser.value) {
|
||||||
this.activeUser = direction ? users[0] : users[users.length - 1];
|
abort();
|
||||||
this.scrollToActiveUser();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentIndex = users.indexOf(activeUser.value as ClientUser);
|
||||||
|
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
abort();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,16 +232,24 @@ export default {
|
||||||
currentIndex -= users.length;
|
currentIndex -= users.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeUser = users[currentIndex];
|
activeUser.value = users[currentIndex];
|
||||||
this.scrollToActiveUser();
|
scrollToActiveUser();
|
||||||
},
|
};
|
||||||
scrollToActiveUser() {
|
|
||||||
// Scroll the list if needed after the active class is applied
|
return {
|
||||||
this.$nextTick(() => {
|
filteredUsers,
|
||||||
const el = this.$refs.userlist.querySelector(".active");
|
groupedUsers,
|
||||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
userSearchInput,
|
||||||
});
|
activeUser,
|
||||||
},
|
userlist,
|
||||||
|
|
||||||
|
setUserSearchInput,
|
||||||
|
getModeClass,
|
||||||
|
selectUser,
|
||||||
|
hoverUser,
|
||||||
|
removeHoverUser,
|
||||||
|
navigateUserList,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</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>
|
|
@ -2,6 +2,7 @@
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
id="context-menu-container"
|
id="context-menu-container"
|
||||||
|
:class="{passthrough}"
|
||||||
@click="containerClick"
|
@click="containerClick"
|
||||||
@contextmenu.prevent="containerClick"
|
@contextmenu.prevent="containerClick"
|
||||||
@keydown.exact.up.prevent="navigateMenu(-1)"
|
@keydown.exact.up.prevent="navigateMenu(-1)"
|
||||||
|
@ -13,14 +14,17 @@
|
||||||
id="context-menu"
|
id="context-menu"
|
||||||
ref="contextMenu"
|
ref="contextMenu"
|
||||||
role="menu"
|
role="menu"
|
||||||
:style="style"
|
:style="{
|
||||||
|
top: style.top + 'px',
|
||||||
|
left: style.left + 'px',
|
||||||
|
}"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@mouseleave="activeItem = -1"
|
@mouseleave="activeItem = -1"
|
||||||
@keydown.enter.prevent="clickActiveItem"
|
@keydown.enter.prevent="clickActiveItem"
|
||||||
>
|
>
|
||||||
<template v-for="(item, id) of items">
|
<!-- TODO: type -->
|
||||||
|
<template v-for="(item, id) of (items as any)" :key="item.name">
|
||||||
<li
|
<li
|
||||||
:key="item.name"
|
|
||||||
:class="[
|
:class="[
|
||||||
'context-menu-' + item.type,
|
'context-menu-' + item.type,
|
||||||
item.class ? 'context-menu-' + item.class : null,
|
item.class ? 'context-menu-' + item.class : null,
|
||||||
|
@ -37,141 +41,77 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
generateUserContextMenu,
|
generateUserContextMenu,
|
||||||
generateChannelContextMenu,
|
generateChannelContextMenu,
|
||||||
generateRemoveNetwork,
|
generateInlineChannelContextMenu,
|
||||||
} from "../js/helpers/contextMenu.js";
|
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 {
|
export default defineComponent({
|
||||||
name: "ContextMenu",
|
name: "ContextMenu",
|
||||||
props: {
|
props: {
|
||||||
message: Object,
|
message: {
|
||||||
},
|
required: false,
|
||||||
data() {
|
type: Object as PropType<ClientMessage>,
|
||||||
return {
|
|
||||||
isOpen: false,
|
|
||||||
previousActiveElement: null,
|
|
||||||
items: [],
|
|
||||||
activeItem: -1,
|
|
||||||
style: {
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$root.$on("escapekey", this.close);
|
|
||||||
this.$root.$on("contextmenu:user", this.openUserContextMenu);
|
|
||||||
this.$root.$on("contextmenu:channel", this.openChannelContextMenu);
|
|
||||||
this.$root.$on("contextmenu:removenetwork", this.openRemoveNetworkContextMenu);
|
|
||||||
},
|
|
||||||
destroyed() {
|
|
||||||
this.$root.$off("escapekey", this.close);
|
|
||||||
this.$root.$off("contextmenu:user", this.openUserContextMenu);
|
|
||||||
this.$root.$off("contextmenu:channel", this.openChannelContextMenu);
|
|
||||||
this.$root.$off("contextmenu:removenetwork", this.openRemoveNetworkContextMenu);
|
|
||||||
|
|
||||||
this.close();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
openRemoveNetworkContextMenu(data) {
|
|
||||||
const items = generateRemoveNetwork(this.$root, data.lobby);
|
|
||||||
this.open(data.event, items);
|
|
||||||
},
|
},
|
||||||
openChannelContextMenu(data) {
|
},
|
||||||
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
|
setup() {
|
||||||
this.open(data.event, items);
|
const store = useStore();
|
||||||
},
|
const router = useRouter();
|
||||||
openUserContextMenu(data) {
|
|
||||||
const {network, channel} = this.$store.state.activeChannel;
|
|
||||||
|
|
||||||
const items = generateUserContextMenu(
|
const isOpen = ref(false);
|
||||||
this.$root,
|
const passthrough = ref(false);
|
||||||
channel,
|
|
||||||
network,
|
|
||||||
channel.users.find((u) => u.nick === data.user.nick) || {nick: data.user.nick}
|
|
||||||
);
|
|
||||||
this.open(data.event, items);
|
|
||||||
},
|
|
||||||
open(event, items) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.previousActiveElement = document.activeElement;
|
const contextMenu = ref<HTMLUListElement | null>();
|
||||||
this.items = items;
|
const previousActiveElement = ref<HTMLElement | null>();
|
||||||
this.activeItem = 0;
|
const items = ref<ContextMenuItem[]>([]);
|
||||||
this.isOpen = true;
|
const activeItem = ref(-1);
|
||||||
|
const style = ref({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Position the menu and set the focus on the first item after it's size has updated
|
const close = () => {
|
||||||
this.$nextTick(() => {
|
if (!isOpen.value) {
|
||||||
const pos = this.positionContextMenu(event);
|
|
||||||
this.style.left = pos.left + "px";
|
|
||||||
this.style.top = pos.top + "px";
|
|
||||||
this.$refs.contextMenu.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
if (!this.isOpen) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isOpen = false;
|
isOpen.value = false;
|
||||||
this.items = [];
|
items.value = [];
|
||||||
|
|
||||||
if (this.previousActiveElement) {
|
if (previousActiveElement.value) {
|
||||||
this.previousActiveElement.focus();
|
previousActiveElement.value.focus();
|
||||||
this.previousActiveElement = null;
|
previousActiveElement.value = null;
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
hoverItem(id) {
|
|
||||||
this.activeItem = id;
|
|
||||||
},
|
|
||||||
clickItem(item) {
|
|
||||||
this.close();
|
|
||||||
|
|
||||||
if (item.action) {
|
const enablePointerEvents = () => {
|
||||||
item.action();
|
passthrough.value = false;
|
||||||
} else if (item.link) {
|
document.body.removeEventListener("pointerup", enablePointerEvents);
|
||||||
this.$router.push(item.link);
|
};
|
||||||
}
|
|
||||||
},
|
|
||||||
clickActiveItem() {
|
|
||||||
if (this.items[this.activeItem]) {
|
|
||||||
this.clickItem(this.items[this.activeItem]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigateMenu(direction) {
|
|
||||||
let currentIndex = this.activeItem;
|
|
||||||
|
|
||||||
currentIndex += direction;
|
const containerClick = (event: MouseEvent) => {
|
||||||
|
|
||||||
const nextItem = this.items[currentIndex];
|
|
||||||
|
|
||||||
// If the next item we would select is a divider, skip over it
|
|
||||||
if (nextItem && nextItem.type === "divider") {
|
|
||||||
currentIndex += direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentIndex < 0) {
|
|
||||||
currentIndex += this.items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentIndex > this.items.length - 1) {
|
|
||||||
currentIndex -= this.items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeItem = currentIndex;
|
|
||||||
},
|
|
||||||
containerClick(event) {
|
|
||||||
if (event.currentTarget === event.target) {
|
if (event.currentTarget === event.target) {
|
||||||
this.close();
|
close();
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
positionContextMenu(event) {
|
|
||||||
const element = event.target;
|
const positionContextMenu = (event: MouseEvent) => {
|
||||||
const menuWidth = this.$refs.contextMenu.offsetWidth;
|
const element = event.target as HTMLElement;
|
||||||
const menuHeight = this.$refs.contextMenu.offsetHeight;
|
|
||||||
|
if (!contextMenu.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuWidth = contextMenu.value?.offsetWidth;
|
||||||
|
const menuHeight = contextMenu.value?.offsetHeight;
|
||||||
|
|
||||||
if (element && element.classList.contains("menu")) {
|
if (element && element.classList.contains("menu")) {
|
||||||
return {
|
return {
|
||||||
|
@ -191,7 +131,154 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
return offset;
|
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>
|
</script>
|
||||||
|
|
|
@ -6,50 +6,61 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import calendar from "dayjs/plugin/calendar";
|
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);
|
dayjs.extend(calendar);
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "DateMarker",
|
name: "DateMarker",
|
||||||
props: {
|
props: {
|
||||||
message: Object,
|
message: {
|
||||||
},
|
type: Object as PropType<ClientMessage>,
|
||||||
computed: {
|
required: true,
|
||||||
localeDate() {
|
|
||||||
return dayjs(this.message.time).format("D MMMM YYYY");
|
|
||||||
},
|
},
|
||||||
|
focused: Boolean,
|
||||||
},
|
},
|
||||||
mounted() {
|
setup(props) {
|
||||||
if (this.hoursPassed() < 48) {
|
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
|
||||||
this.$root.$on("daychange", this.dayChange);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
this.$root.$off("daychange", this.dayChange);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
hoursPassed() {
|
|
||||||
return (Date.now() - Date.parse(this.message.time)) / 3600000;
|
|
||||||
},
|
|
||||||
dayChange() {
|
|
||||||
this.$forceUpdate();
|
|
||||||
|
|
||||||
if (this.hoursPassed() >= 48) {
|
const hoursPassed = () => {
|
||||||
this.$root.$off("daychange", this.dayChange);
|
return (Date.now() - Date.parse(props.message.time.toString())) / 3600000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayChange = () => {
|
||||||
|
if (hoursPassed() >= 48) {
|
||||||
|
eventbus.off("daychange", dayChange);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
friendlyDate() {
|
|
||||||
|
const friendlyDate = () => {
|
||||||
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
// See http://momentjs.com/docs/#/displaying/calendar-time/
|
||||||
return dayjs(this.message.time).calendar(null, {
|
return dayjs(props.message.time).calendar(null, {
|
||||||
sameDay: "[Today]",
|
sameDay: "[Today]",
|
||||||
lastDay: "[Yesterday]",
|
lastDay: "[Yesterday]",
|
||||||
lastWeek: "D MMMM YYYY",
|
lastWeek: "D MMMM YYYY",
|
||||||
sameElse: "D MMMM YYYY",
|
sameElse: "D MMMM YYYY",
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (hoursPassed() < 48) {
|
||||||
|
eventbus.on("daychange", dayChange);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
eventbus.off("daychange", dayChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
localeDate,
|
||||||
|
friendlyDate,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</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>
|
|
@ -9,6 +9,20 @@
|
||||||
>
|
>
|
||||||
<template v-if="link !== null">
|
<template v-if="link !== null">
|
||||||
<button class="close-btn" aria-label="Close"></button>
|
<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>
|
<a class="open-btn" :href="link.link" target="_blank" rel="noopener"></a>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
|
@ -24,82 +38,125 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
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",
|
name: "ImageViewer",
|
||||||
data() {
|
setup() {
|
||||||
return {
|
const viewer = ref<HTMLDivElement>();
|
||||||
link: null,
|
const image = ref<HTMLImageElement>();
|
||||||
position: {
|
|
||||||
x: 0,
|
const link = ref<ClientLinkPreview | null>(null);
|
||||||
y: 0,
|
const previousImage = ref<ClientLinkPreview | null>();
|
||||||
},
|
const nextImage = ref<ClientLinkPreview | null>();
|
||||||
transform: {
|
const channel = ref<ClientChan | null>();
|
||||||
x: 0,
|
|
||||||
y: 0,
|
const position = ref<{
|
||||||
scale: 0,
|
x: number;
|
||||||
},
|
y: number;
|
||||||
};
|
}>({
|
||||||
},
|
x: 0,
|
||||||
computed: {
|
y: 0,
|
||||||
computeImageStyles() {
|
});
|
||||||
|
|
||||||
|
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
|
// Sub pixels may cause the image to blur in certain browsers
|
||||||
// round it down to prevent that
|
// round it down to prevent that
|
||||||
const transformX = Math.floor(this.transform.x);
|
const transformX = Math.floor(transform.value.x);
|
||||||
const transformY = Math.floor(this.transform.y);
|
const transformY = Math.floor(transform.value.y);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: `${this.position.x}px`,
|
left: `${position.value.x}px`,
|
||||||
top: `${this.position.y}px`,
|
top: `${position.value.y}px`,
|
||||||
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${this.transform.scale}, ${this.transform.scale}, 1)`,
|
transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${transform.value.scale}, ${transform.value.scale}, 1)`,
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
},
|
|
||||||
watch: {
|
const closeViewer = () => {
|
||||||
link() {
|
if (link.value === null) {
|
||||||
// TODO: history.pushState
|
|
||||||
if (this.link === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$root.$on("resize", this.correctPosition);
|
channel.value = null;
|
||||||
},
|
previousImage.value = null;
|
||||||
},
|
nextImage.value = null;
|
||||||
mounted() {
|
link.value = null;
|
||||||
this.$root.$on("escapekey", this.closeViewer);
|
};
|
||||||
},
|
|
||||||
destroyed() {
|
const setPrevNextImages = () => {
|
||||||
this.$root.$off("escapekey", this.closeViewer);
|
if (!channel.value || !link.value) {
|
||||||
},
|
return null;
|
||||||
methods: {
|
}
|
||||||
closeViewer() {
|
|
||||||
if (this.link === 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$root.$off("resize", this.correctPosition);
|
const width = viewerEl.offsetWidth;
|
||||||
this.link = null;
|
const height = viewerEl.offsetHeight;
|
||||||
},
|
const scale = Math.min(1, width / imageEl.width, height / imageEl.height);
|
||||||
onImageLoad() {
|
|
||||||
this.prepareImage();
|
|
||||||
},
|
|
||||||
prepareImage() {
|
|
||||||
const viewer = this.$refs.viewer;
|
|
||||||
const image = this.$refs.image;
|
|
||||||
const width = viewer.offsetWidth;
|
|
||||||
const height = viewer.offsetHeight;
|
|
||||||
const scale = Math.min(1, width / image.width, height / image.height);
|
|
||||||
|
|
||||||
this.position.x = Math.floor(-image.naturalWidth / 2);
|
position.value.x = Math.floor(-image.value!.naturalWidth / 2);
|
||||||
this.position.y = Math.floor(-image.naturalHeight / 2);
|
position.value.y = Math.floor(-image.value!.naturalHeight / 2);
|
||||||
this.transform.scale = Math.max(scale, 0.1);
|
transform.value.scale = Math.max(scale, 0.1);
|
||||||
this.transform.x = width / 2;
|
transform.value.x = width / 2;
|
||||||
this.transform.y = height / 2;
|
transform.value.y = height / 2;
|
||||||
},
|
};
|
||||||
calculateZoomShift(newScale, x, y, oldScale) {
|
|
||||||
const imageWidth = this.$refs.image.width;
|
const onImageLoad = () => {
|
||||||
const centerX = this.$refs.viewer.offsetWidth / 2;
|
prepareImage();
|
||||||
const centerY = this.$refs.viewer.offsetHeight / 2;
|
};
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
x:
|
x:
|
||||||
|
@ -111,32 +168,40 @@ export default {
|
||||||
((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale +
|
((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale +
|
||||||
(imageWidth * newScale) / 2,
|
(imageWidth * newScale) / 2,
|
||||||
};
|
};
|
||||||
},
|
};
|
||||||
correctPosition() {
|
|
||||||
const image = this.$refs.image;
|
const correctPosition = () => {
|
||||||
const widthScaled = image.width * this.transform.scale;
|
const imageEl = image.value;
|
||||||
const heightScaled = image.height * this.transform.scale;
|
const viewerEl = viewer.value;
|
||||||
const containerWidth = this.$refs.viewer.offsetWidth;
|
|
||||||
const containerHeight = this.$refs.viewer.offsetHeight;
|
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) {
|
if (widthScaled < containerWidth) {
|
||||||
this.transform.x = containerWidth / 2;
|
transform.value.x = containerWidth / 2;
|
||||||
} else if (this.transform.x - widthScaled / 2 > 0) {
|
} else if (transform.value.x - widthScaled / 2 > 0) {
|
||||||
this.transform.x = widthScaled / 2;
|
transform.value.x = widthScaled / 2;
|
||||||
} else if (this.transform.x + widthScaled / 2 < containerWidth) {
|
} else if (transform.value.x + widthScaled / 2 < containerWidth) {
|
||||||
this.transform.x = containerWidth - widthScaled / 2;
|
transform.value.x = containerWidth - widthScaled / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (heightScaled < containerHeight) {
|
if (heightScaled < containerHeight) {
|
||||||
this.transform.y = containerHeight / 2;
|
transform.value.y = containerHeight / 2;
|
||||||
} else if (this.transform.y - heightScaled / 2 > 0) {
|
} else if (transform.value.y - heightScaled / 2 > 0) {
|
||||||
this.transform.y = heightScaled / 2;
|
transform.value.y = heightScaled / 2;
|
||||||
} else if (this.transform.y + heightScaled / 2 < containerHeight) {
|
} else if (transform.value.y + heightScaled / 2 < containerHeight) {
|
||||||
this.transform.y = containerHeight - heightScaled / 2;
|
transform.value.y = containerHeight - heightScaled / 2;
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
// Reduce multiple touch points into a single x/y/scale
|
// Reduce multiple touch points into a single x/y/scale
|
||||||
reduceTouches(touches) {
|
const reduceTouches = (touches: TouchList) => {
|
||||||
let totalX = 0;
|
let totalX = 0;
|
||||||
let totalY = 0;
|
let totalY = 0;
|
||||||
let totalScale = 0;
|
let totalScale = 0;
|
||||||
|
@ -166,17 +231,19 @@ export default {
|
||||||
y: totalY / touches.length,
|
y: totalY / touches.length,
|
||||||
scale: totalScale / touches.length,
|
scale: totalScale / touches.length,
|
||||||
};
|
};
|
||||||
},
|
};
|
||||||
onTouchStart(e) {
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
// prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer
|
// prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
},
|
};
|
||||||
|
|
||||||
// Touch image manipulation:
|
// Touch image manipulation:
|
||||||
// 1. Move around by dragging it with one finger
|
// 1. Move around by dragging it with one finger
|
||||||
// 2. Change image scale by using two fingers
|
// 2. Change image scale by using two fingers
|
||||||
onImageTouchStart(e) {
|
const onImageTouchStart = (e: TouchEvent) => {
|
||||||
const image = this.$refs.image;
|
const img = image.value;
|
||||||
let touch = this.reduceTouches(e.touches);
|
let touch = reduceTouches(e.touches);
|
||||||
let currentTouches = e.touches;
|
let currentTouches = e.touches;
|
||||||
let touchEndFingers = 0;
|
let touchEndFingers = 0;
|
||||||
|
|
||||||
|
@ -187,21 +254,21 @@ export default {
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTransform = {
|
const startTransform = {
|
||||||
x: this.transform.x,
|
x: transform.value.x,
|
||||||
y: this.transform.y,
|
y: transform.value.y,
|
||||||
scale: this.transform.scale,
|
scale: transform.value.scale,
|
||||||
};
|
};
|
||||||
|
|
||||||
const touchMove = (moveEvent) => {
|
const touchMove = (moveEvent) => {
|
||||||
touch = this.reduceTouches(moveEvent.touches);
|
touch = reduceTouches(moveEvent.touches);
|
||||||
|
|
||||||
if (currentTouches.length !== moveEvent.touches.length) {
|
if (currentTouches.length !== moveEvent.touches.length) {
|
||||||
currentTransform.x = touch.x;
|
currentTransform.x = touch.x;
|
||||||
currentTransform.y = touch.y;
|
currentTransform.y = touch.y;
|
||||||
currentTransform.scale = touch.scale;
|
currentTransform.scale = touch.scale;
|
||||||
startTransform.x = this.transform.x;
|
startTransform.x = transform.value.x;
|
||||||
startTransform.y = this.transform.y;
|
startTransform.y = transform.value.y;
|
||||||
startTransform.scale = this.transform.scale;
|
startTransform.scale = transform.value.scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaX = touch.x - currentTransform.x;
|
const deltaX = touch.x - currentTransform.x;
|
||||||
|
@ -211,20 +278,25 @@ export default {
|
||||||
touchEndFingers = 0;
|
touchEndFingers = 0;
|
||||||
|
|
||||||
const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale));
|
const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale));
|
||||||
const fixedPosition = this.calculateZoomShift(
|
|
||||||
|
const fixedPosition = calculateZoomShift(
|
||||||
newScale,
|
newScale,
|
||||||
startTransform.scale,
|
startTransform.scale,
|
||||||
startTransform.x,
|
startTransform.x,
|
||||||
startTransform.y
|
startTransform.y
|
||||||
);
|
);
|
||||||
|
|
||||||
this.transform.x = fixedPosition.x + deltaX;
|
if (!fixedPosition) {
|
||||||
this.transform.y = fixedPosition.y + deltaY;
|
return;
|
||||||
this.transform.scale = newScale;
|
}
|
||||||
this.correctPosition();
|
|
||||||
|
transform.value.x = fixedPosition.x + deltaX;
|
||||||
|
transform.value.y = fixedPosition.y + deltaY;
|
||||||
|
transform.value.scale = newScale;
|
||||||
|
correctPosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
const touchEnd = (endEvent) => {
|
const touchEnd = (endEvent: TouchEvent) => {
|
||||||
const changedTouches = endEvent.changedTouches.length;
|
const changedTouches = endEvent.changedTouches.length;
|
||||||
|
|
||||||
if (currentTouches.length > changedTouches + touchEndFingers) {
|
if (currentTouches.length > changedTouches + touchEndFingers) {
|
||||||
|
@ -234,27 +306,30 @@ export default {
|
||||||
|
|
||||||
// todo: this is swipe to close, but it's not working very well due to unfinished delta calculation
|
// todo: this is swipe to close, but it's not working very well due to unfinished delta calculation
|
||||||
/* if (
|
/* if (
|
||||||
this.transform.scale <= 1 &&
|
transform.value.scale <= 1 &&
|
||||||
endEvent.changedTouches[0].clientY - startTransform.y <= -70
|
endEvent.changedTouches[0].clientY - startTransform.y <= -70
|
||||||
) {
|
) {
|
||||||
return this.closeViewer();
|
return this.closeViewer();
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
this.correctPosition();
|
correctPosition();
|
||||||
|
|
||||||
image.removeEventListener("touchmove", touchMove, {passive: true});
|
img?.removeEventListener("touchmove", touchMove);
|
||||||
image.removeEventListener("touchend", touchEnd, {passive: true});
|
img?.removeEventListener("touchend", touchEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
image.addEventListener("touchmove", touchMove, {passive: true});
|
img?.addEventListener("touchmove", touchMove, {passive: true});
|
||||||
image.addEventListener("touchend", touchEnd, {passive: true});
|
img?.addEventListener("touchend", touchEnd, {passive: true});
|
||||||
},
|
};
|
||||||
|
|
||||||
// Image mouse manipulation:
|
// Image mouse manipulation:
|
||||||
// 1. Mouse wheel scrolling will zoom in and out
|
// 1. Mouse wheel scrolling will zoom in and out
|
||||||
// 2. If image is zoomed in, simply dragging it will move it around
|
// 2. If image is zoomed in, simply dragging it will move it around
|
||||||
onImageMouseDown(e) {
|
const onImageMouseDown = (e: MouseEvent) => {
|
||||||
// todo: ignore if in touch event currently?
|
// todo: ignore if in touch event currently?
|
||||||
|
|
||||||
// only left mouse
|
// only left mouse
|
||||||
|
// TODO: e.buttons?
|
||||||
if (e.which !== 1) {
|
if (e.which !== 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -262,22 +337,26 @@ export default {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const viewer = this.$refs.viewer;
|
const viewerEl = viewer.value;
|
||||||
const image = this.$refs.image;
|
const imageEl = image.value;
|
||||||
|
|
||||||
|
if (!viewerEl || !imageEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
const startTransformX = this.transform.x;
|
const startTransformX = transform.value.x;
|
||||||
const startTransformY = this.transform.y;
|
const startTransformY = transform.value.y;
|
||||||
const widthScaled = image.width * this.transform.scale;
|
const widthScaled = imageEl.width * transform.value.scale;
|
||||||
const heightScaled = image.height * this.transform.scale;
|
const heightScaled = imageEl.height * transform.value.scale;
|
||||||
const containerWidth = viewer.offsetWidth;
|
const containerWidth = viewerEl.offsetWidth;
|
||||||
const containerHeight = viewer.offsetHeight;
|
const containerHeight = viewerEl.offsetHeight;
|
||||||
const centerX = this.transform.x - widthScaled / 2;
|
const centerX = transform.value.x - widthScaled / 2;
|
||||||
const centerY = this.transform.y - heightScaled / 2;
|
const centerY = transform.value.y - heightScaled / 2;
|
||||||
let movedDistance = 0;
|
let movedDistance = 0;
|
||||||
|
|
||||||
const mouseMove = (moveEvent) => {
|
const mouseMove = (moveEvent: MouseEvent) => {
|
||||||
moveEvent.stopPropagation();
|
moveEvent.stopPropagation();
|
||||||
moveEvent.preventDefault();
|
moveEvent.preventDefault();
|
||||||
|
|
||||||
|
@ -287,66 +366,113 @@ export default {
|
||||||
movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY));
|
movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY));
|
||||||
|
|
||||||
if (centerX < 0 || widthScaled + centerX > containerWidth) {
|
if (centerX < 0 || widthScaled + centerX > containerWidth) {
|
||||||
this.transform.x = startTransformX + newX;
|
transform.value.x = startTransformX + newX;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (centerY < 0 || heightScaled + centerY > containerHeight) {
|
if (centerY < 0 || heightScaled + centerY > containerHeight) {
|
||||||
this.transform.y = startTransformY + newY;
|
transform.value.y = startTransformY + newY;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.correctPosition();
|
correctPosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
const mouseUp = (upEvent) => {
|
const mouseUp = (upEvent: MouseEvent) => {
|
||||||
this.correctPosition();
|
correctPosition();
|
||||||
|
|
||||||
if (movedDistance < 2 && upEvent.button === 0) {
|
if (movedDistance < 2 && upEvent.button === 0) {
|
||||||
this.closeViewer();
|
closeViewer();
|
||||||
}
|
}
|
||||||
|
|
||||||
image.removeEventListener("mousemove", mouseMove);
|
image.value?.removeEventListener("mousemove", mouseMove);
|
||||||
image.removeEventListener("mouseup", mouseUp);
|
image.value?.removeEventListener("mouseup", mouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
image.addEventListener("mousemove", mouseMove);
|
image.value?.addEventListener("mousemove", mouseMove);
|
||||||
image.addEventListener("mouseup", mouseUp);
|
image.value?.addEventListener("mouseup", mouseUp);
|
||||||
},
|
};
|
||||||
|
|
||||||
// If image is zoomed in, holding ctrl while scrolling will move the image up and down
|
// If image is zoomed in, holding ctrl while scrolling will move the image up and down
|
||||||
onMouseWheel(e) {
|
const onMouseWheel = (e: WheelEvent) => {
|
||||||
// if image viewer is closing (css animation), you can still trigger mousewheel
|
// if image viewer is closing (css animation), you can still trigger mousewheel
|
||||||
// TODO: Figure out a better fix for this
|
// TODO: Figure out a better fix for this
|
||||||
if (this.link === null) {
|
if (link.value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault(); // TODO: Can this be passive?
|
e.preventDefault(); // TODO: Can this be passive?
|
||||||
|
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey) {
|
||||||
this.transform.y += e.deltaY;
|
transform.value.y += e.deltaY;
|
||||||
} else {
|
} else {
|
||||||
const delta = e.deltaY > 0 ? 0.1 : -0.1;
|
const delta = e.deltaY > 0 ? 0.1 : -0.1;
|
||||||
const newScale = Math.min(3, Math.max(0.1, this.transform.scale + delta));
|
const newScale = Math.min(3, Math.max(0.1, transform.value.scale + delta));
|
||||||
const fixedPosition = this.calculateZoomShift(
|
const fixedPosition = calculateZoomShift(
|
||||||
newScale,
|
newScale,
|
||||||
this.transform.scale,
|
transform.value.scale,
|
||||||
this.transform.x,
|
transform.value.x,
|
||||||
this.transform.y
|
transform.value.y
|
||||||
);
|
);
|
||||||
this.transform.scale = newScale;
|
|
||||||
this.transform.x = fixedPosition.x;
|
if (!fixedPosition) {
|
||||||
this.transform.y = fixedPosition.y;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transform.value.scale = newScale;
|
||||||
|
transform.value.x = fixedPosition.x;
|
||||||
|
transform.value.y = fixedPosition.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.correctPosition();
|
correctPosition();
|
||||||
},
|
};
|
||||||
onClick(e) {
|
|
||||||
|
const onClick = (e: Event) => {
|
||||||
// If click triggers on the image, ignore it
|
// If click triggers on the image, ignore it
|
||||||
if (e.target === this.$refs.image) {
|
if (e.target === image.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closeViewer();
|
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>
|
</script>
|
||||||
|
|
|
@ -1,30 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="inline-channel" dir="auto" role="button" tabindex="0" @click="onClick"
|
<span
|
||||||
|
class="inline-channel"
|
||||||
|
dir="auto"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click.prevent="openContextMenu"
|
||||||
|
@contextmenu.prevent="openContextMenu"
|
||||||
><slot></slot
|
><slot></slot
|
||||||
></span>
|
></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import socket from "../js/socket";
|
import {defineComponent} from "vue";
|
||||||
|
import eventbus from "../js/eventbus";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "InlineChannel",
|
name: "InlineChannel",
|
||||||
props: {
|
props: {
|
||||||
channel: String,
|
channel: String,
|
||||||
},
|
},
|
||||||
methods: {
|
setup(props) {
|
||||||
onClick() {
|
const openContextMenu = (event) => {
|
||||||
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(this.channel);
|
eventbus.emit("contextmenu:inline-channel", {
|
||||||
|
event: event,
|
||||||
if (existingChannel) {
|
channel: props.channel,
|
||||||
this.$root.switchToChannel(existingChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("input", {
|
|
||||||
target: this.$store.state.activeChannel.channel.id,
|
|
||||||
text: "/join " + this.channel,
|
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
openContextMenu,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
method="post"
|
method="post"
|
||||||
action=""
|
action=""
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@keydown.esc.prevent="$emit('toggleJoinChannel')"
|
@keydown.esc.prevent="$emit('toggle-join-channel')"
|
||||||
@submit.prevent="onSubmit"
|
@submit.prevent="onSubmit"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
@ -35,54 +35,59 @@
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType, ref} from "vue";
|
||||||
|
import {switchToChannel} from "../js/router";
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
import {ClientNetwork, ClientChan} from "../js/types";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "JoinChannel",
|
name: "JoinChannel",
|
||||||
directives: {
|
directives: {
|
||||||
focus: {
|
focus: {
|
||||||
inserted(el) {
|
mounted: (el: HTMLFormElement) => el.focus(),
|
||||||
el.focus();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
data() {
|
emits: ["toggle-join-channel"],
|
||||||
return {
|
setup(props, {emit}) {
|
||||||
inputChannel: "",
|
const store = useStore();
|
||||||
inputPassword: "",
|
const inputChannel = ref("");
|
||||||
};
|
const inputPassword = ref("");
|
||||||
},
|
|
||||||
methods: {
|
const onSubmit = () => {
|
||||||
onSubmit() {
|
const existingChannel = store.getters.findChannelOnCurrentNetwork(inputChannel.value);
|
||||||
const existingChannel = this.$store.getters.findChannelOnCurrentNetwork(
|
|
||||||
this.inputChannel
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingChannel) {
|
if (existingChannel) {
|
||||||
this.$root.switchToChannel(existingChannel);
|
switchToChannel(existingChannel);
|
||||||
} else {
|
} else {
|
||||||
const chanTypes = this.network.serverOptions.CHANTYPES;
|
const chanTypes = props.network.serverOptions.CHANTYPES;
|
||||||
let channel = this.inputChannel;
|
let channel = inputChannel.value;
|
||||||
|
|
||||||
if (chanTypes && chanTypes.length > 0 && !chanTypes.includes(channel[0])) {
|
if (chanTypes && chanTypes.length > 0 && !chanTypes.includes(channel[0])) {
|
||||||
channel = chanTypes[0] + channel;
|
channel = chanTypes[0] + channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit("input", {
|
socket.emit("input", {
|
||||||
text: `/join ${channel} ${this.inputPassword}`,
|
text: `/join ${channel} ${inputPassword.value}`,
|
||||||
target: this.channel.id,
|
target: props.channel.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputChannel = "";
|
inputChannel.value = "";
|
||||||
this.inputPassword = "";
|
inputPassword.value = "";
|
||||||
this.$emit("toggleJoinChannel");
|
emit("toggle-join-channel");
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputChannel,
|
||||||
|
inputPassword,
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -129,134 +129,201 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 friendlysize from "../js/helpers/friendlysize";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
import type {ClientChan, ClientLinkPreview} from "../js/types";
|
||||||
|
import {imageViewerKey} from "./App.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "LinkPreview",
|
name: "LinkPreview",
|
||||||
props: {
|
props: {
|
||||||
link: Object,
|
link: {
|
||||||
keepScrollPosition: Function,
|
type: Object as PropType<ClientLinkPreview>,
|
||||||
},
|
required: true,
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showMoreButton: false,
|
|
||||||
isContentShown: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
moreButtonLabel() {
|
|
||||||
return this.isContentShown ? "Less" : "More";
|
|
||||||
},
|
},
|
||||||
imageMaxSize() {
|
keepScrollPosition: {
|
||||||
if (!this.link.maxSize) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return friendlysize(this.link.maxSize);
|
return friendlysize(props.link.maxSize);
|
||||||
},
|
});
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
"link.type"() {
|
|
||||||
this.updateShownState();
|
|
||||||
this.onPreviewUpdate();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.updateShownState();
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$root.$on("resize", this.handleResize);
|
|
||||||
|
|
||||||
this.onPreviewUpdate();
|
const handleResize = () => {
|
||||||
},
|
nextTick(() => {
|
||||||
beforeDestroy() {
|
if (!content.value || !container.value) {
|
||||||
this.$root.$off("resize", this.handleResize);
|
return;
|
||||||
},
|
}
|
||||||
destroyed() {
|
|
||||||
// Let this preview go through load/canplay events again,
|
showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
|
||||||
// Otherwise the browser can cause a resize on video elements
|
}).catch((e) => {
|
||||||
this.link.sourceLoaded = false;
|
// eslint-disable-next-line no-console
|
||||||
},
|
console.error("Error in LinkPreview.handleResize", e);
|
||||||
methods: {
|
});
|
||||||
onPreviewUpdate() {
|
};
|
||||||
|
|
||||||
|
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
|
// Don't display previews while they are loading on the server
|
||||||
if (this.link.type === "loading") {
|
if (props.link.type === "loading") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error does not have any media to render
|
// Error does not have any media to render
|
||||||
if (this.link.type === "error") {
|
if (props.link.type === "error") {
|
||||||
this.onPreviewReady();
|
onPreviewReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If link doesn't have a thumbnail, render it
|
// If link doesn't have a thumbnail, render it
|
||||||
if (this.link.type === "link") {
|
if (props.link.type === "link") {
|
||||||
this.handleResize();
|
handleResize();
|
||||||
this.keepScrollPosition();
|
props.keepScrollPosition();
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
onPreviewReady() {
|
|
||||||
this.$set(this.link, "sourceLoaded", true);
|
|
||||||
|
|
||||||
this.keepScrollPosition();
|
const onThumbnailError = () => {
|
||||||
|
|
||||||
if (this.link.type === "link") {
|
|
||||||
this.handleResize();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onThumbnailError() {
|
|
||||||
// If thumbnail fails to load, hide it and show the preview without it
|
// If thumbnail fails to load, hide it and show the preview without it
|
||||||
this.link.thumb = "";
|
props.link.thumb = "";
|
||||||
this.onPreviewReady();
|
onPreviewReady();
|
||||||
},
|
};
|
||||||
onThumbnailClick(e) {
|
|
||||||
|
const onThumbnailClick = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
|
if (!imageViewer?.value) {
|
||||||
imageViewer.link = this.link;
|
return;
|
||||||
},
|
}
|
||||||
onMoreClick() {
|
|
||||||
this.isContentShown = !this.isContentShown;
|
|
||||||
this.keepScrollPosition();
|
|
||||||
},
|
|
||||||
handleResize() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (!this.$refs.content) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showMoreButton =
|
imageViewer.value.channel = props.channel;
|
||||||
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
|
imageViewer.value.link = props.link;
|
||||||
});
|
};
|
||||||
},
|
|
||||||
updateShownState() {
|
const onMoreClick = () => {
|
||||||
|
isContentShown.value = !isContentShown.value;
|
||||||
|
props.keepScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShownState = () => {
|
||||||
// User has manually toggled the preview, do not apply default
|
// User has manually toggled the preview, do not apply default
|
||||||
if (this.link.shown !== null) {
|
if (props.link.shown !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultState = false;
|
let defaultState = false;
|
||||||
|
|
||||||
switch (this.link.type) {
|
switch (props.link.type) {
|
||||||
case "error":
|
case "error":
|
||||||
// Collapse all errors by default unless its a message about image being too big
|
// Collapse all errors by default unless its a message about image being too big
|
||||||
if (this.link.error === "image-too-big") {
|
if (props.link.error === "image-too-big") {
|
||||||
defaultState = this.$store.state.settings.media;
|
defaultState = store.state.settings.media;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
defaultState = this.$store.state.settings.links;
|
defaultState = store.state.settings.links;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
defaultState = this.$store.state.settings.media;
|
defaultState = store.state.settings.media;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.link.shown = defaultState;
|
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>
|
</script>
|
||||||
|
|
|
@ -2,18 +2,21 @@
|
||||||
<span class="preview-size">({{ previewSize }})</span>
|
<span class="preview-size">({{ previewSize }})</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent} from "vue";
|
||||||
import friendlysize from "../js/helpers/friendlysize";
|
import friendlysize from "../js/helpers/friendlysize";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "LinkPreviewFileSize",
|
name: "LinkPreviewFileSize",
|
||||||
props: {
|
props: {
|
||||||
size: Number,
|
size: {type: Number, required: true},
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
previewSize() {
|
const previewSize = friendlysize(props.size);
|
||||||
return friendlysize(this.size);
|
|
||||||
},
|
return {
|
||||||
|
previewSize,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,23 +7,31 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
|
import {ClientMessage, ClientLinkPreview} from "../js/types";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "LinkPreviewToggle",
|
name: "LinkPreviewToggle",
|
||||||
props: {
|
props: {
|
||||||
link: Object,
|
link: {type: Object as PropType<ClientLinkPreview>, required: true},
|
||||||
|
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||||
},
|
},
|
||||||
computed: {
|
emits: ["toggle-link-preview"],
|
||||||
ariaLabel() {
|
setup(props, {emit}) {
|
||||||
return this.link.shown ? "Collapse preview" : "Expand preview";
|
const ariaLabel = computed(() => {
|
||||||
},
|
return props.link.shown ? "Collapse preview" : "Expand preview";
|
||||||
},
|
});
|
||||||
methods: {
|
|
||||||
onClick() {
|
|
||||||
this.link.shown = !this.link.shown;
|
|
||||||
|
|
||||||
this.$parent.$emit("linkPreviewToggle", this.link, this.$parent.message);
|
const onClick = () => {
|
||||||
},
|
props.link.shown = !props.link.shown;
|
||||||
|
emit("toggle-link-preview", props.link, props.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ariaLabel,
|
||||||
|
onClick,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</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>
|
|
@ -3,58 +3,72 @@
|
||||||
:id="'msg-' + message.id"
|
:id="'msg-' + message.id"
|
||||||
:class="[
|
:class="[
|
||||||
'msg',
|
'msg',
|
||||||
{self: message.self, highlight: message.highlight, 'previous-source': isPreviousSource},
|
{
|
||||||
|
self: message.self,
|
||||||
|
highlight: message.highlight || focused,
|
||||||
|
'previous-source': isPreviousSource,
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
:data-type="message.type"
|
:data-type="message.type"
|
||||||
|
:data-command="message.command"
|
||||||
:data-from="message.from && message.from.nick"
|
:data-from="message.from && message.from.nick"
|
||||||
>
|
>
|
||||||
<span :aria-label="messageTimeLocale" class="time tooltipped tooltipped-e"
|
<span
|
||||||
>{{ messageTime }}
|
aria-hidden="true"
|
||||||
|
:aria-label="messageTimeLocale"
|
||||||
|
class="time tooltipped tooltipped-e"
|
||||||
|
>{{ `${messageTime} ` }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="message.type === 'unhandled'">
|
<template v-if="message.type === 'unhandled'">
|
||||||
<span class="from">[{{ message.command }}]</span>
|
<span class="from">[{{ message.command }}]</span>
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<span v-for="(param, id) in message.params" :key="id">{{ param }} </span>
|
<span v-for="(param, id) in message.params" :key="id">{{
|
||||||
|
` ${param} `
|
||||||
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isAction()">
|
<template v-else-if="isAction()">
|
||||||
<span class="from"><span class="only-copy">*** </span></span>
|
<span class="from"><span class="only-copy">*** </span></span>
|
||||||
<Component :is="messageComponent" :network="network" :message="message" />
|
<component :is="messageComponent" :network="network" :message="message" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="message.type === 'action'">
|
<template v-else-if="message.type === 'action'">
|
||||||
<span class="from"><span class="only-copy">* </span></span>
|
<span class="from"><span class="only-copy">* </span></span>
|
||||||
<span class="content" dir="auto">
|
<span class="content" dir="auto">
|
||||||
<Username :user="message.from" dir="auto" /> <ParsedMessage
|
<Username
|
||||||
:message="message"
|
:user="message.from"
|
||||||
/>
|
:network="network"
|
||||||
|
:channel="channel"
|
||||||
|
dir="auto"
|
||||||
|
/> <ParsedMessage :message="message" />
|
||||||
<LinkPreview
|
<LinkPreview
|
||||||
v-for="preview in message.previews"
|
v-for="preview in message.previews"
|
||||||
:key="preview.link"
|
:key="preview.link"
|
||||||
:keep-scroll-position="keepScrollPosition"
|
:keep-scroll-position="keepScrollPosition"
|
||||||
:link="preview"
|
:link="preview"
|
||||||
|
:channel="channel"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="message.type === 'message'" class="from">
|
<span v-if="message.type === 'message'" class="from">
|
||||||
<template v-if="message.from && message.from.nick">
|
<template v-if="message.from && message.from.nick">
|
||||||
<span class="only-copy"><</span>
|
<span class="only-copy" aria-hidden="true"><</span>
|
||||||
<Username :user="message.from" />
|
<Username :user="message.from" :network="network" :channel="channel" />
|
||||||
<span class="only-copy">> </span>
|
<span class="only-copy" aria-hidden="true">> </span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="message.type === 'plugin'" class="from">
|
<span v-else-if="message.type === 'plugin'" class="from">
|
||||||
<template v-if="message.from && message.from.nick">
|
<template v-if="message.from && message.from.nick">
|
||||||
<span class="only-copy">[</span>
|
<span class="only-copy" aria-hidden="true">[</span>
|
||||||
{{ message.from.nick }}
|
{{ message.from.nick }}
|
||||||
<span class="only-copy">] </span>
|
<span class="only-copy" aria-hidden="true">] </span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="from">
|
<span v-else class="from">
|
||||||
<template v-if="message.from && message.from.nick">
|
<template v-if="message.from && message.from.nick">
|
||||||
<span class="only-copy">-</span>
|
<span class="only-copy" aria-hidden="true">-</span>
|
||||||
<Username :user="message.from" />
|
<Username :user="message.from" :network="network" :channel="channel" />
|
||||||
<span class="only-copy">- </span>
|
<span class="only-copy" aria-hidden="true">- </span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<span class="content" dir="auto">
|
<span class="content" dir="auto">
|
||||||
|
@ -64,60 +78,96 @@
|
||||||
class="msg-shown-in-active tooltipped tooltipped-e"
|
class="msg-shown-in-active tooltipped tooltipped-e"
|
||||||
><span></span
|
><span></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" />
|
<ParsedMessage :network="network" :message="message" />
|
||||||
<LinkPreview
|
<LinkPreview
|
||||||
v-for="preview in message.previews"
|
v-for="preview in message.previews"
|
||||||
:key="preview.link"
|
:key="preview.link"
|
||||||
:keep-scroll-position="keepScrollPosition"
|
:keep-scroll-position="keepScrollPosition"
|
||||||
:link="preview"
|
:link="preview"
|
||||||
|
:channel="channel"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
const constants = require("../js/constants");
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
import localetime from "../js/helpers/localetime";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import constants from "../js/constants";
|
||||||
|
import localetime from "../js/helpers/localetime";
|
||||||
import Username from "./Username.vue";
|
import Username from "./Username.vue";
|
||||||
import LinkPreview from "./LinkPreview.vue";
|
import LinkPreview from "./LinkPreview.vue";
|
||||||
import ParsedMessage from "./ParsedMessage.vue";
|
import ParsedMessage from "./ParsedMessage.vue";
|
||||||
import MessageTypes from "./MessageTypes";
|
import MessageTypes from "./MessageTypes";
|
||||||
|
|
||||||
|
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
MessageTypes.ParsedMessage = ParsedMessage;
|
MessageTypes.ParsedMessage = ParsedMessage;
|
||||||
MessageTypes.LinkPreview = LinkPreview;
|
MessageTypes.LinkPreview = LinkPreview;
|
||||||
MessageTypes.Username = Username;
|
MessageTypes.Username = Username;
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "Message",
|
name: "Message",
|
||||||
components: MessageTypes,
|
components: MessageTypes,
|
||||||
props: {
|
props: {
|
||||||
message: Object,
|
message: {type: Object as PropType<ClientMessage>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: false},
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
keepScrollPosition: Function,
|
keepScrollPosition: Function as PropType<() => void>,
|
||||||
isPreviousSource: Boolean,
|
isPreviousSource: Boolean,
|
||||||
|
focused: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
messageTime() {
|
const store = useStore();
|
||||||
const format = this.$store.state.settings.showSeconds
|
|
||||||
? constants.timeFormats.msgWithSeconds
|
|
||||||
: constants.timeFormats.msgDefault;
|
|
||||||
|
|
||||||
return dayjs(this.message.time).format(format);
|
const timeFormat = computed(() => {
|
||||||
},
|
let format: keyof typeof constants.timeFormats;
|
||||||
messageTimeLocale() {
|
|
||||||
return localetime(this.message.time);
|
if (store.state.settings.use12hClock) {
|
||||||
},
|
format = store.state.settings.showSeconds ? "msg12hWithSeconds" : "msg12h";
|
||||||
messageComponent() {
|
} else {
|
||||||
return "message-" + this.message.type;
|
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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
});
|
||||||
isAction() {
|
|
||||||
return typeof MessageTypes["message-" + this.message.type] !== "undefined";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -17,47 +17,78 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
const constants = require("../js/constants");
|
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";
|
import Message from "./Message.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageCondensed",
|
name: "MessageCondensed",
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
messages: Array,
|
messages: {
|
||||||
keepScrollPosition: Function,
|
type: Array as PropType<ClientMessage[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
keepScrollPosition: {
|
||||||
|
type: Function as PropType<() => void>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
focused: Boolean,
|
||||||
},
|
},
|
||||||
data() {
|
setup(props) {
|
||||||
return {
|
const isCollapsed = ref(true);
|
||||||
isCollapsed: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
condensedText() {
|
|
||||||
const obj = {};
|
|
||||||
|
|
||||||
constants.condensedTypes.forEach((type) => {
|
const onCollapseClick = () => {
|
||||||
|
isCollapsed.value = !isCollapsed.value;
|
||||||
|
props.keepScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
const condensedText = computed(() => {
|
||||||
|
const obj: Record<string, number> = {};
|
||||||
|
|
||||||
|
condensedTypes.forEach((type) => {
|
||||||
obj[type] = 0;
|
obj[type] = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const message of this.messages) {
|
for (const message of props.messages) {
|
||||||
obj[message.type]++;
|
// 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
|
// Count quits as parts in condensed messages to reduce information density
|
||||||
obj.part += obj.quit;
|
obj.part += obj.quit;
|
||||||
|
|
||||||
const strings = [];
|
const strings: string[] = [];
|
||||||
constants.condensedTypes.forEach((type) => {
|
condensedTypes.forEach((type) => {
|
||||||
if (obj[type]) {
|
if (obj[type]) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "chghost":
|
case "chghost":
|
||||||
strings.push(
|
strings.push(
|
||||||
obj[type] +
|
String(obj[type]) +
|
||||||
(obj[type] > 1
|
(obj[type] > 1
|
||||||
? " users have changed hostname"
|
? " users have changed hostname"
|
||||||
: " user has changed hostname")
|
: " user has changed hostname")
|
||||||
|
@ -65,18 +96,19 @@ export default {
|
||||||
break;
|
break;
|
||||||
case "join":
|
case "join":
|
||||||
strings.push(
|
strings.push(
|
||||||
obj[type] +
|
String(obj[type]) +
|
||||||
(obj[type] > 1 ? " users have joined" : " user has joined")
|
(obj[type] > 1 ? " users have joined" : " user has joined")
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "part":
|
case "part":
|
||||||
strings.push(
|
strings.push(
|
||||||
obj[type] + (obj[type] > 1 ? " users have left" : " user has left")
|
String(obj[type]) +
|
||||||
|
(obj[type] > 1 ? " users have left" : " user has left")
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "nick":
|
case "nick":
|
||||||
strings.push(
|
strings.push(
|
||||||
obj[type] +
|
String(obj[type]) +
|
||||||
(obj[type] > 1
|
(obj[type] > 1
|
||||||
? " users have changed nick"
|
? " users have changed nick"
|
||||||
: " user has changed nick")
|
: " user has changed nick")
|
||||||
|
@ -84,33 +116,50 @@ export default {
|
||||||
break;
|
break;
|
||||||
case "kick":
|
case "kick":
|
||||||
strings.push(
|
strings.push(
|
||||||
obj[type] +
|
String(obj[type]) +
|
||||||
(obj[type] > 1 ? " users were kicked" : " user was kicked")
|
(obj[type] > 1 ? " users were kicked" : " user was kicked")
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "mode":
|
case "mode":
|
||||||
strings.push(
|
strings.push(
|
||||||
obj[type] + (obj[type] > 1 ? " modes were set" : " mode was set")
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let text = strings.pop();
|
|
||||||
|
|
||||||
if (strings.length) {
|
if (strings.length) {
|
||||||
text = strings.join(", ") + ", and " + text;
|
let text = strings.pop();
|
||||||
|
|
||||||
|
if (strings.length) {
|
||||||
|
text = strings.join(", ") + ", and " + text!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return "";
|
||||||
},
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCollapsed,
|
||||||
|
condensedText,
|
||||||
|
onCollapseClick,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
});
|
||||||
onCollapseClick() {
|
|
||||||
this.isCollapsed = !this.isCollapsed;
|
|
||||||
this.keepScrollPosition();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<div v-show="channel.moreHistoryAvailable" class="show-more">
|
<div v-show="channel.moreHistoryAvailable" class="show-more">
|
||||||
<button
|
<button
|
||||||
ref="loadMoreButton"
|
ref="loadMoreButton"
|
||||||
:disabled="channel.historyLoading || !$store.state.isConnected"
|
:disabled="channel.historyLoading || !store.state.isConnected"
|
||||||
class="btn"
|
class="btn"
|
||||||
@click="onShowMoreClick"
|
@click="onShowMoreClick"
|
||||||
>
|
>
|
||||||
|
@ -22,10 +22,11 @@
|
||||||
<DateMarker
|
<DateMarker
|
||||||
v-if="shouldDisplayDateMarker(message, id)"
|
v-if="shouldDisplayDateMarker(message, id)"
|
||||||
:key="message.id + '-date'"
|
:key="message.id + '-date'"
|
||||||
:message="message"
|
:message="message as any"
|
||||||
|
:focused="message.id === focused"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="shouldDisplayUnreadMarker(message.id)"
|
v-if="shouldDisplayUnreadMarker(Number(message.id))"
|
||||||
:key="message.id + '-unread'"
|
:key="message.id + '-unread'"
|
||||||
class="unread-marker"
|
class="unread-marker"
|
||||||
>
|
>
|
||||||
|
@ -38,6 +39,7 @@
|
||||||
:network="network"
|
:network="network"
|
||||||
:keep-scroll-position="keepScrollPosition"
|
:keep-scroll-position="keepScrollPosition"
|
||||||
:messages="message.messages"
|
:messages="message.messages"
|
||||||
|
:focused="message.id === focused"
|
||||||
/>
|
/>
|
||||||
<Message
|
<Message
|
||||||
v-else
|
v-else
|
||||||
|
@ -47,24 +49,50 @@
|
||||||
:message="message"
|
:message="message"
|
||||||
:keep-scroll-position="keepScrollPosition"
|
:keep-scroll-position="keepScrollPosition"
|
||||||
:is-previous-source="isPreviousSource(message, id)"
|
:is-previous-source="isPreviousSource(message, id)"
|
||||||
@linkPreviewToggle="onLinkPreviewToggle"
|
:focused="message.id === focused"
|
||||||
|
@toggle-link-preview="onLinkPreviewToggle"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
require("intersection-observer");
|
import {condensedTypes} from "../../shared/irc";
|
||||||
|
import {ChanType} from "../../shared/types/chan";
|
||||||
const constants = require("../js/constants");
|
import {MessageType, SharedMsg} from "../../shared/types/msg";
|
||||||
|
import eventbus from "../js/eventbus";
|
||||||
import clipboard from "../js/clipboard";
|
import clipboard from "../js/clipboard";
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
import Message from "./Message.vue";
|
import Message from "./Message.vue";
|
||||||
import MessageCondensed from "./MessageCondensed.vue";
|
import MessageCondensed from "./MessageCondensed.vue";
|
||||||
import DateMarker from "./DateMarker.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";
|
||||||
|
|
||||||
export default {
|
type CondensedMessageContainer = {
|
||||||
|
type: "condensed";
|
||||||
|
time: Date;
|
||||||
|
messages: ClientMessage[];
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO; move into component
|
||||||
|
let unreadMarkerShown = false;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "MessageList",
|
name: "MessageList",
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
|
@ -72,38 +100,108 @@ export default {
|
||||||
DateMarker,
|
DateMarker,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
|
focused: Number,
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
condensedMessages() {
|
const store = useStore();
|
||||||
if (this.channel.type !== "channel") {
|
|
||||||
return this.channel.messages;
|
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 actions are hidden, just return a message list with them excluded
|
||||||
if (this.$store.state.settings.statusMessages === "hidden") {
|
if (store.state.settings.statusMessages === "hidden") {
|
||||||
return this.channel.messages.filter(
|
return props.channel.messages.filter(
|
||||||
(message) => !constants.condensedTypes.has(message.type)
|
(message) => !condensedTypes.has(message.type || "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If actions are not condensed, just return raw message list
|
// If actions are not condensed, just return raw message list
|
||||||
if (this.$store.state.settings.statusMessages !== "condensed") {
|
if (store.state.settings.statusMessages !== "condensed") {
|
||||||
return this.channel.messages;
|
return props.channel.messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const condensed = [];
|
let lastCondensedContainer: CondensedMessageContainer | null = null;
|
||||||
let lastCondensedContainer = null;
|
|
||||||
|
|
||||||
for (const message of this.channel.messages) {
|
const condensed: (ClientMessage | CondensedMessageContainer)[] = [];
|
||||||
|
|
||||||
|
for (const message of props.channel.messages) {
|
||||||
// If this message is not condensable, or its an action affecting our user,
|
// 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
|
// then just append the message to container and be done with it
|
||||||
if (
|
if (message.self || message.highlight || !condensedTypes.has(message.type || "")) {
|
||||||
message.self ||
|
|
||||||
message.highlight ||
|
|
||||||
!constants.condensedTypes.has(message.type)
|
|
||||||
) {
|
|
||||||
lastCondensedContainer = null;
|
lastCondensedContainer = null;
|
||||||
|
|
||||||
condensed.push(message);
|
condensed.push(message);
|
||||||
|
@ -111,7 +209,7 @@ export default {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastCondensedContainer === null) {
|
if (!lastCondensedContainer) {
|
||||||
lastCondensedContainer = {
|
lastCondensedContainer = {
|
||||||
time: message.time,
|
time: message.time,
|
||||||
type: "condensed",
|
type: "condensed",
|
||||||
|
@ -121,218 +219,222 @@ export default {
|
||||||
condensed.push(lastCondensedContainer);
|
condensed.push(lastCondensedContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastCondensedContainer.messages.push(message);
|
lastCondensedContainer!.messages.push(message);
|
||||||
|
|
||||||
// Set id of the condensed container to last message id,
|
// Set id of the condensed container to last message id,
|
||||||
// which is required for the unread marker to work correctly
|
// which is required for the unread marker to work correctly
|
||||||
lastCondensedContainer.id = message.id;
|
lastCondensedContainer!.id = message.id;
|
||||||
|
|
||||||
// If this message is the unread boundary, create a split condensed container
|
// If this message is the unread boundary, create a split condensed container
|
||||||
if (message.id === this.channel.firstUnread) {
|
if (message.id === props.channel.firstUnread) {
|
||||||
lastCondensedContainer = null;
|
lastCondensedContainer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return condensed;
|
return condensed.map((message) => {
|
||||||
},
|
// Skip condensing single messages, it doesn't save any
|
||||||
},
|
// space but makes useful information harder to see
|
||||||
watch: {
|
if (message.type === "condensed" && message.messages.length === 1) {
|
||||||
"channel.id"() {
|
return message.messages[0];
|
||||||
this.channel.scrolledToBottom = true;
|
}
|
||||||
|
|
||||||
// Re-add the intersection observer to trigger the check again on channel switch
|
return message;
|
||||||
// Otherwise if last channel had the button visible, switching to a new channel won't trigger the history
|
|
||||||
if (this.historyObserver) {
|
|
||||||
this.historyObserver.unobserve(this.$refs.loadMoreButton);
|
|
||||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"channel.messages"() {
|
|
||||||
this.keepScrollPosition();
|
|
||||||
},
|
|
||||||
"channel.pendingMessage"() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
// Keep the scroll stuck when input gets resized while typing
|
|
||||||
this.keepScrollPosition();
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (!this.$refs.chat) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.IntersectionObserver) {
|
|
||||||
this.historyObserver = new window.IntersectionObserver(this.onLoadButtonObserved, {
|
|
||||||
root: this.$refs.chat,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.jumpToBottom();
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$refs.chat.addEventListener("scroll", this.handleScroll, {passive: true});
|
|
||||||
|
|
||||||
this.$root.$on("resize", this.handleResize);
|
const shouldDisplayDateMarker = (
|
||||||
|
message: SharedMsg | CondensedMessageContainer,
|
||||||
this.$nextTick(() => {
|
id: number
|
||||||
if (this.historyObserver) {
|
) => {
|
||||||
this.historyObserver.observe(this.$refs.loadMoreButton);
|
const previousMessage = condensedMessages.value[id - 1];
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
beforeUpdate() {
|
|
||||||
this.unreadMarkerShown = false;
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
this.$root.$off("resize", this.handleResize);
|
|
||||||
this.$refs.chat.removeEventListener("scroll", this.handleScroll);
|
|
||||||
},
|
|
||||||
destroyed() {
|
|
||||||
if (this.historyObserver) {
|
|
||||||
this.historyObserver.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
shouldDisplayDateMarker(message, id) {
|
|
||||||
const previousMessage = this.condensedMessages[id - 1];
|
|
||||||
|
|
||||||
if (!previousMessage) {
|
if (!previousMessage) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Date(previousMessage.time).getDay() !== new Date(message.time).getDay();
|
const oldDate = new Date(previousMessage.time);
|
||||||
},
|
const newDate = new Date(message.time);
|
||||||
shouldDisplayUnreadMarker(id) {
|
|
||||||
if (!this.unreadMarkerShown && id > this.channel.firstUnread) {
|
return (
|
||||||
this.unreadMarkerShown = true;
|
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 true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
};
|
||||||
isPreviousSource(currentMessage, id) {
|
|
||||||
const previousMessage = this.condensedMessages[id - 1];
|
const isPreviousSource = (currentMessage: ClientMessage, id: number) => {
|
||||||
|
const previousMessage = condensedMessages.value[id - 1];
|
||||||
return (
|
return (
|
||||||
previousMessage &&
|
previousMessage &&
|
||||||
currentMessage.type === "message" &&
|
currentMessage.type === MessageType.MESSAGE &&
|
||||||
previousMessage.type === "message" &&
|
previousMessage.type === MessageType.MESSAGE &&
|
||||||
|
currentMessage.from &&
|
||||||
previousMessage.from &&
|
previousMessage.from &&
|
||||||
currentMessage.from.nick === previousMessage.from.nick
|
currentMessage.from.nick === previousMessage.from.nick
|
||||||
);
|
);
|
||||||
},
|
};
|
||||||
onCopy() {
|
|
||||||
clipboard(this.$el);
|
const onCopy = () => {
|
||||||
},
|
if (chat.value) {
|
||||||
onLinkPreviewToggle(preview, message) {
|
clipboard(chat.value);
|
||||||
this.keepScrollPosition();
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
// Tell the server we're toggling so it remembers at page reload
|
||||||
socket.emit("msg:preview:toggle", {
|
socket.emit("msg:preview:toggle", {
|
||||||
target: this.channel.id,
|
target: props.channel.id,
|
||||||
msgId: message.id,
|
msgId: message.id,
|
||||||
link: preview.link,
|
link: preview.link,
|
||||||
shown: preview.shown,
|
shown: preview.shown,
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
onShowMoreClick() {
|
|
||||||
if (!this.$store.state.isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastMessage = -1;
|
const handleScroll = () => {
|
||||||
|
|
||||||
// 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 this.channel.messages) {
|
|
||||||
if (!message.showInActive) {
|
|
||||||
lastMessage = message.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.channel.historyLoading = true;
|
|
||||||
|
|
||||||
socket.emit("more", {
|
|
||||||
target: this.channel.id,
|
|
||||||
lastId: lastMessage,
|
|
||||||
condensed: this.$store.state.settings.statusMessages !== "shown",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onLoadButtonObserved(entries) {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onShowMoreClick();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
keepScrollPosition() {
|
|
||||||
// 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 (this.isWaitingForNextTick) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = this.$refs.chat;
|
|
||||||
|
|
||||||
if (!el) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.channel.scrolledToBottom) {
|
|
||||||
if (this.channel.historyLoading) {
|
|
||||||
const heightOld = el.scrollHeight - el.scrollTop;
|
|
||||||
|
|
||||||
this.isWaitingForNextTick = true;
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.isWaitingForNextTick = false;
|
|
||||||
this.skipNextScrollEvent = true;
|
|
||||||
el.scrollTop = el.scrollHeight - heightOld;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isWaitingForNextTick = true;
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.isWaitingForNextTick = false;
|
|
||||||
this.jumpToBottom();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handleScroll() {
|
|
||||||
// Setting scrollTop also triggers scroll event
|
// Setting scrollTop also triggers scroll event
|
||||||
// We don't want to perform calculations for that
|
// We don't want to perform calculations for that
|
||||||
if (this.skipNextScrollEvent) {
|
if (skipNextScrollEvent.value) {
|
||||||
this.skipNextScrollEvent = false;
|
skipNextScrollEvent.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = this.$refs.chat;
|
const el = chat.value;
|
||||||
|
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
props.channel.scrolledToBottom = el.scrollHeight - el.scrollTop - el.offsetHeight <= 30;
|
||||||
},
|
};
|
||||||
handleResize() {
|
|
||||||
// Keep message list scrolled to bottom on resize
|
|
||||||
if (this.channel.scrolledToBottom) {
|
|
||||||
this.jumpToBottom();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
jumpToBottom() {
|
|
||||||
this.skipNextScrollEvent = true;
|
|
||||||
this.channel.scrolledToBottom = true;
|
|
||||||
|
|
||||||
const el = this.$refs.chat;
|
const handleResize = () => {
|
||||||
el.scrollTop = el.scrollHeight;
|
// 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>
|
</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>
|
|
@ -9,19 +9,27 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import type {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeAway",
|
name: "MessageTypeAway",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -8,19 +8,27 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeBack",
|
name: "MessageTypeBack",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,22 +6,33 @@
|
||||||
>username to <b>{{ message.new_ident }}</b></span
|
>username to <b>{{ message.new_ident }}</b></span
|
||||||
>
|
>
|
||||||
<span v-if="message.new_host"
|
<span v-if="message.new_host"
|
||||||
>hostname to <i class="hostmask">{{ message.new_host }}</i></span
|
>hostname to
|
||||||
>
|
<i class="hostmask"><ParsedMessage :network="network" :text="message.new_host" /></i
|
||||||
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeChangeHost",
|
name: "MessageTypeChangeHost",
|
||||||
components: {
|
components: {
|
||||||
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,23 +1,31 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<Username :user="message.from" /> 
|
<Username :user="message.from" />
|
||||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage"/></span>
|
{{ ` ` }}<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeCTCP",
|
name: "MessageTypeCTCP",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,23 +2,31 @@
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<Username :user="message.from" />
|
<Username :user="message.from" />
|
||||||
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
|
sent a <abbr title="Client-to-client protocol">CTCP</abbr> request:
|
||||||
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage"/></span>
|
<span class="ctcp-message"><ParsedMessage :text="message.ctcpMessage" /></span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeRequestCTCP",
|
name: "MessageTypeRequestCTCP",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,55 +4,74 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeError",
|
name: "MessageTypeError",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
errorMessage() {
|
const errorMessage = computed(() => {
|
||||||
switch (this.message.error) {
|
// 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":
|
case "bad_channel_key":
|
||||||
return `Cannot join ${this.message.channel} - Bad channel key.`;
|
return `Cannot join ${chan} - Bad channel key.`;
|
||||||
case "banned_from_channel":
|
case "banned_from_channel":
|
||||||
return `Cannot join ${this.message.channel} - You have been banned from the channel.`;
|
return `Cannot join ${chan} - You have been banned from the channel.`;
|
||||||
case "cannot_send_to_channel":
|
case "cannot_send_to_channel":
|
||||||
return `Cannot send to channel ${this.message.channel}`;
|
return `Cannot send to channel ${chan}`;
|
||||||
case "channel_is_full":
|
case "channel_is_full":
|
||||||
return `Cannot join ${this.message.channel} - Channel is full.`;
|
return `Cannot join ${chan} - Channel is full.`;
|
||||||
case "chanop_privs_needed":
|
case "chanop_privs_needed":
|
||||||
return "Cannot perform action: You're not a channel operator.";
|
return "Cannot perform action: You're not a channel operator.";
|
||||||
case "invite_only_channel":
|
case "invite_only_channel":
|
||||||
return `Cannot join ${this.message.channel} - Channel is invite only.`;
|
return `Cannot join ${chan} - Channel is invite only.`;
|
||||||
case "no_such_nick":
|
case "no_such_nick":
|
||||||
return `User ${this.message.nick} hasn't logged in or does not exist.`;
|
return `User ${nick} hasn't logged in or does not exist.`;
|
||||||
case "not_on_channel":
|
case "not_on_channel":
|
||||||
return "Cannot perform action: You're not on the channel.";
|
return "Cannot perform action: You're not on the channel.";
|
||||||
case "password_mismatch":
|
case "password_mismatch":
|
||||||
return "Password mismatch.";
|
return "Password mismatch.";
|
||||||
case "too_many_channels":
|
case "too_many_channels":
|
||||||
return `Cannot join ${this.message.channel} - You've already reached the maximum number of channels allowed.`;
|
return `Cannot join ${chan} - You've already reached the maximum number of channels allowed.`;
|
||||||
case "unknown_command":
|
case "unknown_command":
|
||||||
return `Unknown command: ${this.message.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":
|
case "user_not_in_channel":
|
||||||
return `User ${this.message.nick} is not on the channel.`;
|
return `User ${nick} is not on the channel.`;
|
||||||
case "user_on_channel":
|
case "user_on_channel":
|
||||||
return `User ${this.message.nick} is already on the channel.`;
|
return `User ${nick} is already on the channel.`;
|
||||||
default:
|
default:
|
||||||
if (this.message.reason) {
|
if (props.message.reason) {
|
||||||
return `${this.message.reason} (${this.message.error})`;
|
return `${props.message.reason} (${
|
||||||
|
props.message.error || "!UNDEFINED_ERR"
|
||||||
|
})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.message.error;
|
return props.message.error;
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorMessage,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// This creates a version of `require()` in the context of the current
|
// 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
|
// directory, so we iterate over its content, which is a map statically built by
|
||||||
// Webpack.
|
// Webpack.
|
||||||
// Second argument says it's recursive, third makes sure we only load templates.
|
// Second argument says it's recursive, third makes sure we only load templates.
|
||||||
const requireViews = require.context(".", false, /\.vue$/);
|
const requireViews = require.context(".", false, /\.vue$/);
|
||||||
|
|
||||||
export default requireViews.keys().reduce((acc, path) => {
|
export default requireViews.keys().reduce((acc: Record<string, any>, path) => {
|
||||||
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
|
acc["message-" + path.substring(2, path.length - 4)] = requireViews(path).default;
|
||||||
|
|
||||||
return acc;
|
return acc;
|
|
@ -8,19 +8,27 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeInvite",
|
name: "MessageTypeInvite",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,22 +1,38 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<Username :user="message.from" />
|
<Username :user="message.from" />
|
||||||
<i class="hostmask"> ({{ message.hostmask }})</i>
|
<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
|
has joined the channel
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeJoin",
|
name: "MessageTypeJoin",
|
||||||
components: {
|
components: {
|
||||||
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -9,19 +9,27 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeKick",
|
name: "MessageTypeKick",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,19 +6,27 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeMode",
|
name: "MessageTypeMode",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,12 +4,21 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "MessageChannelMode",
|
name: "MessageChannelMode",
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</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>
|
|
@ -1,29 +1,37 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<span class="text"><ParsedMessage :network="network" :text="cleanText"/></span>
|
<span class="text"><ParsedMessage :network="network" :text="cleanText" /></span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeMOTD",
|
name: "MessageTypeMonospaceBlock",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
setup(props) {
|
||||||
cleanText() {
|
const cleanText = computed(() => {
|
||||||
let lines = this.message.text.split("\n");
|
let lines = props.message.text.split("\n");
|
||||||
|
|
||||||
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
// If all non-empty lines of the MOTD start with a hyphen (which is common
|
||||||
// across MOTDs), remove all the leading hyphens.
|
// across MOTDs), remove all the leading hyphens.
|
||||||
if (lines.every((line) => line === "" || line[0] === "-")) {
|
if (lines.every((line) => line === "" || line[0] === "-")) {
|
||||||
lines = lines.map((line) => line.substr(2));
|
lines = lines.map((line) => line.substring(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove empty lines around the MOTD (but not within it)
|
// Remove empty lines around the MOTD (but not within it)
|
||||||
|
@ -31,7 +39,11 @@ export default {
|
||||||
.map((line) => line.replace(/\s*$/, ""))
|
.map((line) => line.replace(/\s*$/, ""))
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||||
},
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanText,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
|
@ -6,17 +6,25 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeNick",
|
name: "MessageTypeNick",
|
||||||
components: {
|
components: {
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,26 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<Username :user="message.from" />
|
<Username :user="message.from" />
|
||||||
<i class="hostmask"> ({{ message.hostmask }})</i> has left the channel
|
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
|
||||||
|
left the channel
|
||||||
<i v-if="message.text" class="part-reason"
|
<i v-if="message.text" class="part-reason"
|
||||||
>(<ParsedMessage :network="network" :message="message" />)</i
|
>(<ParsedMessage :network="network" :message="message" />)</i
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypePart",
|
name: "MessageTypePart",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,26 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="content">
|
<span class="content">
|
||||||
<Username :user="message.from" />
|
<Username :user="message.from" />
|
||||||
<i class="hostmask"> ({{ message.hostmask }})</i> has quit
|
<i class="hostmask"> (<ParsedMessage :network="network" :text="message.hostmask" />)</i> has
|
||||||
|
quit
|
||||||
<i v-if="message.text" class="quit-reason"
|
<i v-if="message.text" class="quit-reason"
|
||||||
>(<ParsedMessage :network="network" :message="message" />)</i
|
>(<ParsedMessage :network="network" :message="message" />)</i
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import type {ClientMessage, ClientNetwork} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeQuit",
|
name: "MessageTypeQuit",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,12 +2,21 @@
|
||||||
<span class="content">{{ message.text }}</span>
|
<span class="content">{{ message.text }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "MessageTypeRaw",
|
name: "MessageTypeRaw",
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -10,19 +10,27 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import type {ClientMessage, ClientNetwork} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeTopic",
|
name: "MessageTypeTopic",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,23 +6,33 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import localetime from "../../js/helpers/localetime";
|
import localetime from "../../js/helpers/localetime";
|
||||||
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeTopicSetBy",
|
name: "MessageTypeTopicSetBy",
|
||||||
components: {
|
components: {
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
},
|
required: true,
|
||||||
computed: {
|
},
|
||||||
messageTimeLocale() {
|
message: {
|
||||||
return localetime(this.message.when);
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
setup(props) {
|
||||||
|
const messageTimeLocale = computed(() => localetime(props.message.when));
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageTimeLocale,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,7 +12,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<dt>Host mask:</dt>
|
<dt>Host mask:</dt>
|
||||||
<dd class="hostmask">{{ message.whois.ident }}@{{ message.whois.hostname }}</dd>
|
<dd class="hostmask">
|
||||||
|
<ParsedMessage
|
||||||
|
:network="network"
|
||||||
|
:text="message.whois.ident + '@' + message.whois.hostname"
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
|
||||||
<template v-if="message.whois.actual_hostname">
|
<template v-if="message.whois.actual_hostname">
|
||||||
<dt>Actual host:</dt>
|
<dt>Actual host:</dt>
|
||||||
|
@ -50,9 +55,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="message.whois.special">
|
<template v-if="message.whois.special">
|
||||||
<template v-for="special in message.whois.special">
|
<template v-for="special in message.whois.special" :key="special">
|
||||||
<dt :key="special">Special:</dt>
|
<dt>Special:</dt>
|
||||||
<dd :key="special">{{ special }}</dd>
|
<dd>{{ special }}</dd>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -81,6 +86,11 @@
|
||||||
<dd>Yes</dd>
|
<dd>Yes</dd>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="message.whois.certfp">
|
||||||
|
<dt>Certificate:</dt>
|
||||||
|
<dd>{{ message.whois.certfp }}</dd>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="message.whois.server">
|
<template v-if="message.whois.server">
|
||||||
<dt>Connected to:</dt>
|
<dt>Connected to:</dt>
|
||||||
<dd>
|
<dd>
|
||||||
|
@ -101,25 +111,33 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
import localetime from "../../js/helpers/localetime";
|
import localetime from "../../js/helpers/localetime";
|
||||||
|
import {ClientNetwork, ClientMessage} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import Username from "../Username.vue";
|
import Username from "../Username.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "MessageTypeWhois",
|
name: "MessageTypeWhois",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
Username,
|
Username,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
message: Object,
|
type: Object as PropType<ClientNetwork>,
|
||||||
},
|
required: true,
|
||||||
methods: {
|
},
|
||||||
localetime(date) {
|
message: {
|
||||||
return localetime(date);
|
type: Object as PropType<ClientMessage>,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
setup() {
|
||||||
|
return {
|
||||||
|
localetime: (date: Date) => localetime(date),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,26 +6,27 @@
|
||||||
<form class="container" method="post" action="" @submit.prevent="onSubmit">
|
<form class="container" method="post" action="" @submit.prevent="onSubmit">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
<template v-if="defaults.uuid">
|
<template v-if="defaults.uuid">
|
||||||
<input type="hidden" name="uuid" :value="defaults.uuid" />
|
<input v-model="defaults.uuid" type="hidden" name="uuid" />
|
||||||
Edit {{ defaults.name }}
|
Edit {{ defaults.name }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="config.public">The Lounge - </template>
|
|
||||||
Connect
|
Connect
|
||||||
<template v-if="!config.displayNetwork">
|
<template
|
||||||
<template v-if="config.lockNetwork"> to {{ defaults.name }} </template>
|
v-if="config?.lockNetwork && store?.state.serverConfiguration?.public"
|
||||||
|
>
|
||||||
|
to {{ defaults.name }}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</h1>
|
</h1>
|
||||||
<template v-if="config.displayNetwork">
|
<template v-if="!config?.lockNetwork">
|
||||||
<h2>Network settings</h2>
|
<h2>Network settings</h2>
|
||||||
<div class="connect-row">
|
<div class="connect-row">
|
||||||
<label for="connect:name">Name</label>
|
<label for="connect:name">Name</label>
|
||||||
<input
|
<input
|
||||||
id="connect:name"
|
id="connect:name"
|
||||||
|
v-model.trim="defaults.name"
|
||||||
class="input"
|
class="input"
|
||||||
name="name"
|
name="name"
|
||||||
:value="defaults.name"
|
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,41 +35,52 @@
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<input
|
<input
|
||||||
id="connect:host"
|
id="connect:host"
|
||||||
|
v-model.trim="defaults.host"
|
||||||
class="input"
|
class="input"
|
||||||
name="host"
|
name="host"
|
||||||
:value="defaults.host"
|
|
||||||
aria-label="Server address"
|
aria-label="Server address"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
required
|
required
|
||||||
:disabled="config.lockNetwork ? true : false"
|
|
||||||
/>
|
/>
|
||||||
<span id="connect:portseparator">:</span>
|
<span id="connect:portseparator">:</span>
|
||||||
<input
|
<input
|
||||||
id="connect:port"
|
id="connect:port"
|
||||||
ref="serverPort"
|
v-model="defaults.port"
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
name="port"
|
name="port"
|
||||||
:value="defaults.port"
|
|
||||||
aria-label="Server port"
|
aria-label="Server port"
|
||||||
:disabled="config.lockNetwork ? true : false"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="connect-row">
|
||||||
<label></label>
|
<label></label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<label class="tls">
|
<label class="tls">
|
||||||
<input
|
<input
|
||||||
|
v-model="defaults.tls"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="tls"
|
name="tls"
|
||||||
:checked="defaults.tls ? true : false"
|
:disabled="defaults.hasSTSPolicy"
|
||||||
:disabled="
|
|
||||||
config.lockNetwork || defaults.hasSTSPolicy ? true : false
|
|
||||||
"
|
|
||||||
@change="onSecureChanged"
|
|
||||||
/>
|
/>
|
||||||
Use secure connection (TLS)
|
Use secure connection (TLS)
|
||||||
<span
|
<span
|
||||||
|
@ -80,15 +92,118 @@
|
||||||
</label>
|
</label>
|
||||||
<label class="tls">
|
<label class="tls">
|
||||||
<input
|
<input
|
||||||
|
v-model="defaults.rejectUnauthorized"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="rejectUnauthorized"
|
name="rejectUnauthorized"
|
||||||
:checked="defaults.rejectUnauthorized ? true : false"
|
|
||||||
:disabled="config.lockNetwork ? true : false"
|
|
||||||
/>
|
/>
|
||||||
Only allow trusted certificates
|
Only allow trusted certificates
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<h2>User preferences</h2>
|
<h2>User preferences</h2>
|
||||||
|
@ -96,140 +211,362 @@
|
||||||
<label for="connect:nick">Nick</label>
|
<label for="connect:nick">Nick</label>
|
||||||
<input
|
<input
|
||||||
id="connect:nick"
|
id="connect:nick"
|
||||||
|
v-model="defaults.nick"
|
||||||
class="input nick"
|
class="input nick"
|
||||||
name="nick"
|
name="nick"
|
||||||
pattern="[^\s:!@]+"
|
pattern="[^\s:!@]+"
|
||||||
:value="defaults.nick"
|
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
required
|
required
|
||||||
@input="onNickChanged"
|
@input="onNickChanged"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="!config.useHexIp">
|
<template v-if="!config?.useHexIp">
|
||||||
<div class="connect-row">
|
<div class="connect-row">
|
||||||
<label for="connect:username">Username</label>
|
<label for="connect:username">Username</label>
|
||||||
<input
|
<input
|
||||||
id="connect:username"
|
id="connect:username"
|
||||||
ref="usernameInput"
|
ref="usernameInput"
|
||||||
|
v-model.trim="defaults.username"
|
||||||
class="input username"
|
class="input username"
|
||||||
name="username"
|
name="username"
|
||||||
:value="defaults.username"
|
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<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'"
|
|
||||||
name="password"
|
|
||||||
maxlength="300"
|
|
||||||
/>
|
|
||||||
</RevealPassword>
|
|
||||||
</div>
|
|
||||||
<div class="connect-row">
|
<div class="connect-row">
|
||||||
<label for="connect:realname">Real name</label>
|
<label for="connect:realname">Real name</label>
|
||||||
<input
|
<input
|
||||||
id="connect:realname"
|
id="connect:realname"
|
||||||
|
v-model.trim="defaults.realname"
|
||||||
class="input"
|
class="input"
|
||||||
name="realname"
|
name="realname"
|
||||||
:value="defaults.realname"
|
|
||||||
maxlength="300"
|
maxlength="300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="defaults.uuid">
|
<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">
|
<div class="connect-row">
|
||||||
<label for="connect:commands">Commands</label>
|
<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
|
<textarea
|
||||||
id="connect:commands"
|
id="connect:commands"
|
||||||
|
ref="commandsInput"
|
||||||
|
autocomplete="off"
|
||||||
|
:value="defaults.commands ? defaults.commands.join('\n') : ''"
|
||||||
class="input"
|
class="input"
|
||||||
name="commands"
|
name="commands"
|
||||||
placeholder="One /command per line, each command will be executed in the server tab on new connection"
|
@input="resizeCommandsInput"
|
||||||
:value="defaults.commands ? defaults.commands.join('\n') : ''"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<button type="submit" class="btn" :disabled="disabled ? true : false">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="!defaults.uuid">
|
||||||
<div class="connect-row">
|
<div class="connect-row">
|
||||||
<label for="connect:channels">Channels</label>
|
<label for="connect:channels">Channels</label>
|
||||||
<input id="connect:channels" class="input" name="join" :value="defaults.join" />
|
<input
|
||||||
</div>
|
id="connect:channels"
|
||||||
<div>
|
v-model.trim="defaults.join"
|
||||||
<button type="submit" class="btn" :disabled="disabled ? true : false">
|
class="input"
|
||||||
Connect
|
name="join"
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 RevealPassword from "./RevealPassword.vue";
|
||||||
import SidebarToggle from "./SidebarToggle.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 default {
|
export type NetworkFormDefaults = Partial<ClientNetwork> & {
|
||||||
|
join?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "NetworkForm",
|
name: "NetworkForm",
|
||||||
components: {
|
components: {
|
||||||
RevealPassword,
|
RevealPassword,
|
||||||
SidebarToggle,
|
SidebarToggle,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
handleSubmit: Function,
|
handleSubmit: {
|
||||||
defaults: Object,
|
type: Function as PropType<(network: ClientNetwork) => void>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
type: Object as PropType<NetworkFormDefaults>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
},
|
},
|
||||||
data() {
|
setup(props) {
|
||||||
return {
|
const store = useStore();
|
||||||
config: this.$store.state.serverConfiguration,
|
const config = ref(store.state.serverConfiguration);
|
||||||
previousUsername: this.defaults.username,
|
const previousUsername = ref(props.defaults?.username);
|
||||||
};
|
const displayPasswordField = ref(false);
|
||||||
},
|
|
||||||
methods: {
|
const publicPassword = ref<HTMLInputElement | null>(null);
|
||||||
onNickChanged(event) {
|
|
||||||
// Username input is not available when useHexIp is set
|
watch(displayPasswordField, (newValue) => {
|
||||||
if (!this.$refs.usernameInput) {
|
if (newValue) {
|
||||||
|
void nextTick(() => {
|
||||||
|
publicPassword.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandsInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const resizeCommandsInput = () => {
|
||||||
|
if (!commandsInput.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Reset height first so it can down size
|
||||||
!this.$refs.usernameInput.value ||
|
commandsInput.value.style.height = "";
|
||||||
this.$refs.usernameInput.value === this.previousUsername
|
|
||||||
) {
|
// 2 pixels to account for the border
|
||||||
this.$refs.usernameInput.value = event.target.value;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousUsername = event.target.value;
|
const usernameRef = usernameInput.value;
|
||||||
},
|
|
||||||
onSecureChanged(event) {
|
|
||||||
const ports = ["6667", "6697"];
|
|
||||||
const newPort = event.target.checked ? 0 : 1;
|
|
||||||
|
|
||||||
// If you disable TLS and current port is 6697,
|
if (!usernameRef.value || usernameRef.value === previousUsername.value) {
|
||||||
// set it to 6667, and vice versa
|
usernameRef.value = (event.target as HTMLInputElement)?.value;
|
||||||
if (this.$refs.serverPort.value === ports[newPort]) {
|
|
||||||
this.$refs.serverPort.value = ports[1 - newPort];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSubmit(event) {
|
|
||||||
const formData = new FormData(event.target);
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
for (const item of formData.entries()) {
|
|
||||||
data[item[0]] = item[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleSubmit(data);
|
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>
|
</script>
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="$store.state.networks.length === 0" class="empty">
|
<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.
|
You are not connected to any networks yet.
|
||||||
</div>
|
</div>
|
||||||
<div v-else ref="networklist">
|
<div v-else ref="networklist" role="navigation" aria-label="Network and Channel list">
|
||||||
<div class="jump-to-input">
|
<div class="jump-to-input">
|
||||||
<input
|
<input
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
|
@ -46,79 +51,93 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="no-results">
|
<div v-else class="no-results">No results found.</div>
|
||||||
No results found.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Draggable
|
<Draggable
|
||||||
v-else
|
v-else
|
||||||
:list="$store.state.networks"
|
:list="store.state.networks"
|
||||||
:filter="isCurrentlyInTouch"
|
:delay="LONG_TOUCH_DURATION"
|
||||||
:prevent-on-filter="false"
|
:delay-on-touch-only="true"
|
||||||
|
:touch-start-threshold="10"
|
||||||
handle=".channel-list-item[data-type='lobby']"
|
handle=".channel-list-item[data-type='lobby']"
|
||||||
draggable=".network"
|
draggable=".network"
|
||||||
ghost-class="ui-sortable-ghost"
|
ghost-class="ui-sortable-ghost"
|
||||||
drag-class="ui-sortable-dragged"
|
drag-class="ui-sortable-dragging"
|
||||||
group="networks"
|
group="networks"
|
||||||
class="networks"
|
class="networks"
|
||||||
|
item-key="uuid"
|
||||||
@change="onNetworkSort"
|
@change="onNetworkSort"
|
||||||
@start="onDragStart"
|
@choose="onDraggableChoose"
|
||||||
@end="onDragEnd"
|
@unchoose="onDraggableUnchoose"
|
||||||
>
|
>
|
||||||
<div
|
<template v-slot:item="{element: network}">
|
||||||
v-for="network in $store.state.networks"
|
<div
|
||||||
:id="'network-' + network.uuid"
|
:id="'network-' + network.uuid"
|
||||||
:key="network.uuid"
|
:key="network.uuid"
|
||||||
:class="{
|
:class="{
|
||||||
collapsed: network.isCollapsed,
|
collapsed: network.isCollapsed,
|
||||||
'not-connected': !network.status.connected,
|
'not-connected': !network.status.connected,
|
||||||
'not-secure': !network.status.secure,
|
'not-secure': !network.status.secure,
|
||||||
}"
|
}"
|
||||||
class="network"
|
class="network"
|
||||||
role="region"
|
role="region"
|
||||||
>
|
aria-live="polite"
|
||||||
<NetworkLobby
|
@touchstart="onDraggableTouchStart"
|
||||||
:network="network"
|
@touchmove="onDraggableTouchMove"
|
||||||
:is-join-channel-shown="network.isJoinChannelShown"
|
@touchend="onDraggableTouchEnd"
|
||||||
:active="
|
@touchcancel="onDraggableTouchEnd"
|
||||||
$store.state.activeChannel &&
|
|
||||||
network.channels[0] === $store.state.activeChannel.channel
|
|
||||||
"
|
|
||||||
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
|
|
||||||
/>
|
|
||||||
<JoinChannel
|
|
||||||
v-if="network.isJoinChannelShown"
|
|
||||||
:network="network"
|
|
||||||
:channel="network.channels[0]"
|
|
||||||
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Draggable
|
|
||||||
draggable=".channel-list-item"
|
|
||||||
ghost-class="ui-sortable-ghost"
|
|
||||||
drag-class="ui-sortable-dragged"
|
|
||||||
:group="network.uuid"
|
|
||||||
:filter="isCurrentlyInTouch"
|
|
||||||
:prevent-on-filter="false"
|
|
||||||
:list="network.channels"
|
|
||||||
class="channels"
|
|
||||||
@change="onChannelSort"
|
|
||||||
@start="onDragStart"
|
|
||||||
@end="onDragEnd"
|
|
||||||
>
|
>
|
||||||
<Channel
|
<NetworkLobby
|
||||||
v-for="(channel, index) in network.channels"
|
|
||||||
v-if="index > 0"
|
|
||||||
:key="channel.id"
|
|
||||||
:channel="channel"
|
|
||||||
:network="network"
|
:network="network"
|
||||||
|
:is-join-channel-shown="network.isJoinChannelShown"
|
||||||
:active="
|
:active="
|
||||||
$store.state.activeChannel &&
|
store.state.activeChannel &&
|
||||||
channel === $store.state.activeChannel.channel
|
network.channels[0] === store.state.activeChannel.channel
|
||||||
|
"
|
||||||
|
@toggle-join-channel="
|
||||||
|
network.isJoinChannelShown = !network.isJoinChannelShown
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</Draggable>
|
<JoinChannel
|
||||||
</div>
|
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>
|
</Draggable>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -136,6 +155,7 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
padding-right: 35px;
|
padding-right: 35px;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jump-to-input .input::placeholder {
|
.jump-to-input .input::placeholder {
|
||||||
|
@ -183,18 +203,27 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
|
||||||
|
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import Draggable from "vuedraggable";
|
import Draggable from "./Draggable.vue";
|
||||||
import {filter as fuzzyFilter} from "fuzzy";
|
import {filter as fuzzyFilter} from "fuzzy";
|
||||||
import NetworkLobby from "./NetworkLobby.vue";
|
import NetworkLobby from "./NetworkLobby.vue";
|
||||||
import Channel from "./Channel.vue";
|
import Channel from "./Channel.vue";
|
||||||
import JoinChannel from "./JoinChannel.vue";
|
import JoinChannel from "./JoinChannel.vue";
|
||||||
|
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
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 {
|
export default defineComponent({
|
||||||
name: "NetworkList",
|
name: "NetworkList",
|
||||||
components: {
|
components: {
|
||||||
JoinChannel,
|
JoinChannel,
|
||||||
|
@ -202,184 +231,283 @@ export default {
|
||||||
Channel,
|
Channel,
|
||||||
Draggable,
|
Draggable,
|
||||||
},
|
},
|
||||||
data() {
|
setup() {
|
||||||
return {
|
const store = useStore();
|
||||||
searchText: "",
|
const searchText = ref("");
|
||||||
activeSearchItem: null,
|
const activeSearchItem = ref<ClientChan | null>();
|
||||||
};
|
// Number of milliseconds a touch has to last to be considered long
|
||||||
},
|
const LONG_TOUCH_DURATION = 500;
|
||||||
computed: {
|
|
||||||
items() {
|
|
||||||
const items = [];
|
|
||||||
|
|
||||||
for (const network of this.$store.state.networks) {
|
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) {
|
for (const channel of network.channels) {
|
||||||
if (
|
if (
|
||||||
this.$store.state.activeChannel &&
|
store.state.activeChannel &&
|
||||||
channel === this.$store.state.activeChannel.channel
|
channel === store.state.activeChannel.channel
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({network, channel});
|
newItems.push({network, channel});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return newItems;
|
||||||
},
|
});
|
||||||
results() {
|
|
||||||
const results = fuzzyFilter(this.searchText, this.items, {
|
const results = computed(() => {
|
||||||
|
const newResults = fuzzyFilter(searchText.value, items.value, {
|
||||||
extract: (item) => item.channel.name,
|
extract: (item) => item.channel.name,
|
||||||
}).map((item) => item.original);
|
}).map((item) => item.original);
|
||||||
|
|
||||||
return results;
|
return newResults;
|
||||||
},
|
});
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
searchText() {
|
|
||||||
this.setActiveSearchItem();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
Mousetrap.bind("alt+shift+right", this.expandNetwork);
|
|
||||||
Mousetrap.bind("alt+shift+left", this.collapseNetwork);
|
|
||||||
Mousetrap.bind("alt+j", this.toggleSearch);
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
Mousetrap.unbind("alt+shift+right", this.expandNetwork);
|
|
||||||
Mousetrap.unbind("alt+shift+left", this.collapseNetwork);
|
|
||||||
Mousetrap.unbind("alt+j", this.toggleSearch);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
expandNetwork() {
|
|
||||||
if (this.$store.state.activeChannel) {
|
|
||||||
collapseNetwork(this.$store.state.activeChannel.network, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
collapseNetwork() {
|
|
||||||
if (this.$store.state.activeChannel) {
|
|
||||||
collapseNetwork(this.$store.state.activeChannel.network, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isCurrentlyInTouch(e) {
|
|
||||||
// TODO: Implement a way to sort on touch devices
|
|
||||||
return e.pointerType !== "mouse";
|
|
||||||
},
|
|
||||||
onDragStart(e) {
|
|
||||||
e.target.classList.add("ui-sortable-active");
|
|
||||||
},
|
|
||||||
onDragEnd(e) {
|
|
||||||
e.target.classList.remove("ui-sortable-active");
|
|
||||||
},
|
|
||||||
onNetworkSort(e) {
|
|
||||||
if (!e.moved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("sort", {
|
const collapseNetwork = (event: Mousetrap.ExtendedKeyboardEvent) => {
|
||||||
type: "networks",
|
if (isIgnoredKeybind(event)) {
|
||||||
order: this.$store.state.networks.map((n) => n.uuid),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChannelSort(e) {
|
|
||||||
if (!e.moved) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = this.$store.getters.findChannel(e.moved.element.id);
|
|
||||||
|
|
||||||
if (!channel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit("sort", {
|
|
||||||
type: "channels",
|
|
||||||
target: channel.network.uuid,
|
|
||||||
order: channel.network.channels.map((c) => c.id),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
toggleSearch(event) {
|
|
||||||
// Do not handle this keybind in the chat input because
|
|
||||||
// it can be used to type letters with umlauts
|
|
||||||
if (event.target.tagName === "TEXTAREA") {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.$refs.searchInput === document.activeElement) {
|
if (store.state.activeChannel) {
|
||||||
this.deactivateSearch();
|
collapseNetworkHelper(store.state.activeChannel.network, true);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activateSearch();
|
|
||||||
return false;
|
return false;
|
||||||
},
|
};
|
||||||
activateSearch() {
|
|
||||||
if (this.$refs.searchInput === document.activeElement) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sidebarWasClosed = this.$store.state.sidebarOpen ? false : true;
|
moveItemInArray(store.state.networks, oldIndex, newIndex);
|
||||||
this.$store.commit("sidebarOpen", true);
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$refs.searchInput.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deactivateSearch() {
|
|
||||||
this.activeSearchItem = null;
|
|
||||||
this.searchText = "";
|
|
||||||
this.$refs.searchInput.blur();
|
|
||||||
|
|
||||||
if (this.sidebarWasClosed) {
|
socket.emit("sort:networks", {
|
||||||
this.$store.commit("sidebarOpen", false);
|
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;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
setSearchText(e) {
|
// Indexes are offset by one due to the lobby
|
||||||
this.searchText = e.target.value;
|
oldIndex += 1;
|
||||||
},
|
newIndex += 1;
|
||||||
setActiveSearchItem(channel) {
|
|
||||||
if (!this.results.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
channel = this.results[0].channel;
|
channel = results.value[0].channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeSearchItem = channel;
|
activeSearchItem.value = channel;
|
||||||
},
|
};
|
||||||
selectResult() {
|
|
||||||
if (!this.searchText || !this.results.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$root.switchToChannel(this.activeSearchItem);
|
if (activeSearchItem.value) {
|
||||||
this.deactivateSearch();
|
switchToChannel(activeSearchItem.value);
|
||||||
this.scrollToActive();
|
deactivateSearch();
|
||||||
},
|
scrollToActive();
|
||||||
navigateResults(event, direction) {
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateResults = (event: Event, direction: number) => {
|
||||||
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
|
||||||
// and redirecting it to the message list container for scrolling
|
// and redirecting it to the message list container for scrolling
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!this.searchText) {
|
if (!searchText.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channels = this.results.map((r) => r.channel);
|
const channels = results.value.map((r) => r.channel);
|
||||||
|
|
||||||
// Bail out if there's no channels to select
|
// Bail out if there's no channels to select
|
||||||
if (!channels.length) {
|
if (!channels.length) {
|
||||||
this.activeSearchItem = null;
|
activeSearchItem.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentIndex = channels.indexOf(this.activeSearchItem);
|
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 there's no active channel select the first or last one depending on direction
|
||||||
if (!this.activeSearchItem || currentIndex === -1) {
|
if (!activeSearchItem.value || currentIndex === -1) {
|
||||||
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1];
|
activeSearchItem.value = direction ? channels[0] : channels[channels.length - 1];
|
||||||
this.scrollToActive();
|
scrollToActive();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -395,19 +523,54 @@ export default {
|
||||||
currentIndex -= channels.length;
|
currentIndex -= channels.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeSearchItem = channels[currentIndex];
|
activeSearchItem.value = channels[currentIndex];
|
||||||
this.scrollToActive();
|
scrollToActive();
|
||||||
},
|
};
|
||||||
scrollToActive() {
|
|
||||||
// Scroll the list if needed after the active class is applied
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const el = this.$refs.networklist.querySelector(".channel-list-item.active");
|
|
||||||
|
|
||||||
if (el) {
|
watch(searchText, () => {
|
||||||
el.scrollIntoView({block: "nearest", inline: "nearest"});
|
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>
|
</script>
|
||||||
|
|
|
@ -39,46 +39,63 @@
|
||||||
:class="['add-channel', {opened: isJoinChannelShown}]"
|
:class="['add-channel', {opened: isJoinChannelShown}]"
|
||||||
:aria-controls="'join-channel-' + channel.id"
|
:aria-controls="'join-channel-' + channel.id"
|
||||||
:aria-label="joinChannelLabel"
|
:aria-label="joinChannelLabel"
|
||||||
@click.stop="$emit('toggleJoinChannel')"
|
@click.stop="$emit('toggle-join-channel')"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</ChannelWrapper>
|
</ChannelWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {computed, defineComponent, PropType} from "vue";
|
||||||
import collapseNetwork from "../js/helpers/collapseNetwork";
|
import collapseNetwork from "../js/helpers/collapseNetwork";
|
||||||
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
import roundBadgeNumber from "../js/helpers/roundBadgeNumber";
|
||||||
import ChannelWrapper from "./ChannelWrapper.vue";
|
import ChannelWrapper from "./ChannelWrapper.vue";
|
||||||
|
|
||||||
export default {
|
import type {ClientChan, ClientNetwork} from "../js/types";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "Channel",
|
name: "Channel",
|
||||||
components: {
|
components: {
|
||||||
ChannelWrapper,
|
ChannelWrapper,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {
|
||||||
|
type: Object as PropType<ClientNetwork>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
isJoinChannelShown: Boolean,
|
isJoinChannelShown: Boolean,
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
isFiltering: Boolean,
|
isFiltering: Boolean,
|
||||||
},
|
},
|
||||||
computed: {
|
emits: ["toggle-join-channel"],
|
||||||
channel() {
|
setup(props) {
|
||||||
return this.network.channels[0];
|
const channel = computed(() => {
|
||||||
},
|
return props.network.channels[0];
|
||||||
joinChannelLabel() {
|
});
|
||||||
return this.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
|
||||||
},
|
const joinChannelLabel = computed(() => {
|
||||||
unreadCount() {
|
return props.isJoinChannelShown ? "Cancel" : "Join a channel…";
|
||||||
return roundBadgeNumber(this.channel.unread);
|
});
|
||||||
},
|
|
||||||
},
|
const unreadCount = computed(() => {
|
||||||
methods: {
|
return roundBadgeNumber(channel.value.unread);
|
||||||
onCollapseClick() {
|
});
|
||||||
collapseNetwork(this.network, !this.network.isCollapsed);
|
|
||||||
},
|
const onCollapseClick = () => {
|
||||||
getExpandLabel(network) {
|
collapseNetwork(props.network, !props.network.isCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpandLabel = (network: ClientNetwork) => {
|
||||||
return network.isCollapsed ? "Expand" : "Collapse";
|
return network.isCollapsed ? "Expand" : "Collapse";
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
joinChannelLabel,
|
||||||
|
unreadCount,
|
||||||
|
onCollapseClick,
|
||||||
|
getExpandLabel,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,23 +1,22 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType, h} from "vue";
|
||||||
import parse from "../js/helpers/parse";
|
import parse from "../js/helpers/parse";
|
||||||
|
import type {ClientMessage, ClientNetwork} from "../js/types";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "ParsedMessage",
|
name: "ParsedMessage",
|
||||||
functional: true,
|
functional: true,
|
||||||
props: {
|
props: {
|
||||||
text: String,
|
text: String,
|
||||||
message: Object,
|
message: {type: Object as PropType<ClientMessage | string>, required: false},
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||||
},
|
},
|
||||||
render(createElement, context) {
|
render(context) {
|
||||||
return parse(
|
return parse(
|
||||||
createElement,
|
typeof context.text !== "undefined" ? context.text : context.message.text,
|
||||||
typeof context.props.text !== "undefined"
|
context.message,
|
||||||
? context.props.text
|
context.network
|
||||||
: context.props.message.text,
|
|
||||||
context.props.message,
|
|
||||||
context.props.network
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<slot :isVisible="isVisible" />
|
<slot :is-visible="isVisible" />
|
||||||
<span
|
<span
|
||||||
ref="revealButton"
|
ref="revealButton"
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -16,18 +16,22 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import {defineComponent, ref} from "vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "RevealPassword",
|
name: "RevealPassword",
|
||||||
data() {
|
setup() {
|
||||||
|
const isVisible = ref(false);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
isVisible.value = !isVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isVisible: false,
|
isVisible,
|
||||||
|
onClick,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
});
|
||||||
onClick() {
|
|
||||||
this.isVisible = !this.isVisible;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,35 +1,66 @@
|
||||||
<template>
|
<template>
|
||||||
<Chat v-if="activeChannel" :network="activeChannel.network" :channel="activeChannel.channel" />
|
<Chat
|
||||||
|
v-if="activeChannel"
|
||||||
|
:network="activeChannel.network"
|
||||||
|
:channel="activeChannel.channel"
|
||||||
|
:focused="parseInt(String(route.query.focused), 10)"
|
||||||
|
@channel-changed="channelChanged"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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
|
// Temporary component for routing channels and lobbies
|
||||||
import Chat from "./Chat.vue";
|
import Chat from "./Chat.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "RoutedChat",
|
name: "RoutedChat",
|
||||||
components: {
|
components: {
|
||||||
Chat,
|
Chat,
|
||||||
},
|
},
|
||||||
computed: {
|
setup() {
|
||||||
activeChannel() {
|
const route = useRoute();
|
||||||
const chanId = parseInt(this.$route.params.id, 10);
|
const store = useStore();
|
||||||
const channel = this.$store.getters.findChannel(chanId);
|
|
||||||
|
const activeChannel = computed(() => {
|
||||||
|
const chanId = parseInt(String(route.params.id || ""), 10);
|
||||||
|
const channel = store.getters.findChannel(chanId);
|
||||||
return channel;
|
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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
});
|
||||||
activeChannel() {
|
|
||||||
this.setActiveChannel();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.setActiveChannel();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setActiveChannel() {
|
|
||||||
this.$store.commit("activeChannel", this.activeChannel);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,14 +7,12 @@
|
||||||
session.ip
|
session.ip
|
||||||
}}</a>
|
}}</a>
|
||||||
|
|
||||||
<template v-if="!session.current">
|
<p v-if="session.active > 1" class="session-usage">
|
||||||
<p v-if="session.active">
|
Active in {{ session.active }} browsers
|
||||||
<em>Currently active</em>
|
</p>
|
||||||
</p>
|
<p v-else-if="!session.current && !session.active" class="session-usage">
|
||||||
<p v-else>
|
Last used on <time>{{ lastUse }}</time>
|
||||||
Last used on <time>{{ lastUse }}</time>
|
</p>
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-btn">
|
<div class="session-item-btn">
|
||||||
<button class="btn" @click.prevent="signOut">
|
<button class="btn" @click.prevent="signOut">
|
||||||
|
@ -25,30 +23,61 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 localetime from "../js/helpers/localetime";
|
||||||
import Auth from "../js/auth";
|
import Auth from "../js/auth";
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
|
import {ClientSession} from "../js/store";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "Session",
|
name: "Session",
|
||||||
props: {
|
props: {
|
||||||
session: Object,
|
session: {
|
||||||
},
|
type: Object as PropType<ClientSession>,
|
||||||
computed: {
|
required: true,
|
||||||
lastUse() {
|
|
||||||
return localetime(this.session.lastUse);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
setup(props) {
|
||||||
signOut() {
|
const lastUse = computed(() => {
|
||||||
if (!this.session.current) {
|
return localetime(props.session.lastUse);
|
||||||
socket.emit("sign-out", this.session.token);
|
});
|
||||||
|
|
||||||
|
const signOut = () => {
|
||||||
|
if (!props.session.current) {
|
||||||
|
socket.emit("sign-out", props.session.token);
|
||||||
} else {
|
} else {
|
||||||
socket.emit("sign-out");
|
socket.emit("sign-out");
|
||||||
Auth.signout();
|
Auth.signout();
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastUse,
|
||||||
|
signOut,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</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>
|
|
@ -6,11 +6,13 @@
|
||||||
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
|
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg.svg`"
|
||||||
class="logo"
|
class="logo"
|
||||||
alt="The Lounge"
|
alt="The Lounge"
|
||||||
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
|
:src="`img/logo-${isPublic() ? 'horizontal-' : ''}transparent-bg-inverted.svg`"
|
||||||
class="logo-inverted"
|
class="logo-inverted"
|
||||||
alt="The Lounge"
|
alt="The Lounge"
|
||||||
|
role="presentation"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="isDevelopment"
|
v-if="isDevelopment"
|
||||||
|
@ -32,169 +34,236 @@
|
||||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||||
aria-label="Connect to network"
|
aria-label="Connect to network"
|
||||||
><router-link
|
><router-link
|
||||||
|
v-slot:default="{navigate, isActive}"
|
||||||
to="/connect"
|
to="/connect"
|
||||||
tag="button"
|
|
||||||
active-class="active"
|
|
||||||
:class="['icon', 'connect']"
|
|
||||||
aria-label="Connect to network"
|
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-controls="connect"
|
aria-controls="connect"
|
||||||
:aria-selected="$route.name === 'Connect'"
|
>
|
||||||
/></span>
|
<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"
|
<span class="tooltipped tooltipped-n tooltipped-no-touch" aria-label="Settings"
|
||||||
><router-link
|
><router-link
|
||||||
|
v-slot:default="{navigate, isActive}"
|
||||||
to="/settings"
|
to="/settings"
|
||||||
tag="button"
|
|
||||||
active-class="active"
|
|
||||||
:class="['icon', 'settings']"
|
|
||||||
aria-label="Settings"
|
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-controls="settings"
|
aria-controls="settings"
|
||||||
:aria-selected="$route.name === 'Settings'"
|
>
|
||||||
/></span>
|
<button
|
||||||
|
:class="['icon', 'settings', {active: isActive}]"
|
||||||
|
:aria-selected="isActive"
|
||||||
|
@click="navigate"
|
||||||
|
@keypress.enter="navigate"
|
||||||
|
></button> </router-link
|
||||||
|
></span>
|
||||||
<span
|
<span
|
||||||
class="tooltipped tooltipped-n tooltipped-no-touch"
|
class="tooltipped tooltipped-n tooltipped-no-touch"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
$store.state.serverConfiguration.isUpdateAvailable
|
store.state.serverConfiguration?.isUpdateAvailable
|
||||||
? 'Help\n(update available)'
|
? 'Help\n(update available)'
|
||||||
: 'Help'
|
: 'Help'
|
||||||
"
|
"
|
||||||
><router-link
|
><router-link
|
||||||
|
v-slot:default="{navigate, isActive}"
|
||||||
to="/help"
|
to="/help"
|
||||||
tag="button"
|
|
||||||
active-class="active"
|
|
||||||
:class="[
|
|
||||||
'icon',
|
|
||||||
'help',
|
|
||||||
{notified: $store.state.serverConfiguration.isUpdateAvailable},
|
|
||||||
]"
|
|
||||||
aria-label="Help"
|
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-controls="help"
|
aria-controls="help"
|
||||||
:aria-selected="$route.name === 'Help'"
|
>
|
||||||
/></span>
|
<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>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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";
|
import NetworkList from "./NetworkList.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "Sidebar",
|
name: "Sidebar",
|
||||||
components: {
|
components: {
|
||||||
NetworkList,
|
NetworkList,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
overlay: HTMLElement,
|
overlay: {type: Object as PropType<HTMLElement | null>, required: true},
|
||||||
},
|
},
|
||||||
data() {
|
setup(props) {
|
||||||
return {
|
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||||
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);
|
||||||
};
|
};
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.touchStartPos = null;
|
|
||||||
this.touchCurPos = null;
|
|
||||||
this.touchStartTime = 0;
|
|
||||||
this.menuWidth = 0;
|
|
||||||
this.menuIsMoving = false;
|
|
||||||
this.menuIsAbsolute = false;
|
|
||||||
|
|
||||||
this.onTouchStart = (e) => {
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
this.touchStartPos = this.touchCurPos = e.touches.item(0);
|
const touch = (touchCurPos.value = e.touches.item(0));
|
||||||
|
|
||||||
if (e.touches.length !== 1) {
|
if (
|
||||||
this.onTouchEnd();
|
!touch ||
|
||||||
|
!touchStartPos.value ||
|
||||||
|
!touchStartPos.value.screenX ||
|
||||||
|
!touchStartPos.value.screenY
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = window.getComputedStyle(this.$refs.sidebar);
|
let distX = touch.screenX - touchStartPos.value.screenX;
|
||||||
|
const distY = touch.screenY - touchStartPos.value.screenY;
|
||||||
|
|
||||||
this.menuWidth = parseFloat(styles.width);
|
if (!menuIsMoving.value) {
|
||||||
this.menuIsAbsolute = styles.position === "absolute";
|
|
||||||
|
|
||||||
if (!this.$store.state.sidebarOpen || this.touchStartPos.screenX > this.menuWidth) {
|
|
||||||
this.touchStartTime = Date.now();
|
|
||||||
|
|
||||||
document.body.addEventListener("touchmove", this.onTouchMove, {passive: true});
|
|
||||||
document.body.addEventListener("touchend", this.onTouchEnd, {passive: true});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.onTouchMove = (e) => {
|
|
||||||
const touch = (this.touchCurPos = e.touches.item(0));
|
|
||||||
let distX = touch.screenX - this.touchStartPos.screenX;
|
|
||||||
const distY = touch.screenY - this.touchStartPos.screenY;
|
|
||||||
|
|
||||||
if (!this.menuIsMoving) {
|
|
||||||
// tan(45°) is 1. Gestures in 0°-45° (< 1) are considered horizontal, so
|
// 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
|
// menu must be open; gestures in 45°-90° (>1) are considered vertical, so
|
||||||
// chat windows must be scrolled.
|
// chat windows must be scrolled.
|
||||||
if (Math.abs(distY / distX) >= 1) {
|
if (Math.abs(distY / distX) >= 1) {
|
||||||
this.onTouchEnd();
|
// eslint-disable-next-line no-use-before-define
|
||||||
|
onTouchEnd();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const devicePixelRatio = window.devicePixelRatio || 2;
|
const devicePixelRatio = window.devicePixelRatio || 2;
|
||||||
|
|
||||||
if (Math.abs(distX) > devicePixelRatio) {
|
if (Math.abs(distX) > devicePixelRatio) {
|
||||||
this.$store.commit("sidebarDragging", true);
|
store.commit("sidebarDragging", true);
|
||||||
this.menuIsMoving = true;
|
menuIsMoving.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not animate the menu on desktop view
|
// Do not animate the menu on desktop view
|
||||||
if (!this.menuIsAbsolute) {
|
if (!menuIsAbsolute.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.$store.state.sidebarOpen) {
|
if (store.state.sidebarOpen) {
|
||||||
distX += this.menuWidth;
|
distX += menuWidth.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distX > this.menuWidth) {
|
if (distX > menuWidth.value) {
|
||||||
distX = this.menuWidth;
|
distX = menuWidth.value;
|
||||||
} else if (distX < 0) {
|
} else if (distX < 0) {
|
||||||
distX = 0;
|
distX = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$refs.sidebar.style.transform = "translate3d(" + distX + "px, 0, 0)";
|
if (sidebar.value) {
|
||||||
this.overlay.style.opacity = distX / this.menuWidth;
|
sidebar.value.style.transform = "translate3d(" + distX.toString() + "px, 0, 0)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.overlay) {
|
||||||
|
props.overlay.style.opacity = `${distX / menuWidth.value}`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.onTouchEnd = () => {
|
const onTouchEnd = () => {
|
||||||
const diff = this.touchCurPos.screenX - this.touchStartPos.screenX;
|
if (!touchStartPos.value?.screenX || !touchCurPos.value?.screenX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = touchCurPos.value.screenX - touchStartPos.value.screenX;
|
||||||
const absDiff = Math.abs(diff);
|
const absDiff = Math.abs(diff);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
absDiff > this.menuWidth / 2 ||
|
absDiff > menuWidth.value / 2 ||
|
||||||
(Date.now() - this.touchStartTime < 180 && absDiff > 50)
|
(Date.now() - touchStartTime.value < 180 && absDiff > 50)
|
||||||
) {
|
) {
|
||||||
this.toggle(diff > 0);
|
toggle(diff > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeEventListener("touchmove", this.onTouchMove);
|
document.body.removeEventListener("touchmove", onTouchMove);
|
||||||
document.body.removeEventListener("touchend", this.onTouchEnd);
|
document.body.removeEventListener("touchend", onTouchEnd);
|
||||||
this.$store.commit("sidebarDragging", false);
|
|
||||||
|
|
||||||
this.$refs.sidebar.style.transform = null;
|
store.commit("sidebarDragging", false);
|
||||||
this.overlay.style.opacity = null;
|
|
||||||
|
|
||||||
this.touchStartPos = null;
|
touchStartPos.value = null;
|
||||||
this.touchCurPos = null;
|
touchCurPos.value = null;
|
||||||
this.touchStartTime = 0;
|
touchStartTime.value = 0;
|
||||||
this.menuIsMoving = false;
|
menuIsMoving.value = false;
|
||||||
|
|
||||||
|
void nextTick(() => {
|
||||||
|
if (sidebar.value) {
|
||||||
|
sidebar.value.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.overlay) {
|
||||||
|
props.overlay.style.opacity = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
this.toggle = (state) => {
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
this.$store.commit("sidebarOpen", state);
|
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});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.body.addEventListener("touchstart", this.onTouchStart, {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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
});
|
||||||
isPublic: () => document.body.classList.contains("public"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
|
<button class="lt" aria-label="Toggle channel list" @click="store.commit('toggleSidebar')" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
import {defineComponent} from "vue";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "SidebarToggle",
|
name: "SidebarToggle",
|
||||||
};
|
setup() {
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="ban in channel.data" :key="ban.hostmask">
|
<tr v-for="ban in channel.data" :key="ban.hostmask">
|
||||||
<td class="hostmask">{{ ban.hostmask }}</td>
|
<td class="hostmask"><ParsedMessage :network="network" :text="ban.hostmask" /></td>
|
||||||
<td class="banned_by">{{ ban.banned_by }}</td>
|
<td class="banned_by">{{ ban.banned_by }}</td>
|
||||||
<td class="banned_at">{{ localetime(ban.banned_at) }}</td>
|
<td class="banned_at">{{ localetime(ban.banned_at) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -17,19 +17,29 @@
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import localetime from "../../js/helpers/localetime";
|
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 {
|
export default defineComponent({
|
||||||
name: "ListBans",
|
name: "ListBans",
|
||||||
|
components: {
|
||||||
|
ParsedMessage,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
methods: {
|
setup() {
|
||||||
localetime(date) {
|
const localetime = (date: number | Date) => {
|
||||||
return localetime(date);
|
return localeTime(date);
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
localetime,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -18,17 +18,19 @@
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientChan, ClientNetwork} from "../../js/types";
|
||||||
import ParsedMessage from "../ParsedMessage.vue";
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "ListChannels",
|
name: "ListChannels",
|
||||||
components: {
|
components: {
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -8,26 +8,32 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="user in channel.data" :key="user.hostmask">
|
<tr v-for="user in channel.data" :key="user.hostmask">
|
||||||
<td class="hostmask">{{ user.hostmask }}</td>
|
<td class="hostmask"><ParsedMessage :network="network" :text="user.hostmask" /></td>
|
||||||
<td class="when">{{ localetime(user.when) }}</td>
|
<td class="when">{{ localetime(user.when) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import localetime from "../../js/helpers/localetime";
|
import localetime from "../../js/helpers/localetime";
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientChan} from "../../js/types";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "ListIgnored",
|
name: "ListIgnored",
|
||||||
|
components: {
|
||||||
|
ParsedMessage,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
methods: {
|
setup() {
|
||||||
localetime(date) {
|
return {
|
||||||
return localetime(date);
|
localetime,
|
||||||
},
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -9,7 +9,9 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="invite in channel.data" :key="invite.hostmask">
|
<tr v-for="invite in channel.data" :key="invite.hostmask">
|
||||||
<td class="hostmask">{{ invite.hostmask }}</td>
|
<td class="hostmask">
|
||||||
|
<ParsedMessage :network="network" :text="invite.hostmask" />
|
||||||
|
</td>
|
||||||
<td class="invitened_by">{{ invite.invited_by }}</td>
|
<td class="invitened_by">{{ invite.invited_by }}</td>
|
||||||
<td class="invitened_at">{{ localetime(invite.invited_at) }}</td>
|
<td class="invitened_at">{{ localetime(invite.invited_at) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -17,19 +19,25 @@
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import ParsedMessage from "../ParsedMessage.vue";
|
||||||
import localetime from "../../js/helpers/localetime";
|
import localetime from "../../js/helpers/localetime";
|
||||||
|
import {defineComponent, PropType} from "vue";
|
||||||
|
import {ClientNetwork, ClientChan} from "../../js/types";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "ListInvites",
|
name: "ListInvites",
|
||||||
|
components: {
|
||||||
|
ParsedMessage,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
||||||
channel: Object,
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
||||||
},
|
},
|
||||||
methods: {
|
setup() {
|
||||||
localetime(date) {
|
return {
|
||||||
return localetime(date);
|
localetime: (date: Date) => localetime(date),
|
||||||
},
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,40 +1,84 @@
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="['user', nickColor, {active: active}]"
|
:class="['user', {[nickColor]: store.state.settings.coloredNicks}, {active: active}]"
|
||||||
:data-name="user.nick"
|
:data-name="user.nick"
|
||||||
role="button"
|
role="button"
|
||||||
v-on="onHover ? {mouseenter: hover} : {}"
|
v-on="onHover ? {mouseenter: hover} : {}"
|
||||||
@click.prevent="openContextMenu"
|
@click.prevent="openContextMenu"
|
||||||
@contextmenu.prevent="openContextMenu"
|
@contextmenu.prevent="openContextMenu"
|
||||||
><slot>{{ user.mode }}{{ user.nick }}</slot></span
|
><slot>{{ mode }}{{ user.nick }}</slot></span
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 colorClass from "../js/helpers/colorClass";
|
||||||
|
import type {ClientChan, ClientNetwork} from "../js/types";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
export default {
|
type UsernameUser = Partial<UserInMessage> & {
|
||||||
|
mode?: string;
|
||||||
|
nick: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "Username",
|
name: "Username",
|
||||||
props: {
|
props: {
|
||||||
user: Object,
|
user: {
|
||||||
|
// TODO: UserInMessage shouldn't be necessary here.
|
||||||
|
type: Object as PropType<UsernameUser | UserInMessage>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
onHover: Function,
|
onHover: {
|
||||||
},
|
type: Function as PropType<(user: UserInMessage) => void>,
|
||||||
computed: {
|
required: false,
|
||||||
nickColor() {
|
|
||||||
return colorClass(this.user.nick);
|
|
||||||
},
|
},
|
||||||
|
channel: {type: Object as PropType<ClientChan>, required: false},
|
||||||
|
network: {type: Object as PropType<ClientNetwork>, required: false},
|
||||||
},
|
},
|
||||||
methods: {
|
setup(props) {
|
||||||
hover() {
|
const mode = computed(() => {
|
||||||
return this.onHover(this.user);
|
// Message objects have a singular mode, but user objects have modes array
|
||||||
},
|
if (props.user.modes) {
|
||||||
openContextMenu(event) {
|
return props.user.modes[0];
|
||||||
this.$root.$emit("contextmenu:user", {
|
}
|
||||||
|
|
||||||
|
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,
|
event: event,
|
||||||
user: this.user,
|
user: props.user,
|
||||||
|
network: props.network,
|
||||||
|
channel: props.channel,
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
nickColor,
|
||||||
|
hover,
|
||||||
|
openContextMenu,
|
||||||
|
store,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,31 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="version-checker" :class="[$store.state.versionStatus]">
|
<div id="version-checker" :class="[store.state.versionStatus]">
|
||||||
<p v-if="$store.state.versionStatus === 'loading'">
|
<p v-if="store.state.versionStatus === 'loading'">Checking for updates…</p>
|
||||||
Checking for updates…
|
<p v-if="store.state.versionStatus === 'new-version'">
|
||||||
</p>
|
The Lounge <b>{{ store.state.versionData?.latest.version }}</b>
|
||||||
<p v-if="$store.state.versionStatus === 'new-version'">
|
<template v-if="store.state.versionData?.latest.prerelease"> (pre-release) </template>
|
||||||
The Lounge <b>{{ $store.state.versionData.latest.version }}</b>
|
|
||||||
<template v-if="$store.state.versionData.latest.prerelease">
|
|
||||||
(pre-release)
|
|
||||||
</template>
|
|
||||||
is now available.
|
is now available.
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a :href="$store.state.versionData.latest.url" target="_blank" rel="noopener">
|
<a :href="store.state.versionData?.latest.url" target="_blank" rel="noopener">
|
||||||
Read more on GitHub
|
Read more on GitHub
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="$store.state.versionStatus === 'new-packages'">
|
<p v-if="store.state.versionStatus === 'new-packages'">
|
||||||
The Lounge is up to date, but there are out of date packages Run
|
The Lounge is up to date, but there are out of date packages Run
|
||||||
<code>thelounge upgrade</code> on the server to upgrade packages.
|
<code>thelounge upgrade</code> on the server to upgrade packages.
|
||||||
</p>
|
</p>
|
||||||
<template v-if="$store.state.versionStatus === 'up-to-date'">
|
<template v-if="store.state.versionStatus === 'up-to-date'">
|
||||||
<p>
|
<p>The Lounge is up to date!</p>
|
||||||
The Lounge is up to date!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="$store.state.versionDataExpired"
|
v-if="store.state.versionDataExpired"
|
||||||
id="check-now"
|
id="check-now"
|
||||||
class="btn btn-small"
|
class="btn btn-small"
|
||||||
@click="checkNow"
|
@click="checkNow"
|
||||||
|
@ -33,32 +27,40 @@
|
||||||
Check now
|
Check now
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="$store.state.versionStatus === 'error'">
|
<template v-if="store.state.versionStatus === 'error'">
|
||||||
<p>
|
<p>Information about latest release could not be retrieved.</p>
|
||||||
Information about latest release could not be retrieved.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
|
<button id="check-now" class="btn btn-small" @click="checkNow">Try again</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, onMounted} from "vue";
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
|
import {useStore} from "../js/store";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "VersionChecker",
|
name: "VersionChecker",
|
||||||
mounted() {
|
setup() {
|
||||||
if (!this.$store.state.versionData) {
|
const store = useStore();
|
||||||
this.checkNow();
|
|
||||||
}
|
const checkNow = () => {
|
||||||
},
|
store.commit("versionData", null);
|
||||||
methods: {
|
store.commit("versionStatus", "loading");
|
||||||
checkNow() {
|
|
||||||
this.$store.commit("versionData", null);
|
|
||||||
this.$store.commit("versionStatus", "loading");
|
|
||||||
socket.emit("changelog");
|
socket.emit("changelog");
|
||||||
},
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!store.state.versionData) {
|
||||||
|
checkNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
checkNow,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,31 +7,26 @@
|
||||||
<router-link id="back-to-help" to="/help">« Help</router-link>
|
<router-link id="back-to-help" to="/help">« Help</router-link>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="
|
v-if="store.state.versionData?.current && store.state.versionData?.current.version"
|
||||||
$store.state.versionData &&
|
|
||||||
$store.state.versionData.current &&
|
|
||||||
$store.state.versionData.current.version
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
Release notes for {{ $store.state.versionData.current.version }}
|
Release notes for {{ store.state.versionData.current.version }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<template v-if="$store.state.versionData.current.changelog">
|
<template v-if="store.state.versionData.current.changelog">
|
||||||
<h3>Introduction</h3>
|
<h3>Introduction</h3>
|
||||||
<div
|
<div
|
||||||
ref="changelog"
|
ref="changelog"
|
||||||
class="changelog-text"
|
class="changelog-text"
|
||||||
v-html="$store.state.versionData.current.changelog"
|
v-html="store.state.versionData.current.changelog"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>Unable to retrieve changelog for current release from GitHub.</p>
|
<p>Unable to retrieve changelog for current release from GitHub.</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
:href="
|
v-if="store.state.serverConfiguration?.version"
|
||||||
`https://github.com/thelounge/thelounge/releases/tag/v${$store.state.serverConfiguration.version}`
|
:href="`https://github.com/thelounge/thelounge/releases/tag/v${store.state.serverConfiguration?.version}`"
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>View release notes for this version on GitHub</a
|
>View release notes for this version on GitHub</a
|
||||||
|
@ -44,34 +39,29 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, onMounted, onUpdated, ref} from "vue";
|
||||||
import socket from "../../js/socket";
|
import socket from "../../js/socket";
|
||||||
|
import {useStore} from "../../js/store";
|
||||||
import SidebarToggle from "../SidebarToggle.vue";
|
import SidebarToggle from "../SidebarToggle.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "Changelog",
|
name: "Changelog",
|
||||||
components: {
|
components: {
|
||||||
SidebarToggle,
|
SidebarToggle,
|
||||||
},
|
},
|
||||||
mounted() {
|
setup() {
|
||||||
if (!this.$store.state.versionData) {
|
const store = useStore();
|
||||||
socket.emit("changelog");
|
const changelog = ref<HTMLDivElement | null>(null);
|
||||||
}
|
|
||||||
|
|
||||||
this.patchChangelog();
|
const patchChangelog = () => {
|
||||||
},
|
if (!changelog.value) {
|
||||||
updated() {
|
|
||||||
this.patchChangelog();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
patchChangelog() {
|
|
||||||
if (!this.$refs.changelog) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = this.$refs.changelog.querySelectorAll("a");
|
const links = changelog.value.querySelectorAll("a");
|
||||||
|
|
||||||
for (const link of links) {
|
links.forEach((link) => {
|
||||||
// Make sure all links will open a new tab instead of exiting the application
|
// Make sure all links will open a new tab instead of exiting the application
|
||||||
link.setAttribute("target", "_blank");
|
link.setAttribute("target", "_blank");
|
||||||
link.setAttribute("rel", "noopener");
|
link.setAttribute("rel", "noopener");
|
||||||
|
@ -80,8 +70,24 @@ export default {
|
||||||
// Add required metadata to image links, to support built-in image viewer
|
// Add required metadata to image links, to support built-in image viewer
|
||||||
link.classList.add("toggle-thumbnail");
|
link.classList.add("toggle-thumbnail");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!store.state.versionData) {
|
||||||
|
socket.emit("changelog");
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
patchChangelog();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpdated(() => {
|
||||||
|
patchChangelog();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,11 +2,14 @@
|
||||||
<NetworkForm :handle-submit="handleSubmit" :defaults="defaults" :disabled="disabled" />
|
<NetworkForm :handle-submit="handleSubmit" :defaults="defaults" :disabled="disabled" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import socket from "../../js/socket";
|
import {defineComponent, ref} from "vue";
|
||||||
import NetworkForm from "../NetworkForm.vue";
|
|
||||||
|
|
||||||
export default {
|
import socket from "../../js/socket";
|
||||||
|
import {useStore} from "../../js/store";
|
||||||
|
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
name: "Connect",
|
name: "Connect",
|
||||||
components: {
|
components: {
|
||||||
NetworkForm,
|
NetworkForm,
|
||||||
|
@ -14,25 +17,22 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
queryParams: Object,
|
queryParams: Object,
|
||||||
},
|
},
|
||||||
data() {
|
setup(props) {
|
||||||
// Merge settings from url params into default settings
|
const store = useStore();
|
||||||
const defaults = Object.assign(
|
|
||||||
{},
|
const disabled = ref(false);
|
||||||
this.$store.state.serverConfiguration.defaults,
|
|
||||||
this.parseOverrideParams(this.queryParams)
|
const handleSubmit = (data: Record<string, any>) => {
|
||||||
);
|
disabled.value = true;
|
||||||
return {
|
|
||||||
disabled: false,
|
|
||||||
defaults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
handleSubmit(data) {
|
|
||||||
this.disabled = true;
|
|
||||||
socket.emit("network:new", data);
|
socket.emit("network:new", data);
|
||||||
},
|
};
|
||||||
parseOverrideParams(params) {
|
|
||||||
const parsedParams = {};
|
const parseOverrideParams = (params?: Record<string, string>) => {
|
||||||
|
if (!params) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams: Record<string, any> = {};
|
||||||
|
|
||||||
for (let key of Object.keys(params)) {
|
for (let key of Object.keys(params)) {
|
||||||
let value = params[key];
|
let value = params[key];
|
||||||
|
@ -49,7 +49,7 @@ export default {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!Object.prototype.hasOwnProperty.call(
|
!Object.prototype.hasOwnProperty.call(
|
||||||
this.$store.state.serverConfiguration.defaults,
|
store.state.serverConfiguration?.defaults,
|
||||||
key
|
key
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -58,17 +58,12 @@ export default {
|
||||||
|
|
||||||
// When the network is locked, URL overrides should not affect disabled fields
|
// When the network is locked, URL overrides should not affect disabled fields
|
||||||
if (
|
if (
|
||||||
this.$store.state.serverConfiguration.lockNetwork &&
|
store.state.serverConfiguration?.lockNetwork &&
|
||||||
["host", "port", "tls", "rejectUnauthorized"].includes(key)
|
["name", "host", "port", "tls", "rejectUnauthorized"].includes(key)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the network is not displayed, its name in the UI is not customizable
|
|
||||||
if (!this.$store.state.serverConfiguration.displayNetwork && key === "name") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === "join") {
|
if (key === "join") {
|
||||||
value = value
|
value = value
|
||||||
.split(",")
|
.split(",")
|
||||||
|
@ -83,7 +78,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override server provided defaults with parameters passed in the URL if they match the data type
|
// Override server provided defaults with parameters passed in the URL if they match the data type
|
||||||
switch (typeof this.$store.state.serverConfiguration.defaults[key]) {
|
switch (typeof store.state.serverConfiguration?.defaults[key]) {
|
||||||
case "boolean":
|
case "boolean":
|
||||||
if (value === "0" || value === "false") {
|
if (value === "0" || value === "false") {
|
||||||
parsedParams[key] = false;
|
parsedParams[key] = false;
|
||||||
|
@ -102,7 +97,21 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsedParams;
|
return parsedParams;
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const defaults = ref<Partial<NetworkFormDefaults>>(
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
store.state.serverConfiguration?.defaults,
|
||||||
|
parseOverrideParams(props.queryParams)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaults,
|
||||||
|
disabled,
|
||||||
|
handleSubmit,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<h2 class="help-version-title">
|
<h2 class="help-version-title">
|
||||||
<span>About The Lounge</span>
|
<span>About The Lounge</span>
|
||||||
<small>
|
<small>
|
||||||
v{{ $store.state.serverConfiguration.version }} (<router-link
|
v{{ store.state.serverConfiguration?.version }} (<router-link
|
||||||
id="view-changelog"
|
id="view-changelog"
|
||||||
to="/changelog"
|
to="/changelog"
|
||||||
>release notes</router-link
|
>release notes</router-link
|
||||||
|
@ -20,15 +20,13 @@
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<VersionChecker />
|
<VersionChecker />
|
||||||
|
|
||||||
<template v-if="$store.state.serverConfiguration.gitCommit">
|
<template v-if="store.state.serverConfiguration?.gitCommit">
|
||||||
<p>
|
<p>
|
||||||
The Lounge is running from source (<a
|
The Lounge is running from source (<a
|
||||||
:href="
|
:href="`https://github.com/thelounge/thelounge/tree/${store.state.serverConfiguration?.gitCommit}`"
|
||||||
`https://github.com/thelounge/thelounge/tree/${$store.state.serverConfiguration.gitCommit}`
|
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>commit <code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
|
>commit <code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
|
||||||
>).
|
>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -36,13 +34,11 @@
|
||||||
<li>
|
<li>
|
||||||
Compare
|
Compare
|
||||||
<a
|
<a
|
||||||
:href="
|
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.gitCommit}...master`"
|
||||||
`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.gitCommit}...master`
|
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>between
|
>between
|
||||||
<code>{{ $store.state.serverConfiguration.gitCommit }}</code> and
|
<code>{{ store.state.serverConfiguration?.gitCommit }}</code> and
|
||||||
<code>master</code></a
|
<code>master</code></a
|
||||||
>
|
>
|
||||||
to see what you are missing
|
to see what you are missing
|
||||||
|
@ -50,14 +46,12 @@
|
||||||
<li>
|
<li>
|
||||||
Compare
|
Compare
|
||||||
<a
|
<a
|
||||||
:href="
|
:href="`https://github.com/thelounge/thelounge/compare/${store.state.serverConfiguration?.version}...${store.state.serverConfiguration?.gitCommit}`"
|
||||||
`https://github.com/thelounge/thelounge/compare/${$store.state.serverConfiguration.version}...${$store.state.serverConfiguration.gitCommit}`
|
|
||||||
"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>between
|
>between
|
||||||
<code>{{ $store.state.serverConfiguration.version }}</code> and
|
<code>{{ store.state.serverConfiguration?.version }}</code> and
|
||||||
<code>{{ $store.state.serverConfiguration.gitCommit }}</code></a
|
<code>{{ store.state.serverConfiguration?.gitCommit }}</code></a
|
||||||
>
|
>
|
||||||
to see your local changes
|
to see your local changes
|
||||||
</li>
|
</li>
|
||||||
|
@ -93,6 +87,36 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
|
@ -155,6 +179,26 @@
|
||||||
</div>
|
</div>
|
||||||
</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="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>A</kbd></span>
|
<span v-if="!isApple"><kbd>Alt</kbd> <kbd>A</kbd></span>
|
||||||
|
@ -195,6 +239,38 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2>Formatting Shortcuts</h2>
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
|
@ -312,9 +388,7 @@
|
||||||
<kbd>↓</kbd> keys to highlight an item, and insert it by pressing <kbd>Tab</kbd> or
|
<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).
|
<kbd>Enter</kbd> (or by clicking the desired item).
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>Autocompletion can be disabled in settings.</p>
|
||||||
Autocompletion can be disabled in settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
|
@ -468,9 +542,7 @@
|
||||||
<code>/disconnect [message]</code>
|
<code>/disconnect [message]</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>Disconnect from the current network with an optionally-provided message.</p>
|
||||||
Disconnect from the current network with an optionally-provided message.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -522,22 +594,37 @@
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/join channel</code>
|
<code>/join channel [password]</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Join a channel.</p>
|
<p>
|
||||||
|
Join a channel. Password is only needed in protected channels and can
|
||||||
|
usually be omitted.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-item">
|
<div class="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/kick nick</code>
|
<code>/kick nick [reason]</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>Kick a user from the current channel.</p>
|
<p>Kick a user from the current channel.</p>
|
||||||
</div>
|
</div>
|
||||||
</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="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/list</code>
|
<code>/list</code>
|
||||||
|
@ -581,6 +668,20 @@
|
||||||
</div>
|
</div>
|
||||||
</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="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/nick newnick</code>
|
<code>/nick newnick</code>
|
||||||
|
@ -604,9 +705,7 @@
|
||||||
<code>/op nick [...nick]</code>
|
<code>/op nick [...nick]</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>Give op (<code>+o</code>) to one or several users in the current channel.</p>
|
||||||
Give op (<code>+o</code>) to one or several users in the current channel.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -650,9 +749,7 @@
|
||||||
<code>/quit [message]</code>
|
<code>/quit [message]</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>Disconnect from the current network with an optional message.</p>
|
||||||
Disconnect from the current network with an optional message.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -675,6 +772,15 @@
|
||||||
</div>
|
</div>
|
||||||
</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="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/topic [newtopic]</code>
|
<code>/topic [newtopic]</code>
|
||||||
|
@ -711,6 +817,18 @@
|
||||||
</div>
|
</div>
|
||||||
</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="help-item">
|
||||||
<div class="subject">
|
<div class="subject">
|
||||||
<code>/voice nick [...nick]</code>
|
<code>/voice nick [...nick]</code>
|
||||||
|
@ -727,29 +845,35 @@
|
||||||
<code>/whois nick</code>
|
<code>/whois nick</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>Retrieve information about the given user on the current network.</p>
|
||||||
Retrieve information about the given user on the current network.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
|
import {defineComponent, ref} from "vue";
|
||||||
|
import {useStore} from "../../js/store";
|
||||||
import SidebarToggle from "../SidebarToggle.vue";
|
import SidebarToggle from "../SidebarToggle.vue";
|
||||||
import VersionChecker from "../VersionChecker.vue";
|
import VersionChecker from "../VersionChecker.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "Help",
|
name: "Help",
|
||||||
components: {
|
components: {
|
||||||
SidebarToggle,
|
SidebarToggle,
|
||||||
VersionChecker,
|
VersionChecker,
|
||||||
},
|
},
|
||||||
data() {
|
setup() {
|
||||||
|
const store = useStore();
|
||||||
|
const isApple = navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false;
|
||||||
|
const isTouch = navigator.maxTouchPoints > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isApple: navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) || false,
|
isApple,
|
||||||
|
isTouch,
|
||||||
|
store,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,44 +7,61 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 socket from "../../js/socket";
|
||||||
import NetworkForm from "../NetworkForm.vue";
|
import {useStore} from "../../js/store";
|
||||||
|
import NetworkForm, {NetworkFormDefaults} from "../NetworkForm.vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "NetworkEdit",
|
name: "NetworkEdit",
|
||||||
components: {
|
components: {
|
||||||
NetworkForm,
|
NetworkForm,
|
||||||
},
|
},
|
||||||
data() {
|
setup() {
|
||||||
return {
|
const route = useRoute();
|
||||||
disabled: false,
|
const store = useStore();
|
||||||
networkData: null,
|
|
||||||
|
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 || ""));
|
||||||
};
|
};
|
||||||
},
|
|
||||||
watch: {
|
const handleSubmit = (data: {uuid: string; name: string}) => {
|
||||||
"$route.params.uuid"() {
|
disabled.value = true;
|
||||||
this.setNetworkData();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.setNetworkData();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setNetworkData() {
|
|
||||||
socket.emit("network:get", this.$route.params.uuid);
|
|
||||||
this.networkData = this.$store.getters.findNetwork(this.$route.params.uuid);
|
|
||||||
},
|
|
||||||
handleSubmit(data) {
|
|
||||||
this.disabled = true;
|
|
||||||
socket.emit("network:edit", data);
|
socket.emit("network:edit", data);
|
||||||
|
|
||||||
// TODO: move networks to vuex and update state when the network info comes in
|
// TODO: move networks to vuex and update state when the network info comes in
|
||||||
const network = this.$store.getters.findNetwork(data.uuid);
|
const network = store.getters.findNetwork(data.uuid);
|
||||||
network.name = network.channels[0].name = data.name;
|
|
||||||
|
|
||||||
this.$root.switchToChannel(network.channels[0]);
|
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>
|
</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>
|
|
@ -3,569 +3,54 @@
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<SidebarToggle />
|
<SidebarToggle />
|
||||||
</div>
|
</div>
|
||||||
<form ref="settingsForm" class="container" @change="onChange" @submit.prevent>
|
<Navigation />
|
||||||
<h1 class="title">Settings</h1>
|
|
||||||
|
|
||||||
<div>
|
<div class="container">
|
||||||
<label class="opt">
|
<form ref="settingsForm" autocomplete="off" @change="onChange" @submit.prevent>
|
||||||
<input
|
<router-view></router-view>
|
||||||
:checked="$store.state.settings.advanced"
|
</form>
|
||||||
type="checkbox"
|
</div>
|
||||||
name="advanced"
|
|
||||||
/>
|
|
||||||
Advanced settings
|
|
||||||
</label>
|
|
||||||
</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.public && $store.state.settings.advanced">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
Show seconds in timestamp
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
|
|
||||||
<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>
|
|
||||||
<h2>
|
|
||||||
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>
|
|
||||||
<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 v-if="$store.state.settings.advanced">
|
|
||||||
<label class="opt">
|
|
||||||
<label for="nickPostfix" class="sr-only">
|
|
||||||
Nick autocomplete postfix (e.g. <code>, </code>)
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 v-if="isIOS" class="apple-push-unsupported">
|
|
||||||
Safari does
|
|
||||||
<a
|
|
||||||
href="https://bugs.webkit.org/show_bug.cgi?id=182566"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>not support the web push notification specification</a
|
|
||||||
>, and because all browsers on iOS use Safari under the hood, The Lounge
|
|
||||||
is unable to provide push notifications on iOS devices.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<h2>Browser Notifications</h2>
|
|
||||||
<div>
|
|
||||||
<label class="opt">
|
|
||||||
<input
|
|
||||||
id="desktopNotifications"
|
|
||||||
:checked="$store.state.settings.desktopNotifications"
|
|
||||||
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 === '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 v-if="$store.state.settings.advanced">
|
|
||||||
<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 && $store.state.settings.advanced">
|
|
||||||
<label class="opt">
|
|
||||||
<label for="highlights" class="sr-only">
|
|
||||||
Custom highlights (comma-separated keywords)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="highlights"
|
|
||||||
:value="$store.state.settings.highlights"
|
|
||||||
type="text"
|
|
||||||
name="highlights"
|
|
||||||
class="input"
|
|
||||||
placeholder="Custom highlights (comma-separated keywords)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!$store.state.serverConfiguration.public &&
|
|
||||||
!$store.state.serverConfiguration.ldapEnabled
|
|
||||||
"
|
|
||||||
id="change-password"
|
|
||||||
>
|
|
||||||
<h2>Change password</h2>
|
|
||||||
<div class="password-container">
|
|
||||||
<label for="old_password_input" class="sr-only">
|
|
||||||
Enter current password
|
|
||||||
</label>
|
|
||||||
<RevealPassword v-slot:default="slotProps">
|
|
||||||
<input
|
|
||||||
id="old_password_input"
|
|
||||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
|
||||||
name="old_password"
|
|
||||||
class="input"
|
|
||||||
placeholder="Enter current password"
|
|
||||||
/>
|
|
||||||
</RevealPassword>
|
|
||||||
</div>
|
|
||||||
<div class="password-container">
|
|
||||||
<label for="new_password_input" class="sr-only">
|
|
||||||
Enter desired new password
|
|
||||||
</label>
|
|
||||||
<RevealPassword v-slot:default="slotProps">
|
|
||||||
<input
|
|
||||||
id="new_password_input"
|
|
||||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
|
||||||
name="new_password"
|
|
||||||
class="input"
|
|
||||||
placeholder="Enter desired new password"
|
|
||||||
/>
|
|
||||||
</RevealPassword>
|
|
||||||
</div>
|
|
||||||
<div class="password-container">
|
|
||||||
<label for="verify_password_input" class="sr-only">
|
|
||||||
Repeat new password
|
|
||||||
</label>
|
|
||||||
<RevealPassword v-slot:default="slotProps">
|
|
||||||
<input
|
|
||||||
id="verify_password_input"
|
|
||||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
|
||||||
name="verify_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.settings.advanced">
|
|
||||||
<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 v-if="!$store.state.serverConfiguration.public" class="session-list">
|
|
||||||
<h2>Sessions</h2>
|
|
||||||
|
|
||||||
<h3>Current session</h3>
|
|
||||||
<Session
|
|
||||||
v-if="$store.getters.currentSession"
|
|
||||||
:session="$store.getters.currentSession"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h3>Other sessions</h3>
|
|
||||||
<p v-if="$store.state.sessions.length === 0">Loading…</p>
|
|
||||||
<p v-else-if="$store.getters.otherSessions.length === 0">
|
|
||||||
<em>You are not currently logged in to any other device.</em>
|
|
||||||
</p>
|
|
||||||
<Session
|
|
||||||
v-for="session in $store.getters.otherSessions"
|
|
||||||
v-else
|
|
||||||
:key="session.token"
|
|
||||||
:session="session"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import socket from "../../js/socket";
|
import {defineComponent} from "vue";
|
||||||
import webpush from "../../js/webpush";
|
|
||||||
import RevealPassword from "../RevealPassword.vue";
|
|
||||||
import Session from "../Session.vue";
|
|
||||||
import SidebarToggle from "../SidebarToggle.vue";
|
import SidebarToggle from "../SidebarToggle.vue";
|
||||||
|
import Navigation from "../Settings/Navigation.vue";
|
||||||
|
import {useStore} from "../../js/store";
|
||||||
|
|
||||||
let installPromptEvent = null;
|
export default defineComponent({
|
||||||
|
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
installPromptEvent = e;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
components: {
|
components: {
|
||||||
RevealPassword,
|
|
||||||
Session,
|
|
||||||
SidebarToggle,
|
SidebarToggle,
|
||||||
|
Navigation,
|
||||||
},
|
},
|
||||||
data() {
|
setup() {
|
||||||
return {
|
const store = useStore();
|
||||||
canRegisterProtocol: false,
|
|
||||||
passwordChangeStatus: null,
|
|
||||||
passwordErrors: {
|
|
||||||
missing_fields: "Please enter a new password",
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
isIOS: navigator.platform.match(/(iPhone|iPod|iPad)/i) || false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
hasInstallPromptEvent() {
|
|
||||||
// TODO: This doesn't hide the button after clicking
|
|
||||||
return installPromptEvent !== null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
socket.emit("sessions:get");
|
|
||||||
|
|
||||||
// Enable protocol handler registration if supported,
|
const onChange = (event: Event) => {
|
||||||
// and the network configuration is not locked
|
|
||||||
this.canRegisterProtocol =
|
|
||||||
window.navigator.registerProtocolHandler &&
|
|
||||||
!this.$store.state.serverConfiguration.lockNetwork;
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onChange(event) {
|
|
||||||
const ignore = ["old_password", "new_password", "verify_password"];
|
const ignore = ["old_password", "new_password", "verify_password"];
|
||||||
|
|
||||||
const name = event.target.name;
|
const name = (event.target as HTMLInputElement).name;
|
||||||
|
|
||||||
if (ignore.includes(name)) {
|
if (ignore.includes(name)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let value;
|
let value: boolean | string;
|
||||||
|
|
||||||
if (event.target.type === "checkbox") {
|
if ((event.target as HTMLInputElement).type === "checkbox") {
|
||||||
value = event.target.checked;
|
value = (event.target as HTMLInputElement).checked;
|
||||||
} else {
|
} else {
|
||||||
value = event.target.value;
|
value = (event.target as HTMLInputElement).value;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.dispatch("settings/update", {name, value, sync: true});
|
void store.dispatch("settings/update", {name, value, sync: true});
|
||||||
},
|
};
|
||||||
changePassword() {
|
|
||||||
const allFields = new FormData(this.$refs.settingsForm);
|
|
||||||
const data = {
|
|
||||||
old_password: allFields.get("old_password"),
|
|
||||||
new_password: allFields.get("new_password"),
|
|
||||||
verify_password: allFields.get("verify_password"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.old_password || !data.new_password || !data.verify_password) {
|
return {
|
||||||
this.passwordChangeStatus = {
|
onChange,
|
||||||
success: false,
|
};
|
||||||
error: "missing_fields",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.new_password !== data.verify_password) {
|
|
||||||
this.passwordChangeStatus = {
|
|
||||||
success: false,
|
|
||||||
error: "password_mismatch",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.once("change-password", (response) => {
|
|
||||||
this.passwordChangeStatus = response;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.emit("change-password", data);
|
|
||||||
},
|
|
||||||
onForceSyncClick() {
|
|
||||||
this.$store.dispatch("settings/syncAll", true);
|
|
||||||
this.$store.dispatch("settings/update", {
|
|
||||||
name: "syncSettings",
|
|
||||||
value: true,
|
|
||||||
sync: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
registerProtocol() {
|
|
||||||
const uri = document.location.origin + document.location.pathname + "?uri=%s";
|
|
||||||
|
|
||||||
window.navigator.registerProtocolHandler("irc", uri, "The Lounge");
|
|
||||||
window.navigator.registerProtocolHandler("ircs", uri, "The Lounge");
|
|
||||||
},
|
|
||||||
nativeInstallPrompt() {
|
|
||||||
installPromptEvent.prompt();
|
|
||||||
installPromptEvent = null;
|
|
||||||
},
|
|
||||||
playNotification() {
|
|
||||||
const pop = new Audio();
|
|
||||||
pop.src = "audio/pop.wav";
|
|
||||||
pop.play();
|
|
||||||
},
|
|
||||||
onPushButtonClick() {
|
|
||||||
webpush.togglePushSubscription();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -19,14 +19,13 @@
|
||||||
<label for="signin-username">Username</label>
|
<label for="signin-username">Username</label>
|
||||||
<input
|
<input
|
||||||
id="signin-username"
|
id="signin-username"
|
||||||
ref="username"
|
v-model="username"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
:value="getStoredUser()"
|
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
@ -36,9 +35,8 @@
|
||||||
<RevealPassword v-slot:default="slotProps">
|
<RevealPassword v-slot:default="slotProps">
|
||||||
<input
|
<input
|
||||||
id="signin-password"
|
id="signin-password"
|
||||||
ref="password"
|
v-model="password"
|
||||||
:type="slotProps.isVisible ? 'text' : 'password'"
|
:type="slotProps.isVisible ? 'text' : 'password'"
|
||||||
name="password"
|
|
||||||
class="input"
|
class="input"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
|
@ -55,51 +53,64 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import storage from "../../js/localStorage";
|
import storage from "../../js/localStorage";
|
||||||
import socket from "../../js/socket";
|
import socket from "../../js/socket";
|
||||||
import RevealPassword from "../RevealPassword.vue";
|
import RevealPassword from "../RevealPassword.vue";
|
||||||
|
import {defineComponent, onBeforeUnmount, onMounted, ref} from "vue";
|
||||||
|
|
||||||
export default {
|
export default defineComponent({
|
||||||
name: "SignIn",
|
name: "SignIn",
|
||||||
components: {
|
components: {
|
||||||
RevealPassword,
|
RevealPassword,
|
||||||
},
|
},
|
||||||
data() {
|
setup() {
|
||||||
return {
|
const inFlight = ref(false);
|
||||||
inFlight: false,
|
const errorShown = ref(false);
|
||||||
errorShown: false,
|
|
||||||
|
const username = ref(storage.get("user") || "");
|
||||||
|
const password = ref("");
|
||||||
|
|
||||||
|
const onAuthFailed = () => {
|
||||||
|
inFlight.value = false;
|
||||||
|
errorShown.value = true;
|
||||||
};
|
};
|
||||||
},
|
|
||||||
mounted() {
|
const onSubmit = (event: Event) => {
|
||||||
socket.on("auth:failed", this.onAuthFailed);
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
socket.off("auth:failed", this.onAuthFailed);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onAuthFailed() {
|
|
||||||
this.inFlight = false;
|
|
||||||
this.errorShown = true;
|
|
||||||
},
|
|
||||||
onSubmit(event) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
this.inFlight = true;
|
if (!username.value || !password.value) {
|
||||||
this.errorShown = false;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight.value = true;
|
||||||
|
errorShown.value = false;
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
user: this.$refs.username.value,
|
user: username.value,
|
||||||
password: this.$refs.password.value,
|
password: password.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.set("user", values.user);
|
storage.set("user", values.user);
|
||||||
|
|
||||||
socket.emit("auth:perform", values);
|
socket.emit("auth:perform", values);
|
||||||
},
|
};
|
||||||
getStoredUser() {
|
|
||||||
return storage.get("user");
|
onMounted(() => {
|
||||||
},
|
socket.on("auth:failed", onAuthFailed);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
socket.off("auth:failed", onAuthFailed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
inFlight,
|
||||||
|
errorShown,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
onSubmit,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
2
client/css/fontawesome.css
vendored
2
client/css/fontawesome.css
vendored
|
@ -1,6 +1,6 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
/* We use free solid icons - https://fontawesome.com/icons?s=solid&m=free */
|
/* We use free solid icons - https://fontawesome.com/icons?s=solid&m=free */
|
||||||
font-family: "FontAwesome";
|
font-family: FontAwesome;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
src:
|
src:
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
--button-text-color-hover: #fff;
|
--button-text-color-hover: #fff;
|
||||||
|
|
||||||
/* Color for sidebar overlay and other things that dim the viewport when something else is on top */
|
/* Color for sidebar overlay and other things that dim the viewport when something else is on top */
|
||||||
--overlay-bg-color: rgba(0, 0, 0, 0.5);
|
--overlay-bg-color: rgb(0 0 0 / 50%);
|
||||||
|
|
||||||
/* Links and link-looking buttons */
|
/* Links and link-looking buttons */
|
||||||
--link-color: #50a656;
|
--link-color: #50a656;
|
||||||
|
@ -29,10 +29,10 @@
|
||||||
--window-heading-color: #6c797a;
|
--window-heading-color: #6c797a;
|
||||||
|
|
||||||
/* Color of the date marker, text and separator */
|
/* Color of the date marker, text and separator */
|
||||||
--date-marker-color: rgba(0, 107, 59, 0.5);
|
--date-marker-color: rgb(0 107 59 / 50%);
|
||||||
|
|
||||||
/* Color of the unread message marker, text and separator */
|
/* Color of the unread message marker, text and separator */
|
||||||
--unread-marker-color: rgba(231, 76, 60, 0.5);
|
--unread-marker-color: rgb(231 76 60 / 50%);
|
||||||
|
|
||||||
/* Background and left-border color of highlight messages */
|
/* Background and left-border color of highlight messages */
|
||||||
--highlight-bg-color: #efe8dc;
|
--highlight-bg-color: #efe8dc;
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::placeholder {
|
::placeholder {
|
||||||
color: rgba(0, 0, 0, 0.35);
|
color: rgb(0 0 0 / 35%);
|
||||||
opacity: 1; /* fix opacity in Firefox */
|
opacity: 1; /* fix opacity in Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +104,11 @@ body {
|
||||||
* Disable pull-to-refresh on mobile that conflicts with scrolling the message list.
|
* Disable pull-to-refresh on mobile that conflicts with scrolling the message list.
|
||||||
* See http://stackoverflow.com/a/29313685/1935861
|
* See http://stackoverflow.com/a/29313685/1935861
|
||||||
*/
|
*/
|
||||||
overflow-y: hidden;
|
overflow: hidden; /* iOS Safari requires overflow rather than overflow-y */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.force-no-select * {
|
||||||
|
user-select: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
|
@ -144,7 +148,7 @@ button {
|
||||||
|
|
||||||
code,
|
code,
|
||||||
pre,
|
pre,
|
||||||
#chat .msg[data-type="motd"] .text,
|
#chat .msg[data-type="monospace_block"] .text,
|
||||||
.irc-monospace,
|
.irc-monospace,
|
||||||
textarea#user-specified-css-input {
|
textarea#user-specified-css-input {
|
||||||
font-family: Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
|
font-family: Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace;
|
||||||
|
@ -164,7 +168,7 @@ pre {
|
||||||
padding: 9.5px;
|
padding: 9.5px;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.42857143;
|
line-height: 1.4286;
|
||||||
color: #333;
|
color: #333;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
@ -183,7 +187,7 @@ kbd {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-shadow: 0 1px 0 #fff;
|
text-shadow: 0 1px 0 #fff;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent);
|
background-image: linear-gradient(180deg, rgb(0 0 0 / 5%), transparent);
|
||||||
border: 1px solid #bbb;
|
border: 1px solid #bbb;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 2px 0 #bbb, inset 0 1px 1px #fff, inset 0 -1px 3px #ccc;
|
box-shadow: 0 2px 0 #bbb, inset 0 1px 1px #fff, inset 0 -1px 3px #ccc;
|
||||||
|
@ -225,7 +229,7 @@ p {
|
||||||
.btn:active,
|
.btn:active,
|
||||||
.btn:focus {
|
.btn:focus {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
box-shadow: 0 0 0 3px rgba(132, 206, 136, 0.5);
|
box-shadow: 0 0 0 3px rgb(132 206 136 / 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
.btn:active {
|
||||||
|
@ -276,19 +280,25 @@ p {
|
||||||
.only-copy {
|
.only-copy {
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 0;
|
width: 0.01px; /* Must be non-zero to be the first selected character on Firefox */
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icons */
|
/* Icons */
|
||||||
|
|
||||||
#viewport .lt::before,
|
#viewport .lt::before,
|
||||||
#viewport .rt::before,
|
#viewport .rt::before,
|
||||||
|
#chat button.mentions::before,
|
||||||
|
#chat button.close::before,
|
||||||
#chat button.menu::before,
|
#chat button.menu::before,
|
||||||
|
#chat button.search::before,
|
||||||
.channel-list-item::before,
|
.channel-list-item::before,
|
||||||
#footer .icon,
|
#footer .icon,
|
||||||
#chat .count::before,
|
#chat .count::before,
|
||||||
|
#connect .extra-help,
|
||||||
#settings .extra-help,
|
#settings .extra-help,
|
||||||
#settings #play::before,
|
#settings #play::before,
|
||||||
|
#settings .settings-menu .icon::before,
|
||||||
#form #upload::before,
|
#form #upload::before,
|
||||||
#form #submit::before,
|
#form #submit::before,
|
||||||
#chat .msg[data-type="away"] .from::before,
|
#chat .msg[data-type="away"] .from::before,
|
||||||
|
@ -296,12 +306,17 @@ p {
|
||||||
#chat .msg[data-type="invite"] .from::before,
|
#chat .msg[data-type="invite"] .from::before,
|
||||||
#chat .msg[data-type="join"] .from::before,
|
#chat .msg[data-type="join"] .from::before,
|
||||||
#chat .msg[data-type="kick"] .from::before,
|
#chat .msg[data-type="kick"] .from::before,
|
||||||
|
#chat .msg[data-type="login"] .from::before,
|
||||||
|
#chat .msg[data-type="logout"] .from::before,
|
||||||
#chat .msg[data-type="part"] .from::before,
|
#chat .msg[data-type="part"] .from::before,
|
||||||
#chat .msg[data-type="quit"] .from::before,
|
#chat .msg[data-type="quit"] .from::before,
|
||||||
#chat .msg[data-type="topic"] .from::before,
|
#chat .msg[data-type="topic"] .from::before,
|
||||||
#chat .msg[data-type="mode_channel"] .from::before,
|
#chat .msg[data-type="mode_channel"] .from::before,
|
||||||
|
#chat .msg[data-type="mode_user"] .from::before,
|
||||||
#chat .msg[data-type="mode"] .from::before,
|
#chat .msg[data-type="mode"] .from::before,
|
||||||
#chat .msg[data-type="motd"] .from::before,
|
#chat .msg[data-command="motd"] .from::before,
|
||||||
|
#chat .msg[data-command="help"] .from::before,
|
||||||
|
#chat .msg[data-command="info"] .from::before,
|
||||||
#chat .msg[data-type="ctcp"] .from::before,
|
#chat .msg[data-type="ctcp"] .from::before,
|
||||||
#chat .msg[data-type="ctcp_request"] .from::before,
|
#chat .msg[data-type="ctcp_request"] .from::before,
|
||||||
#chat .msg[data-type="whois"] .from::before,
|
#chat .msg[data-type="whois"] .from::before,
|
||||||
|
@ -309,6 +324,7 @@ p {
|
||||||
#chat .msg[data-type="action"] .from::before,
|
#chat .msg[data-type="action"] .from::before,
|
||||||
#chat .msg[data-type="plugin"] .from::before,
|
#chat .msg[data-type="plugin"] .from::before,
|
||||||
#chat .msg[data-type="raw"] .from::before,
|
#chat .msg[data-type="raw"] .from::before,
|
||||||
|
#chat .msg-statusmsg span::before,
|
||||||
#chat .msg-shown-in-active span::before,
|
#chat .msg-shown-in-active span::before,
|
||||||
#chat .toggle-button::after,
|
#chat .toggle-button::after,
|
||||||
#chat .toggle-content .more-caret::before,
|
#chat .toggle-content .more-caret::before,
|
||||||
|
@ -326,6 +342,7 @@ p {
|
||||||
.channel-list-item .not-connected-icon::before,
|
.channel-list-item .not-connected-icon::before,
|
||||||
.channel-list-item .parted-channel-icon::before,
|
.channel-list-item .parted-channel-icon::before,
|
||||||
.jump-to-input::before,
|
.jump-to-input::before,
|
||||||
|
.password-container .reveal-password span,
|
||||||
#sidebar .collapse-network-icon::before {
|
#sidebar .collapse-network-icon::before {
|
||||||
font: normal normal normal 14px/1 FontAwesome;
|
font: normal normal normal 14px/1 FontAwesome;
|
||||||
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
|
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
|
||||||
|
@ -336,6 +353,9 @@ p {
|
||||||
#viewport .lt::before { content: "\f0c9"; /* http://fontawesome.io/icon/bars/ */ }
|
#viewport .lt::before { content: "\f0c9"; /* http://fontawesome.io/icon/bars/ */ }
|
||||||
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ }
|
#viewport .rt::before { content: "\f0c0"; /* https://fontawesome.com/icons/users?style=solid */ }
|
||||||
#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ }
|
#chat button.menu::before { content: "\f142"; /* http://fontawesome.io/icon/ellipsis-v/ */ }
|
||||||
|
#chat button.mentions::before { content: "\f1fa"; /* https://fontawesome.com/icons/at?style=solid */ }
|
||||||
|
#chat button.search::before { content: "\f002"; /* https://fontawesome.com/icons/search?style=solid */ }
|
||||||
|
#chat button.close::before { content: "\f00d"; /* https://fontawesome.com/icons/times?style=solid */ }
|
||||||
|
|
||||||
.context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
.context-menu-join::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||||
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
|
.context-menu-user::before { content: "\f007"; /* http://fontawesome.io/icon/user/ */ }
|
||||||
|
@ -344,11 +364,14 @@ p {
|
||||||
.context-menu-disconnect::before { content: "\f127"; /* https://fontawesome.com/icons/unlink?style=solid */ }
|
.context-menu-disconnect::before { content: "\f127"; /* https://fontawesome.com/icons/unlink?style=solid */ }
|
||||||
.context-menu-connect::before { content: "\f0c1"; /* https://fontawesome.com/icons/link?style=solid */ }
|
.context-menu-connect::before { content: "\f0c1"; /* https://fontawesome.com/icons/link?style=solid */ }
|
||||||
.context-menu-action-whois::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ }
|
.context-menu-action-whois::before { content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */ }
|
||||||
|
.context-menu-action-ignore::before { content: "\f506"; /* https://fontawesome.com/icons/user-slash?style=solid */ }
|
||||||
.context-menu-action-kick::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ }
|
.context-menu-action-kick::before { content: "\f05e"; /* http://fontawesome.io/icon/ban/ */ }
|
||||||
.context-menu-action-op::before { content: "\f1fa"; /* http://fontawesome.io/icon/at/ */ }
|
.context-menu-action-set-mode::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||||
.context-menu-action-voice::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
.context-menu-action-revoke-mode::before { content: "\f068"; /* http://fontawesome.io/icon/minus/ */ }
|
||||||
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
|
.context-menu-network::before { content: "\f233"; /* https://fontawesome.com/icons/server?style=solid */ }
|
||||||
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
|
.context-menu-edit::before { content: "\f303"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }
|
||||||
|
.context-menu-clear-history::before { content: "\f1f8"; /* https://fontawesome.com/icons/trash?style=solid */ }
|
||||||
|
.context-menu-mute::before { content: "\f6a9"; /* https://fontawesome.com/v5.15/icons/volume-mute?style=solid */ }
|
||||||
|
|
||||||
.channel-list-item .not-secure-icon::before {
|
.channel-list-item .not-secure-icon::before {
|
||||||
content: "\f071"; /* https://fontawesome.com/icons/exclamation-triangle?style=solid */
|
content: "\f071"; /* https://fontawesome.com/icons/exclamation-triangle?style=solid */
|
||||||
|
@ -361,17 +384,23 @@ p {
|
||||||
|
|
||||||
.context-menu-query::before,
|
.context-menu-query::before,
|
||||||
.context-menu-action-query::before,
|
.context-menu-action-query::before,
|
||||||
.channel-list-item[data-type="query"]::before { content: "\f075"; /* https://fontawesome.com/icons/comment?style=solid */ }
|
.channel-list-item[data-type="query"]::before {
|
||||||
|
content: "\f075"; /* https://fontawesome.com/icons/comment?style=solid */
|
||||||
|
}
|
||||||
|
|
||||||
.context-menu-chan::before,
|
.context-menu-chan::before,
|
||||||
.channel-list-item[data-type="channel"]::before { content: "\f086"; /* http://fontawesome.io/icon/comments/ */ }
|
.channel-list-item[data-type="channel"]::before { content: "\f086"; /* http://fontawesome.io/icon/comments/ */ }
|
||||||
|
|
||||||
.channel-list-item[data-type="special"]::before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ }
|
.channel-list-item[data-type="special"]::before { content: "\f03a"; /* http://fontawesome.io/icon/list/ */ }
|
||||||
|
|
||||||
.channel-list-item.has-draft:not(.active):not([data-type="lobby"])::before { content: "\f304"; /* https://fontawesome.com/icons/pen?style=solid */ }
|
.channel-list-item.has-draft:not(.active):not([data-type="lobby"])::before {
|
||||||
|
content: "\f304"; /* https://fontawesome.com/icons/pen?style=solid */
|
||||||
|
}
|
||||||
|
|
||||||
#footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
#footer .connect::before { content: "\f067"; /* http://fontawesome.io/icon/plus/ */ }
|
||||||
|
|
||||||
#footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ }
|
#footer .settings::before { content: "\f013"; /* http://fontawesome.io/icon/cog/ */ }
|
||||||
|
|
||||||
#footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ }
|
#footer .help::before { content: "\f059"; /* http://fontawesome.io/icon/question/ */ }
|
||||||
|
|
||||||
#form #upload::before { content: "\f0c6"; /* https://fontawesome.com/icons/paperclip?style=solid */ }
|
#form #upload::before { content: "\f0c6"; /* https://fontawesome.com/icons/paperclip?style=solid */ }
|
||||||
|
@ -398,26 +427,21 @@ p {
|
||||||
#help .documentation-link::before { content: "\f19d"; /* http://fontawesome.io/icon/graduation-cap/ */ }
|
#help .documentation-link::before { content: "\f19d"; /* http://fontawesome.io/icon/graduation-cap/ */ }
|
||||||
#help .report-issue-link::before { content: "\f188"; /* http://fontawesome.io/icon/bug/ */ }
|
#help .report-issue-link::before { content: "\f188"; /* http://fontawesome.io/icon/bug/ */ }
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat .msg[data-type="invite"] .from::before {
|
#chat .msg[data-type="invite"] .from::before {
|
||||||
content: "\f0e0"; /* https://fontawesome.com/icons/envelope?style=solid */
|
content: "\f0e0"; /* https://fontawesome.com/icons/envelope?style=solid */
|
||||||
color: #2ecc40;
|
color: #2ecc40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat .msg[data-type="login"] .from::before {
|
||||||
|
content: "\f007"; /* https://fontawesome.com/icons/user?style=solid */
|
||||||
|
color: #2ecc40;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat .msg[data-type="logout"] .from::before {
|
||||||
|
content: "\f007"; /* https://fontawesome.com/icons/user?style=solid */
|
||||||
|
color: #ff4136;
|
||||||
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="part"] .from::before,
|
#chat .msg[data-type="part"] .from::before,
|
||||||
#chat .msg[data-type="quit"] .from::before {
|
#chat .msg[data-type="quit"] .from::before {
|
||||||
content: "\f2f5"; /* https://fontawesome.com/icons/sign-out-alt?style=solid */
|
content: "\f2f5"; /* https://fontawesome.com/icons/sign-out-alt?style=solid */
|
||||||
|
@ -432,16 +456,27 @@ p {
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="mode_channel"] .from::before,
|
#chat .msg[data-type="mode_channel"] .from::before,
|
||||||
|
#chat .msg[data-type="mode_user"] .from::before,
|
||||||
#chat .msg[data-type="mode"] .from::before {
|
#chat .msg[data-type="mode"] .from::before {
|
||||||
content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */
|
content: "\f05a"; /* http://fontawesome.io/icon/info-circle/ */
|
||||||
color: #2ecc40;
|
color: #2ecc40;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="motd"] .from::before {
|
#chat .msg[data-command="motd"] .from::before {
|
||||||
content: "\f02e"; /* https://fontawesome.com/icons/bookmark?style=solid */
|
content: "\f02e"; /* https://fontawesome.com/icons/bookmark?style=solid */
|
||||||
color: var(--body-color-muted);
|
color: var(--body-color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat .msg[data-command="help"] .from::before {
|
||||||
|
content: "\f059"; /* https://fontawesome.com/icons/question-circle?style=solid */
|
||||||
|
color: var(--body-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat .msg[data-command="info"] .from::before {
|
||||||
|
content: "\f05a"; /* https://fontawesome.com/icons/info-circle?style=solid */
|
||||||
|
color: var(--body-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="ctcp"] .from::before,
|
#chat .msg[data-type="ctcp"] .from::before,
|
||||||
#chat .msg[data-type="ctcp_request"] .from::before {
|
#chat .msg[data-type="ctcp_request"] .from::before {
|
||||||
content: "\f15c"; /* https://fontawesome.com/icons/file-alt?style=solid */
|
content: "\f15c"; /* https://fontawesome.com/icons/file-alt?style=solid */
|
||||||
|
@ -488,16 +523,25 @@ p {
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat .msg-statusmsg,
|
||||||
#chat .msg-shown-in-active {
|
#chat .msg-shown-in-active {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat .msg-statusmsg span::before,
|
||||||
#chat .msg-shown-in-active span::before {
|
#chat .msg-shown-in-active span::before {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
content: "\f06e"; /* https://fontawesome.com/icons/eye?style=solid */
|
content: "\f06e"; /* https://fontawesome.com/icons/eye?style=solid */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat .msg-statusmsg {
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
background-color: #ff9e18;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
#chat .toggle-button {
|
#chat .toggle-button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
@ -519,6 +563,7 @@ p {
|
||||||
line-height: 45px;
|
line-height: 45px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#connect .extra-help::before,
|
||||||
#settings .extra-help::before {
|
#settings .extra-help::before {
|
||||||
content: "\f059"; /* http://fontawesome.io/icon/question-circle/ */
|
content: "\f059"; /* http://fontawesome.io/icon/question-circle/ */
|
||||||
}
|
}
|
||||||
|
@ -542,6 +587,11 @@ p {
|
||||||
|
|
||||||
/* End icons */
|
/* End icons */
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#viewport {
|
#viewport {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -561,20 +611,25 @@ p {
|
||||||
|
|
||||||
#viewport .lt,
|
#viewport .lt,
|
||||||
#viewport .rt,
|
#viewport .rt,
|
||||||
#chat button.menu {
|
#chat button.mentions,
|
||||||
|
#chat button.search,
|
||||||
|
#chat button.menu,
|
||||||
|
#chat button.close {
|
||||||
color: #607992;
|
color: #607992;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
margin-top: 6px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewport .lt::before,
|
#viewport .lt::before,
|
||||||
#viewport .rt::before,
|
#viewport .rt::before,
|
||||||
#chat button.menu::before {
|
#chat button.mentions::before,
|
||||||
|
#chat button.search::before,
|
||||||
|
#chat button.menu::before,
|
||||||
|
#chat button.close::before {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
line-height: 36px; /* Fix alignment in Microsoft Edge */
|
line-height: 36px; /* Fix alignment in Microsoft Edge */
|
||||||
}
|
}
|
||||||
|
@ -672,10 +727,10 @@ p {
|
||||||
background on hover (unless active) */
|
background on hover (unless active) */
|
||||||
.channel-list-item:hover,
|
.channel-list-item:hover,
|
||||||
#footer button:hover {
|
#footer button:hover {
|
||||||
background-color: rgba(48, 62, 74, 0.5); /* #303e4a x 50% alpha */
|
background-color: rgb(48 62 74 / 50%); /* #303e4a x 50% alpha */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Darker background and defualt cursor for active channels */
|
/* Darker background and default cursor for active channels */
|
||||||
#footer button.active,
|
#footer button.active,
|
||||||
.channel-list-item.active {
|
.channel-list-item.active {
|
||||||
background-color: #303e4a;
|
background-color: #303e4a;
|
||||||
|
@ -684,14 +739,19 @@ background on hover (unless active) */
|
||||||
|
|
||||||
/* Remove background on hovered/active channel when sorting/drag-and-dropping */
|
/* Remove background on hovered/active channel when sorting/drag-and-dropping */
|
||||||
.ui-sortable-ghost,
|
.ui-sortable-ghost,
|
||||||
.channel-list-item.ui-sortable-dragged,
|
.ui-sortable-dragging .channel-list-item,
|
||||||
.ui-sortable-dragged .channel-list-item,
|
.ui-sortable-dragging,
|
||||||
.ui-sortable-active .channel-list-item:hover,
|
.ui-sortable-dragging:hover,
|
||||||
.ui-sortable-active .channel-list-item.active {
|
.ui-sortable-dragging.active,
|
||||||
|
.ui-sortable-dragging-touch-cue .channel-list-item,
|
||||||
|
.ui-sortable-dragging-touch-cue,
|
||||||
|
.ui-sortable-dragging-touch-cue:hover,
|
||||||
|
.ui-sortable-dragging-touch-cue.active {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-sortable-ghost::after {
|
.ui-sortable-ghost::after,
|
||||||
|
.ui-sortable-dragging-touch-cue:not(.ui-sortable-dragging)::after {
|
||||||
background: var(--body-bg-color);
|
background: var(--body-bg-color);
|
||||||
border: 1px dashed #99a2b4;
|
border: 1px dashed #99a2b4;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
@ -704,6 +764,10 @@ background on hover (unless active) */
|
||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-sortable-dragging-touch-cue:not(.ui-sortable-ghost)::after {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
#sidebar .network {
|
#sidebar .network {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
@ -762,6 +826,10 @@ background on hover (unless active) */
|
||||||
color: #f1978e;
|
color: #f1978e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-list-item.is-muted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-list-item::before {
|
.channel-list-item::before {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
|
@ -789,7 +857,7 @@ background on hover (unless active) */
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-list-item .badge {
|
.channel-list-item .badge {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgb(255 255 255 / 6%);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #afb6c0;
|
color: #afb6c0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
@ -948,7 +1016,6 @@ background on hover (unless active) */
|
||||||
|
|
||||||
textarea.input {
|
textarea.input {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
height: 100px;
|
|
||||||
min-height: 35px;
|
min-height: 35px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
@ -982,7 +1049,7 @@ textarea.input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.window h2 {
|
.window h2 {
|
||||||
border-bottom: 1px solid currentColor;
|
border-bottom: 1px solid currentcolor;
|
||||||
color: var(--window-heading-color);
|
color: var(--window-heading-color);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
margin: 30px 0 10px;
|
margin: 30px 0 10px;
|
||||||
|
@ -1001,6 +1068,7 @@ textarea.input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
align-items: center;
|
||||||
line-height: 45px;
|
line-height: 45px;
|
||||||
height: 45px;
|
height: 45px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
|
@ -1016,7 +1084,10 @@ textarea.input {
|
||||||
.header .title {
|
.header .title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-container {
|
.topic-container {
|
||||||
|
@ -1032,6 +1103,12 @@ textarea.input {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
flex-shrink: 99999999;
|
||||||
|
min-width: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .topic.empty {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header .topic-input {
|
.header .topic-input {
|
||||||
|
@ -1045,6 +1122,7 @@ textarea.input {
|
||||||
height: 35px;
|
height: 35px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: normal;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1125,6 +1203,7 @@ textarea.input {
|
||||||
|
|
||||||
#chat .chat-content {
|
#chat .chat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -1160,10 +1239,7 @@ textarea.input {
|
||||||
|
|
||||||
#sidebar .join-form .input {
|
#sidebar .join-form .input {
|
||||||
display: block;
|
display: block;
|
||||||
margin-left: auto;
|
margin: 5px auto;
|
||||||
margin-right: auto;
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .join-form .btn {
|
#sidebar .join-form .btn {
|
||||||
|
@ -1212,7 +1288,7 @@ textarea.input {
|
||||||
border: 2px solid var(--button-color);
|
border: 2px solid var(--button-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.15);
|
box-shadow: 0 6px 10px 0 rgb(0 0 0 / 15%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-down:hover .scroll-down-arrow {
|
.scroll-down:hover .scroll-down-arrow {
|
||||||
|
@ -1309,12 +1385,18 @@ textarea.input {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
width: 55px;
|
width: 55px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
box-sizing: content-box; /* highlights have a border-left */
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat.show-seconds .time {
|
#chat.time-12h .time,
|
||||||
|
#chat.time-seconds .time {
|
||||||
width: 75px;
|
width: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat.time-seconds.time-12h .time {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
#chat .from {
|
#chat .from {
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
@ -1356,42 +1438,42 @@ textarea.input {
|
||||||
|
|
||||||
/* Nicknames */
|
/* Nicknames */
|
||||||
|
|
||||||
#chat .user {
|
.user {
|
||||||
color: #50a656;
|
color: #50a656;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat.colored-nicks .user.color-1 { color: #107ead; }
|
.user.color-1 { color: #107ead; }
|
||||||
#chat.colored-nicks .user.color-2 { color: #a86500; }
|
.user.color-2 { color: #a86500; }
|
||||||
#chat.colored-nicks .user.color-3 { color: #008a3c; }
|
.user.color-3 { color: #008a3c; }
|
||||||
#chat.colored-nicks .user.color-4 { color: #e00096; }
|
.user.color-4 { color: #e00096; }
|
||||||
#chat.colored-nicks .user.color-5 { color: #f0000c; }
|
.user.color-5 { color: #f0000c; }
|
||||||
#chat.colored-nicks .user.color-6 { color: #000094; }
|
.user.color-6 { color: #000094; }
|
||||||
#chat.colored-nicks .user.color-7 { color: #006441; }
|
.user.color-7 { color: #006441; }
|
||||||
#chat.colored-nicks .user.color-8 { color: #00566e; }
|
.user.color-8 { color: #00566e; }
|
||||||
#chat.colored-nicks .user.color-9 { color: #e6006b; }
|
.user.color-9 { color: #e6006b; }
|
||||||
#chat.colored-nicks .user.color-10 { color: #0d8766; }
|
.user.color-10 { color: #0d8766; }
|
||||||
#chat.colored-nicks .user.color-11 { color: #006b3b; }
|
.user.color-11 { color: #006b3b; }
|
||||||
#chat.colored-nicks .user.color-12 { color: #00857e; }
|
.user.color-12 { color: #00857e; }
|
||||||
#chat.colored-nicks .user.color-13 { color: #00465b; }
|
.user.color-13 { color: #00465b; }
|
||||||
#chat.colored-nicks .user.color-14 { color: #eb005a; }
|
.user.color-14 { color: #eb005a; }
|
||||||
#chat.colored-nicks .user.color-15 { color: #e62600; }
|
.user.color-15 { color: #e62600; }
|
||||||
#chat.colored-nicks .user.color-16 { color: #0f8546; }
|
.user.color-16 { color: #0f8546; }
|
||||||
#chat.colored-nicks .user.color-17 { color: #e60067; }
|
.user.color-17 { color: #e60067; }
|
||||||
#chat.colored-nicks .user.color-18 { color: #eb002b; }
|
.user.color-18 { color: #eb002b; }
|
||||||
#chat.colored-nicks .user.color-19 { color: #eb003f; }
|
.user.color-19 { color: #eb003f; }
|
||||||
#chat.colored-nicks .user.color-20 { color: #007a56; }
|
.user.color-20 { color: #007a56; }
|
||||||
#chat.colored-nicks .user.color-21 { color: #095092; }
|
.user.color-21 { color: #095092; }
|
||||||
#chat.colored-nicks .user.color-22 { color: #000bde; }
|
.user.color-22 { color: #000bde; }
|
||||||
#chat.colored-nicks .user.color-23 { color: #008577; }
|
.user.color-23 { color: #008577; }
|
||||||
#chat.colored-nicks .user.color-24 { color: #00367d; }
|
.user.color-24 { color: #00367d; }
|
||||||
#chat.colored-nicks .user.color-25 { color: #007e9e; }
|
.user.color-25 { color: #007e9e; }
|
||||||
#chat.colored-nicks .user.color-26 { color: #006119; }
|
.user.color-26 { color: #006119; }
|
||||||
#chat.colored-nicks .user.color-27 { color: #007ea8; }
|
.user.color-27 { color: #007ea8; }
|
||||||
#chat.colored-nicks .user.color-28 { color: #3c8500; }
|
.user.color-28 { color: #3c8500; }
|
||||||
#chat.colored-nicks .user.color-29 { color: #e6007e; }
|
.user.color-29 { color: #e6007e; }
|
||||||
#chat.colored-nicks .user.color-30 { color: #c75300; }
|
.user.color-30 { color: #c75300; }
|
||||||
#chat.colored-nicks .user.color-31 { color: #eb0400; }
|
.user.color-31 { color: #eb0400; }
|
||||||
#chat.colored-nicks .user.color-32 { color: #e60082; }
|
.user.color-32 { color: #e60082; }
|
||||||
|
|
||||||
#chat .self .content {
|
#chat .self .content {
|
||||||
color: var(--body-color-muted);
|
color: var(--body-color-muted);
|
||||||
|
@ -1447,11 +1529,11 @@ textarea.input {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat.hide-motd .msg[data-type="motd"] {
|
#chat.hide-motd .msg[data-command="motd"] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="motd"] .text {
|
#chat .msg[data-type="monospace_block"] .text {
|
||||||
background: #f6f6f6;
|
background: #f6f6f6;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
@ -1479,8 +1561,11 @@ textarea.input {
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="notice"] .time,
|
#chat .msg[data-type="notice"] .time,
|
||||||
|
#chat .msg[data-type="wallops"] .time,
|
||||||
#chat .msg[data-type="notice"] .content,
|
#chat .msg[data-type="notice"] .content,
|
||||||
#chat .msg[data-type="notice"] .user {
|
#chat .msg[data-type="wallops"] .content,
|
||||||
|
#chat .msg[data-type="notice"] .user,
|
||||||
|
#chat .msg[data-type="wallops"] .user {
|
||||||
color: #0074d9;
|
color: #0074d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1488,6 +1573,10 @@ textarea.input {
|
||||||
content: "Notice: ";
|
content: "Notice: ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat .msg[data-type="wallops"] .from .user::before {
|
||||||
|
content: "Wallops: ";
|
||||||
|
}
|
||||||
|
|
||||||
#chat .msg[data-type="error"],
|
#chat .msg[data-type="error"],
|
||||||
#chat .msg[data-type="error"] .from {
|
#chat .msg[data-type="error"] .from {
|
||||||
color: #e74c3c;
|
color: #e74c3c;
|
||||||
|
@ -1500,14 +1589,9 @@ textarea.input {
|
||||||
|
|
||||||
#chat .chat-view[data-type="channel"] .msg.highlight .time {
|
#chat .chat-view[data-type="channel"] .msg.highlight .time {
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
width: 50px;
|
|
||||||
color: #696969;
|
color: #696969;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat.show-seconds .chat-view[data-type="channel"] .msg.highlight .time {
|
|
||||||
width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat .chat-view[data-type="channel"] .msg.highlight .content {
|
#chat .chat-view[data-type="channel"] .msg.highlight .content {
|
||||||
border-left: 1px solid var(--highlight-bg-color);
|
border-left: 1px solid var(--highlight-bg-color);
|
||||||
}
|
}
|
||||||
|
@ -1534,7 +1618,7 @@ textarea.input {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 1px 3px rgb(0 0 0 / 20%);
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
@ -1658,6 +1742,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
|
|
||||||
#chat .userlist .search {
|
#chat .userlist .search {
|
||||||
color: var(--body-color);
|
color: var(--body-color);
|
||||||
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
@ -1816,30 +1901,33 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect .tls input {
|
#connect .tls input,
|
||||||
|
#connect input[name="proxyEnabled"] {
|
||||||
margin: 3px 10px 0 0;
|
margin: 3px 10px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect\:host {
|
#connect\:host,
|
||||||
|
#connect\:proxyHost {
|
||||||
width: 70%;
|
width: 70%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect\:port {
|
#connect\:port,
|
||||||
|
#connect\:proxyPort {
|
||||||
width: 25%;
|
width: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect\:portseparator {
|
#connect\:portseparator,
|
||||||
|
#connect\:proxyPortSeparator {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect .btn {
|
#connect .btn {
|
||||||
margin-left: 25%;
|
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings .apple-push-unsupported,
|
|
||||||
#settings .settings-sync-panel {
|
#settings .settings-sync-panel {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
@ -1866,12 +1954,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
|
|
||||||
#settings .settings-sync-panel .btn:active,
|
#settings .settings-sync-panel .btn:active,
|
||||||
#settings .settings-sync-panel .btn:focus {
|
#settings .settings-sync-panel .btn:focus {
|
||||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.5);
|
box-shadow: 0 0 0 3px rgb(0 123 255 / 50%);
|
||||||
}
|
|
||||||
|
|
||||||
#settings .apple-push-unsupported a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings .opt {
|
#settings .opt {
|
||||||
|
@ -1883,6 +1966,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#connect .extra-help,
|
||||||
#settings .extra-help {
|
#settings .extra-help {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
@ -1943,7 +2027,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-container .reveal-password span {
|
.password-container .reveal-password span {
|
||||||
font: normal normal normal 14px/1 FontAwesome;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #607992;
|
color: #607992;
|
||||||
width: 35px;
|
width: 35px;
|
||||||
|
@ -1988,6 +2071,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
padding-right: 15px;
|
padding-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#help .help-item .subject.gesture {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
#help .help-item .description p {
|
#help .help-item .description p {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -2017,12 +2104,20 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
|
|
||||||
.window#changelog h3 {
|
.window#changelog h3 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
border-bottom: 1px solid currentColor;
|
border-bottom: 1px solid currentcolor;
|
||||||
color: var(--window-heading-color);
|
color: var(--window-heading-color);
|
||||||
margin: 30px 0 10px;
|
margin: 30px 0 10px;
|
||||||
padding-bottom: 7px;
|
padding-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window#chat-container {
|
||||||
|
/*
|
||||||
|
Chat has its own scrollbar, so remove the one on parent
|
||||||
|
This caused a performance issue in Chrome
|
||||||
|
*/
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
#version-checker {
|
#version-checker {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -2179,6 +2274,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mentions-popup-container,
|
||||||
#context-menu-container {
|
#context-menu-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -2189,6 +2285,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#context-menu-container.passthrough {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#context-menu-container.passthrough > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentions-popup,
|
||||||
#context-menu,
|
#context-menu,
|
||||||
.textcomplete-menu {
|
.textcomplete-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -2198,8 +2303,8 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 3px 12px rgb(0 0 0 / 15%);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
border: 1px solid rgb(0 0 0 / 15%);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
@ -2207,7 +2312,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
.context-menu-divider {
|
.context-menu-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgb(0 0 0 / 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-item,
|
.context-menu-item,
|
||||||
|
@ -2228,7 +2333,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
.textcomplete-item:hover,
|
.textcomplete-item:hover,
|
||||||
.textcomplete-menu .active,
|
.textcomplete-menu .active,
|
||||||
#chat .userlist .user.active {
|
#chat .userlist .user.active {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgb(0 0 0 / 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-item::before,
|
.context-menu-item::before,
|
||||||
|
@ -2569,7 +2674,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
|
|
||||||
#viewport.menu-open #sidebar,
|
#viewport.menu-open #sidebar,
|
||||||
#viewport.menu-dragging #sidebar {
|
#viewport.menu-dragging #sidebar {
|
||||||
box-shadow: 0 0 25px 0 rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 25px 0 rgb(0 0 0 / 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewport.menu-open #sidebar-overlay,
|
#viewport.menu-open #sidebar-overlay,
|
||||||
|
@ -2589,6 +2694,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
right: 0;
|
right: 0;
|
||||||
transform: translateX(180px);
|
transform: translateX(180px);
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#viewport.userlist-open #chat .userlist {
|
#viewport.userlist-open #chat .userlist {
|
||||||
|
@ -2628,11 +2734,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#connect .btn {
|
|
||||||
margin-left: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#help .help-version-title {
|
#help .help-version-title {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
@ -2690,24 +2791,25 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
background-color: rgba(0, 0, 0, 0);
|
background-color: rgb(0 0 0 / 0%);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar:hover {
|
::-webkit-scrollbar:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.09);
|
background-color: rgb(0 0 0 / 9%);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:vertical {
|
::-webkit-scrollbar-thumb:vertical {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgb(0 0 0 / 50%);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:vertical:active {
|
::-webkit-scrollbar-thumb:vertical:active {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgb(0 0 0 / 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image viewer and drag-and-drop overlay */
|
/* Image viewer and drag-and-drop overlay */
|
||||||
|
|
||||||
|
#confirm-dialog-overlay,
|
||||||
#upload-overlay,
|
#upload-overlay,
|
||||||
#image-viewer,
|
#image-viewer,
|
||||||
#image-viewer .open-btn,
|
#image-viewer .open-btn,
|
||||||
|
@ -2719,6 +2821,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#confirm-dialog-overlay,
|
||||||
#upload-overlay,
|
#upload-overlay,
|
||||||
#image-viewer {
|
#image-viewer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
@ -2734,14 +2837,16 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#confirm-dialog-overlay.opened,
|
||||||
#upload-overlay.is-dragover,
|
#upload-overlay.is-dragover,
|
||||||
#image-viewer.opened {
|
#image-viewer.opened {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#confirm-dialog-overlay,
|
||||||
#image-viewer {
|
#image-viewer {
|
||||||
background: rgba(0, 0, 0, 0.9);
|
background: rgb(0 0 0 / 90%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#image-viewer .close-btn,
|
#image-viewer .close-btn,
|
||||||
|
@ -2804,22 +2909,31 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
background-position: 0 0, 10px 10px;
|
background-position: 0 0, 10px 10px;
|
||||||
background-size: 20px 20px;
|
background-size: 20px 20px;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(45deg, #eee 25%, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0) 75%, #eee 75%, #eee 100%),
|
linear-gradient(45deg, #eee 25%, rgb(0 0 0 / 0%) 25%, rgb(0 0 0 / 0%) 75%, #eee 75%, #eee 100%),
|
||||||
linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%);
|
linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Correctly handle multiple successive whitespace characters.
|
/* Correctly handle multiple successive whitespace characters.
|
||||||
For example: user has quit ( ===> L O L <=== ) */
|
For example: user has quit ( ===> L O L <=== ) */
|
||||||
|
|
||||||
.header .topic,
|
|
||||||
#chat .msg[data-type="action"] .content,
|
#chat .msg[data-type="action"] .content,
|
||||||
#chat .msg[data-type="message"] .content,
|
#chat .msg[data-type="message"] .content,
|
||||||
#chat .msg[data-type="motd"] .content,
|
#chat .msg[data-type="monospace_block"] .content,
|
||||||
#chat .msg[data-type="notice"] .content,
|
#chat .msg[data-type="notice"] .content,
|
||||||
#chat .ctcp-message,
|
#chat .ctcp-message,
|
||||||
#chat .part-reason,
|
#chat .part-reason,
|
||||||
#chat .quit-reason,
|
#chat .quit-reason,
|
||||||
#chat .new-topic,
|
#chat .new-topic {
|
||||||
#chat table.channel-list .topic {
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chat table.channel-list .topic,
|
||||||
|
.header .topic {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-view[data-type="search-results"] .search-status {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
<link id="favicon" rel="icon" sizes="16x16 32x32 64x64" href="favicon.ico" data-other="img/favicon-alerted.ico" type="image/x-icon">
|
<link id="favicon" rel="icon" sizes="16x16 32x32 64x64" href="favicon.ico" data-other="img/favicon-alerted.ico" type="image/x-icon">
|
||||||
|
|
||||||
<!-- Safari pinned tab icon -->
|
<!-- Safari pinned tab icon -->
|
||||||
<link rel="mask-icon" href="img/icon-black-transparent-bg.svg" color="#415363">
|
<link rel="mask-icon" href="img/icon-black-transparent-bg.svg" color="#415364">
|
||||||
|
|
||||||
<link rel="manifest" href="thelounge.webmanifest">
|
<link rel="manifest" href="thelounge.webmanifest">
|
||||||
|
|
||||||
|
@ -48,12 +48,12 @@
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body class="<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
|
<body class="<%- public ? " public" : "" %>" data-transports="<%- JSON.stringify(transports) %>">
|
||||||
<div id="viewport"></div>
|
<div id="app"></div>
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
<div class="window">
|
<div class="window">
|
||||||
<div id="loading-status-container">
|
<div id="loading-status-container">
|
||||||
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="The Lounge" width="256" height="170">
|
<img src="img/logo-vertical-transparent-bg.svg" class="logo" alt="" width="256" height="170">
|
||||||
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="The Lounge" width="256" height="170">
|
<img src="img/logo-vertical-transparent-bg-inverted.svg" class="logo-inverted" alt="" width="256" height="170">
|
||||||
<p id="loading-page-message">The Lounge requires a modern browser with JavaScript enabled.</p>
|
<p id="loading-page-message">The Lounge requires a modern browser with JavaScript enabled.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="loading-reload-container">
|
<div id="loading-reload-container">
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
import storage from "./localStorage";
|
import storage from "./localStorage";
|
||||||
import location from "./location";
|
import location from "./location";
|
||||||
|
|
|
@ -1,90 +1,92 @@
|
||||||
"use strict";
|
import constants from "./constants";
|
||||||
|
|
||||||
const constants = require("./constants");
|
|
||||||
|
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
import {Textcomplete, Textarea} from "textcomplete";
|
import {Textcomplete, StrategyProps} from "@textcomplete/core";
|
||||||
|
import {TextareaEditor} from "@textcomplete/textarea";
|
||||||
|
|
||||||
import fuzzy from "fuzzy";
|
import fuzzy from "fuzzy";
|
||||||
|
|
||||||
import emojiMap from "./helpers/simplemap.json";
|
import emojiMap from "./helpers/simplemap.json";
|
||||||
import store from "./store";
|
import {store} from "./store";
|
||||||
|
import {ChanType} from "../../shared/types/chan";
|
||||||
|
|
||||||
export default enableAutocomplete;
|
export default enableAutocomplete;
|
||||||
|
|
||||||
const emojiSearchTerms = Object.keys(emojiMap);
|
const emojiSearchTerms = Object.keys(emojiMap);
|
||||||
const emojiStrategy = {
|
const emojiStrategy: StrategyProps = {
|
||||||
id: "emoji",
|
id: "emoji",
|
||||||
match: /(^|\s):([-+\w:?]{2,}):?$/,
|
match: /(^|\s):([-+\w:?]{2,}):?$/,
|
||||||
search(term, callback) {
|
search(term: string, callback: (matches) => void) {
|
||||||
// Trim colon from the matched term,
|
// Trim colon from the matched term,
|
||||||
// as we are unable to get a clean string from match regex
|
// as we are unable to get a clean string from match regex
|
||||||
term = term.replace(/:$/, "");
|
term = term.replace(/:$/, "");
|
||||||
callback(fuzzyGrep(term, emojiSearchTerms));
|
callback(fuzzyGrep(term, emojiSearchTerms));
|
||||||
},
|
},
|
||||||
template([string, original]) {
|
template([string, original]: [string, string]) {
|
||||||
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
|
return `<span class="emoji">${String(emojiMap[original])}</span> ${string}`;
|
||||||
},
|
},
|
||||||
replace([, original]) {
|
replace([, original]: [string, string]) {
|
||||||
return "$1" + emojiMap[original];
|
return "$1" + String(emojiMap[original]);
|
||||||
},
|
},
|
||||||
index: 2,
|
index: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nicksStrategy = {
|
const nicksStrategy: StrategyProps = {
|
||||||
id: "nicks",
|
id: "nicks",
|
||||||
match: /(^|\s)(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
|
match: /(^|\s)(@([a-zA-Z_[\]\\^{}|`@][a-zA-Z0-9_[\]\\^{}|`-]*)?)$/,
|
||||||
search(term, callback) {
|
search(term: string, callback: (matches: string[] | string[][]) => void) {
|
||||||
term = term.slice(1);
|
term = term.slice(1);
|
||||||
|
|
||||||
if (term[0] === "@") {
|
if (term[0] === "@") {
|
||||||
|
// TODO: type
|
||||||
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
|
callback(completeNicks(term.slice(1), true).map((val) => ["@" + val[0], "@" + val[1]]));
|
||||||
} else {
|
} else {
|
||||||
callback(completeNicks(term, true));
|
callback(completeNicks(term, true));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
template([string]) {
|
template([string]: [string, string]) {
|
||||||
return string;
|
return string;
|
||||||
},
|
},
|
||||||
replace([, original]) {
|
replace([, original]: [string, string]) {
|
||||||
return "$1" + replaceNick(original);
|
return "$1" + replaceNick(original);
|
||||||
},
|
},
|
||||||
index: 2,
|
index: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const chanStrategy = {
|
const chanStrategy: StrategyProps = {
|
||||||
id: "chans",
|
id: "chans",
|
||||||
match: /(^|\s)((?:#|\+|&|![A-Z0-9]{5})(?:[^\s]+)?)$/,
|
match: /(^|\s)((?:#|\+|&|![A-Z0-9]{5})(?:[^\s]+)?)$/,
|
||||||
search(term, callback) {
|
search(term: string, callback: (matches: string[][]) => void) {
|
||||||
callback(completeChans(term));
|
callback(completeChans(term));
|
||||||
},
|
},
|
||||||
template([string]) {
|
template([string]: [string, string]) {
|
||||||
return string;
|
return string;
|
||||||
},
|
},
|
||||||
replace([, original]) {
|
replace([, original]: [string, string]) {
|
||||||
return "$1" + original;
|
return "$1" + original;
|
||||||
},
|
},
|
||||||
index: 2,
|
index: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const commandStrategy = {
|
const commandStrategy: StrategyProps = {
|
||||||
id: "commands",
|
id: "commands",
|
||||||
match: /^\/(\w*)$/,
|
match: /^\/(\w*)$/,
|
||||||
search(term, callback) {
|
search(term: string, callback: (matches: string[][]) => void) {
|
||||||
callback(completeCommands("/" + term));
|
callback(completeCommands("/" + term));
|
||||||
},
|
},
|
||||||
template([string]) {
|
template([string]: [string, string]) {
|
||||||
return string;
|
return string;
|
||||||
},
|
},
|
||||||
replace([, original]) {
|
replace([, original]: [string, string]) {
|
||||||
return original;
|
return original;
|
||||||
},
|
},
|
||||||
index: 1,
|
index: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const foregroundColorStrategy = {
|
const foregroundColorStrategy: StrategyProps = {
|
||||||
id: "foreground-colors",
|
id: "foreground-colors",
|
||||||
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
|
match: /\x03(\d{0,2}|[A-Za-z ]{0,10})$/,
|
||||||
search(term, callback) {
|
search(term: string, callback: (matches: string[][]) => void) {
|
||||||
term = term.toLowerCase();
|
term = term.toLowerCase();
|
||||||
|
|
||||||
const matchingColorCodes = constants.colorCodeMap
|
const matchingColorCodes = constants.colorCodeMap
|
||||||
|
@ -105,19 +107,19 @@ const foregroundColorStrategy = {
|
||||||
|
|
||||||
callback(matchingColorCodes);
|
callback(matchingColorCodes);
|
||||||
},
|
},
|
||||||
template(value) {
|
template(value: string[]) {
|
||||||
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
|
return `<span class="irc-fg${parseInt(value[0], 10)}">${value[1]}</span>`;
|
||||||
},
|
},
|
||||||
replace(value) {
|
replace(value: string) {
|
||||||
return "\x03" + value[0];
|
return "\x03" + value[0];
|
||||||
},
|
},
|
||||||
index: 1,
|
index: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const backgroundColorStrategy = {
|
const backgroundColorStrategy: StrategyProps = {
|
||||||
id: "background-colors",
|
id: "background-colors",
|
||||||
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
|
match: /\x03(\d{2}),(\d{0,2}|[A-Za-z ]{0,10})$/,
|
||||||
search(term, callback, match) {
|
search(term: string, callback: (matchingColorCodes: string[][]) => void, match: string[]) {
|
||||||
term = term.toLowerCase();
|
term = term.toLowerCase();
|
||||||
const matchingColorCodes = constants.colorCodeMap
|
const matchingColorCodes = constants.colorCodeMap
|
||||||
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
|
.filter((i) => fuzzy.test(term, i[0]) || fuzzy.test(term, i[1]))
|
||||||
|
@ -138,25 +140,25 @@ const backgroundColorStrategy = {
|
||||||
|
|
||||||
callback(matchingColorCodes);
|
callback(matchingColorCodes);
|
||||||
},
|
},
|
||||||
template(value) {
|
template(value: string[]) {
|
||||||
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
|
return `<span class="irc-fg${parseInt(value[2], 10)} irc-bg irc-bg${parseInt(
|
||||||
value[0],
|
value[0],
|
||||||
10
|
10
|
||||||
)}">${value[1]}</span>`;
|
)}">${value[1]}</span>`;
|
||||||
},
|
},
|
||||||
replace(value) {
|
replace(value: string[]) {
|
||||||
return "\x03$1," + value[0];
|
return "\x03$1," + value[0];
|
||||||
},
|
},
|
||||||
index: 2,
|
index: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
function enableAutocomplete(input) {
|
function enableAutocomplete(input: HTMLTextAreaElement) {
|
||||||
let tabCount = 0;
|
let tabCount = 0;
|
||||||
let lastMatch = "";
|
let lastMatch = "";
|
||||||
let currentMatches = [];
|
let currentMatches: string[] | string[][] = [];
|
||||||
|
|
||||||
input.addEventListener("input", (e) => {
|
input.addEventListener("input", (e) => {
|
||||||
if (e.detail === "autocomplete") {
|
if ((e as CustomEvent).detail === "autocomplete") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,10 +179,7 @@ function enableAutocomplete(input) {
|
||||||
const text = input.value;
|
const text = input.value;
|
||||||
|
|
||||||
if (tabCount === 0) {
|
if (tabCount === 0) {
|
||||||
lastMatch = text
|
lastMatch = text.substring(0, input.selectionStart).split(/\s/).pop() || "";
|
||||||
.substring(0, input.selectionStart)
|
|
||||||
.split(/\s/)
|
|
||||||
.pop();
|
|
||||||
|
|
||||||
if (lastMatch.length === 0) {
|
if (lastMatch.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -195,12 +194,14 @@ function enableAutocomplete(input) {
|
||||||
|
|
||||||
const position = input.selectionStart - lastMatch.length;
|
const position = input.selectionStart - lastMatch.length;
|
||||||
const newMatch = replaceNick(
|
const newMatch = replaceNick(
|
||||||
currentMatches[tabCount % currentMatches.length],
|
// TODO: type this properly
|
||||||
|
String(currentMatches[tabCount % currentMatches.length]),
|
||||||
position
|
position
|
||||||
);
|
);
|
||||||
const remainder = text.substr(input.selectionStart);
|
const remainder = text.substring(input.selectionStart);
|
||||||
|
|
||||||
input.value = text.substr(0, position) + newMatch + remainder;
|
input.value = text.substr(0, position) + newMatch + remainder;
|
||||||
|
|
||||||
input.selectionStart -= remainder.length;
|
input.selectionStart -= remainder.length;
|
||||||
input.selectionEnd = input.selectionStart;
|
input.selectionEnd = input.selectionStart;
|
||||||
|
|
||||||
|
@ -217,29 +218,21 @@ function enableAutocomplete(input) {
|
||||||
"keydown"
|
"keydown"
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = new Textarea(input);
|
const strategies = [
|
||||||
const textcomplete = new Textcomplete(editor, {
|
|
||||||
dropdown: {
|
|
||||||
className: "textcomplete-menu",
|
|
||||||
placement: "top",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
textcomplete.register([
|
|
||||||
emojiStrategy,
|
emojiStrategy,
|
||||||
nicksStrategy,
|
nicksStrategy,
|
||||||
chanStrategy,
|
chanStrategy,
|
||||||
commandStrategy,
|
commandStrategy,
|
||||||
foregroundColorStrategy,
|
foregroundColorStrategy,
|
||||||
backgroundColorStrategy,
|
backgroundColorStrategy,
|
||||||
]);
|
];
|
||||||
|
|
||||||
// Activate the first item by default
|
const editor = new TextareaEditor(input);
|
||||||
// https://github.com/yuku-t/textcomplete/issues/93
|
const textcomplete = new Textcomplete(editor, strategies, {
|
||||||
textcomplete.on("rendered", () => {
|
dropdown: {
|
||||||
if (textcomplete.dropdown.items.length > 0) {
|
className: "textcomplete-menu",
|
||||||
textcomplete.dropdown.items[0].activate();
|
placement: "top",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
textcomplete.on("show", () => {
|
textcomplete.on("show", () => {
|
||||||
|
@ -261,14 +254,14 @@ function enableAutocomplete(input) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceNick(original, position = 1) {
|
function replaceNick(original: string, position = 1) {
|
||||||
// If no postfix specified, return autocompleted nick as-is
|
// If no postfix specified, return autocompleted nick as-is
|
||||||
if (!store.state.settings.nickPostfix) {
|
if (!store.state.settings.nickPostfix) {
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there is whitespace in the input already, append space to nick
|
// If there is whitespace in the input already, append space to nick
|
||||||
if (position > 0 && /\s/.test(store.state.activeChannel.channel.pendingMessage)) {
|
if (position > 0 && /\s/.test(store.state.activeChannel?.channel.pendingMessage || "")) {
|
||||||
return original + " ";
|
return original + " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -276,7 +269,7 @@ function replaceNick(original, position = 1) {
|
||||||
return original + store.state.settings.nickPostfix;
|
return original + store.state.settings.nickPostfix;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fuzzyGrep(term, array) {
|
function fuzzyGrep<T>(term: string, array: Array<T>) {
|
||||||
const results = fuzzy.filter(term, array, {
|
const results = fuzzy.filter(term, array, {
|
||||||
pre: "<b>",
|
pre: "<b>",
|
||||||
post: "</b>",
|
post: "</b>",
|
||||||
|
@ -285,6 +278,10 @@ function fuzzyGrep(term, array) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function rawNicks() {
|
function rawNicks() {
|
||||||
|
if (!store.state.activeChannel) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (store.state.activeChannel.channel.users.length > 0) {
|
if (store.state.activeChannel.channel.users.length > 0) {
|
||||||
const users = store.state.activeChannel.channel.users.slice();
|
const users = store.state.activeChannel.channel.users.slice();
|
||||||
|
|
||||||
|
@ -295,7 +292,7 @@ function rawNicks() {
|
||||||
const otherUser = store.state.activeChannel.channel.name;
|
const otherUser = store.state.activeChannel.channel.name;
|
||||||
|
|
||||||
// If this is a query, add their name to autocomplete
|
// If this is a query, add their name to autocomplete
|
||||||
if (me !== otherUser && store.state.activeChannel.channel.type === "query") {
|
if (me !== otherUser && store.state.activeChannel.channel.type === ChanType.QUERY) {
|
||||||
return [otherUser, me];
|
return [otherUser, me];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,7 +300,7 @@ function rawNicks() {
|
||||||
return [me];
|
return [me];
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeNicks(word, isFuzzy) {
|
function completeNicks(word: string, isFuzzy: boolean) {
|
||||||
const users = rawNicks();
|
const users = rawNicks();
|
||||||
word = word.toLowerCase();
|
word = word.toLowerCase();
|
||||||
|
|
||||||
|
@ -314,19 +311,30 @@ function completeNicks(word, isFuzzy) {
|
||||||
return users.filter((w) => !w.toLowerCase().indexOf(word));
|
return users.filter((w) => !w.toLowerCase().indexOf(word));
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeCommands(word) {
|
function getCommands() {
|
||||||
const words = constants.commands.slice();
|
let cmds = constants.commands.slice();
|
||||||
|
|
||||||
return fuzzyGrep(word, words);
|
if (!store.state.settings.searchEnabled) {
|
||||||
|
cmds = cmds.filter((c) => c !== "/search");
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeChans(word) {
|
function completeCommands(word: string) {
|
||||||
const words = [];
|
const commands = getCommands();
|
||||||
|
return fuzzyGrep(word, commands);
|
||||||
|
}
|
||||||
|
|
||||||
for (const channel of store.state.activeChannel.network.channels) {
|
function completeChans(word: string) {
|
||||||
// Push all channels that start with the same CHANTYPE
|
const words: string[] = [];
|
||||||
if (channel.type === "channel" && channel.name[0] === word[0]) {
|
|
||||||
words.push(channel.name);
|
if (store.state.activeChannel) {
|
||||||
|
for (const channel of store.state.activeChannel.network.channels) {
|
||||||
|
// Push all channels that start with the same CHANTYPE
|
||||||
|
if (channel.type === ChanType.CHANNEL && channel.name[0] === word[0]) {
|
||||||
|
words.push(channel.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
36
client/js/chan.ts
Normal file
36
client/js/chan.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import {ClientChan, ClientMessage} from "./types";
|
||||||
|
import {SharedNetworkChan} from "../../shared/types/network";
|
||||||
|
import {SharedMsg, MessageType} from "../../shared/types/msg";
|
||||||
|
import {ChanType} from "../../shared/types/chan";
|
||||||
|
|
||||||
|
export function toClientChan(shared: SharedNetworkChan): ClientChan {
|
||||||
|
const history: string[] = [""].concat(
|
||||||
|
shared.messages
|
||||||
|
.filter((m) => m.self && m.text && m.type === MessageType.MESSAGE)
|
||||||
|
// TS is too stupid to see the nil guard on filter... so we monkey patch it
|
||||||
|
.map((m): string => (m.text ? m.text : ""))
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 99)
|
||||||
|
);
|
||||||
|
// filter the unused vars
|
||||||
|
const {messages, totalMessages: _, ...props} = shared;
|
||||||
|
const channel: ClientChan = {
|
||||||
|
...props,
|
||||||
|
editTopic: false,
|
||||||
|
pendingMessage: "",
|
||||||
|
inputHistoryPosition: 0,
|
||||||
|
historyLoading: false,
|
||||||
|
scrolledToBottom: true,
|
||||||
|
users: [],
|
||||||
|
usersOutdated: shared.type === ChanType.CHANNEL ? true : false,
|
||||||
|
moreHistoryAvailable: shared.totalMessages > shared.messages.length,
|
||||||
|
inputHistory: history,
|
||||||
|
messages: sharedMsgToClientMsg(messages),
|
||||||
|
};
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedMsgToClientMsg(shared: SharedMsg[]): ClientMessage[] {
|
||||||
|
// TODO: this is a stub for now, we will want to populate client specific stuff here
|
||||||
|
return shared;
|
||||||
|
}
|
|
@ -1,13 +1,16 @@
|
||||||
"use strict";
|
export default function (chat: HTMLDivElement) {
|
||||||
|
|
||||||
export default function(chat) {
|
|
||||||
// Disable in Firefox as it already copies flex text correctly
|
// Disable in Firefox as it already copies flex text correctly
|
||||||
|
// @ts-expect-error Property 'InstallTrigger' does not exist on type 'Window & typeof globalThis'.ts(2339)
|
||||||
if (typeof window.InstallTrigger !== "undefined") {
|
if (typeof window.InstallTrigger !== "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If selection does not span multiple elements, do nothing
|
// If selection does not span multiple elements, do nothing
|
||||||
if (selection.anchorNode === selection.focusNode) {
|
if (selection.anchorNode === selection.focusNode) {
|
||||||
return;
|
return;
|
|
@ -1,15 +1,17 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
import socket from "../socket";
|
import socket from "../socket";
|
||||||
import store from "../store";
|
import {store} from "../store";
|
||||||
|
|
||||||
function input() {
|
export function input(): boolean {
|
||||||
const messageIds = [];
|
if (!store.state.activeChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIds: number[] = [];
|
||||||
|
|
||||||
for (const message of store.state.activeChannel.channel.messages) {
|
for (const message of store.state.activeChannel.channel.messages) {
|
||||||
let toggled = false;
|
let toggled = false;
|
||||||
|
|
||||||
for (const preview of message.previews) {
|
for (const preview of message.previews || []) {
|
||||||
if (preview.shown) {
|
if (preview.shown) {
|
||||||
preview.shown = false;
|
preview.shown = false;
|
||||||
toggled = true;
|
toggled = true;
|
||||||
|
@ -22,9 +24,9 @@ function input() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the server we're toggling so it remembers at page reload
|
// Tell the server we're toggling so it remembers at page reload
|
||||||
if (messageIds.length > 0) {
|
if (!document.body.classList.contains("public") && messageIds.length > 0) {
|
||||||
socket.emit("msg:preview:toggle", {
|
socket.emit("msg:preview:toggle", {
|
||||||
target: store.state.activeChannel.channel.id,
|
target: store.state.activeChannel?.channel.id,
|
||||||
messageIds: messageIds,
|
messageIds: messageIds,
|
||||||
shown: false,
|
shown: false,
|
||||||
});
|
});
|
||||||
|
@ -32,5 +34,3 @@ function input() {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {input};
|
|
|
@ -1,15 +1,17 @@
|
||||||
"use strict";
|
|
||||||
|
|
||||||
import socket from "../socket";
|
import socket from "../socket";
|
||||||
import store from "../store";
|
import {store} from "../store";
|
||||||
|
|
||||||
function input() {
|
export function input(): boolean {
|
||||||
const messageIds = [];
|
if (!store.state.activeChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIds: number[] = [];
|
||||||
|
|
||||||
for (const message of store.state.activeChannel.channel.messages) {
|
for (const message of store.state.activeChannel.channel.messages) {
|
||||||
let toggled = false;
|
let toggled = false;
|
||||||
|
|
||||||
for (const preview of message.previews) {
|
for (const preview of message.previews || []) {
|
||||||
if (!preview.shown) {
|
if (!preview.shown) {
|
||||||
preview.shown = true;
|
preview.shown = true;
|
||||||
toggled = true;
|
toggled = true;
|
||||||
|
@ -22,9 +24,9 @@ function input() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the server we're toggling so it remembers at page reload
|
// Tell the server we're toggling so it remembers at page reload
|
||||||
if (messageIds.length > 0) {
|
if (!document.body.classList.contains("public") && messageIds.length > 0) {
|
||||||
socket.emit("msg:preview:toggle", {
|
socket.emit("msg:preview:toggle", {
|
||||||
target: store.state.activeChannel.channel.id,
|
target: store.state.activeChannel?.channel.id,
|
||||||
messageIds: messageIds,
|
messageIds: messageIds,
|
||||||
shown: true,
|
shown: true,
|
||||||
});
|
});
|
||||||
|
@ -32,5 +34,3 @@ function input() {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {input};
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue