mirror of
https://github.com/thelounge/thelounge.git
synced 2024-06-07 16:22:18 +02:00
Compare commits
1320 commits
v4.1.0-rc.
...
master
Author | SHA1 | Date | |
---|---|---|---|
0d9c184f19 | |||
4de413070d | |||
0955d9df06 | |||
cb4aaf6a97 | |||
45c2fc87ee | |||
29fcc2da05 | |||
12679081c8 | |||
0e48014d5a | |||
4819406af5 | |||
74563effa7 | |||
cbab10f416 | |||
4dfeb899b4 | |||
3259ac596d | |||
3fbbc39cd6 | |||
9ae9482223 | |||
a3953405ed | |||
9086bc648d | |||
da2572fe25 | |||
d9977df315 | |||
cc0aa5e8e5 | |||
02df78b0f2 | |||
18b0e06855 | |||
d5db9c653b | |||
f7926267d9 | |||
8eb398c5cc | |||
36cb75ee99 | |||
1ec67a6605 | |||
8372c5a57e | |||
5567f07a7c | |||
a200bab8bd | |||
91ac363cc6 | |||
6c9d2c36a1 | |||
6241eed8f4 | |||
03151e0ab1 | |||
7f5e0f3ebf | |||
5e444be37b | |||
c8664301ba | |||
1edb5a72c1 | |||
31d987283a | |||
4ceafb653f | |||
f25fee4c6c | |||
96848c1c1b | |||
4b07e05491 | |||
fc9805545b | |||
82e4150cc8 | |||
e61e356f1e | |||
5001d607b1 | |||
8c41356ae9 | |||
e2b56cf16b | |||
92a0affba1 | |||
edb96f683b | |||
5c8951ffc3 | |||
c3fc54e158 | |||
917fdb2a0a | |||
b8400a3a46 | |||
071a5afda6 | |||
5274fdc21a | |||
b8a9fe08ab | |||
a4afa08add | |||
4614c35486 | |||
540144c417 | |||
bb7c3925c6 | |||
9898f38de6 | |||
9f2c82e152 | |||
17ba07db3b | |||
0311e5f836 | |||
4d0474b897 | |||
14b9169899 | |||
50037644c0 | |||
7287c6bcaa | |||
bfca0ca612 | |||
300bd4c84c | |||
42ea66c343 | |||
1565eb8d05 | |||
29750a3e51 | |||
3ea5170e6a | |||
fe4f497fad | |||
c20cd6bda1 | |||
1c4ce5d4a5 | |||
9c4d24d1f7 | |||
35e38d13c4 | |||
bf7eb0e727 | |||
5ee9c2b338 | |||
e15b121080 | |||
98452ccc18 | |||
a8e7022d04 | |||
60486bf5e3 | |||
46f3fd9682 | |||
56215382a3 | |||
9ab9ad0f56 | |||
0660a8772c | |||
f5c691f37b | |||
0067c30273 | |||
843db1727b | |||
e9ef59b641 | |||
fceffd42b9 | |||
b89b0cad53 | |||
c869ea9a73 | |||
9aee3e3e98 | |||
636b5c5b04 | |||
6984e8f25a | |||
e43cbb139c | |||
e57e547b74 | |||
3217536245 | |||
194b4e1a2f | |||
88c8830a17 | |||
7073584f1c | |||
8e6920af1d | |||
7bc184b252 | |||
4d237600d5 | |||
383907c2b8 | |||
f0ee3be6fb | |||
12a0b0b6f9 | |||
d716402da2 | |||
d0b71aba32 | |||
3f0ee6a961 | |||
b67e4699f5 | |||
68ba13ca12 | |||
3eb19135f5 | |||
549c445853 | |||
2466c1b1e4 | |||
f5867c3643 | |||
231c498def | |||
eeaec413d6 | |||
515f894c13 | |||
e8f6ba5b08 | |||
07276bbde4 | |||
9ad92e1860 | |||
7923d4a2cd | |||
9248358169 | |||
6ab52bc9a9 | |||
48213955b9 | |||
682b3b91aa | |||
be3e27aa19 | |||
c09f751552 | |||
fb5864ee00 | |||
3bd5b704c7 | |||
139ce47b73 | |||
45563d9a59 | |||
e2fda1fb84 | |||
a77fbb894f | |||
fe50a90235 | |||
a8be84028c | |||
25e55ce75c | |||
113e9bd2fb | |||
2b146ba3e6 | |||
f95dd29a0d | |||
91dc719c93 | |||
5af893db3a | |||
daabb76781 | |||
393d0a63b7 | |||
037fc479b8 | |||
646bafab99 | |||
d4c77c74f6 | |||
eeefeb229c | |||
29c5323bfd | |||
a12ddc75d8 | |||
dd24cb1300 | |||
ae6bae69ac | |||
b5372e3ed7 | |||
d15998d919 | |||
436bf6a180 | |||
1d2fdd95b0 | |||
eaa70caad7 | |||
aa95032760 | |||
e636121d7a | |||
083abae750 | |||
01cfe3d19d | |||
7f0b721790 | |||
edb1226b47 | |||
b0ca8e51fb | |||
21b1152f53 | |||
74aff7ee5a | |||
14d9ff247d | |||
aec8d0b033 | |||
60ddf17124 | |||
20227b174c | |||
d18182da8b | |||
ea35040b42 | |||
97f553eea8 | |||
6603c1a6e6 | |||
73a529acea | |||
2f40d9dbcc | |||
d1561f8ebc | |||
ec75ff00cb | |||
884a92c74b | |||
77b64c546b | |||
cc59e6b578 | |||
fb1d79f5fa | |||
100ff3c198 | |||
d893feff1c | |||
88a5fef4ea | |||
5b64ecbe68 | |||
5024acd7dc | |||
bbfada251c | |||
8cec292f2c | |||
22ae594cc3 | |||
1c6bec2323 | |||
9105fbc23a | |||
8c54cd50d8 | |||
59de6afd3f | |||
b506966b08 | |||
785ec0a0e2 | |||
250433c875 | |||
d4d5a8e386 | |||
bcca111a4d | |||
b686059c6b | |||
ff77a33663 | |||
d308e74183 | |||
f999db99c7 | |||
76c896aea2 | |||
08413c7b6b | |||
48301b1ca3 | |||
03795a2718 | |||
2985727996 | |||
9f05a75c39 | |||
c0b38d4762 | |||
2878f87879 | |||
447a237fc6 | |||
430a865e9f | |||
816b7686e3 | |||
2e019a2fdb | |||
4f9ca3e192 | |||
57c4d5513c | |||
3e21bfcbea | |||
607b9fc96a | |||
1a1153aed6 | |||
54ff563247 | |||
ed0a47fe2c | |||
3af4ad1076 | |||
79fae26f39 | |||
c6b1913b91 | |||
071ad96d9b | |||
2ef8b37009 | |||
8aa5e33b1d | |||
43a2b397a2 | |||
c43a47afc1 | |||
14575c94cf | |||
303f53fe72 | |||
06f1387f7b | |||
c5326e8795 | |||
355c5d6fa4 | |||
7ac2a6fd77 | |||
c4879fdbba | |||
4255c1cdec | |||
ae9d312b2a | |||
f7c6ba5eb1 | |||
4d60d9c282 | |||
af49ef21ea | |||
7a9ddc01e1 | |||
8f08cf3d0b | |||
e05871fd2f | |||
ede48ab034 | |||
4c6fa550aa | |||
9388960497 | |||
7bce779254 | |||
a7b85db990 | |||
f4ef11de3f | |||
3a63484762 | |||
04b2bf036b | |||
3066f48a69 | |||
ed40c83a2b | |||
f21f665384 | |||
12d9ef34f0 | |||
9ee1cf13a8 | |||
ba1a4206a6 | |||
59cf29ef4a | |||
8e43d8083d | |||
3cd0a75ac2 | |||
21d1dbaad6 | |||
90ad06a29a | |||
0c7cc85184 | |||
3be805bd38 | |||
e25c296901 | |||
4babd17383 | |||
b408843ff1 | |||
0f3487c533 | |||
21ada132b1 | |||
2f162daee1 | |||
3ac9c36d95 | |||
c30da27f95 | |||
30a3ba489a | |||
0dca3954f4 | |||
e8b6434144 | |||
edc6f77c64 | |||
0dd74a93bf | |||
4e954b919c | |||
eb509f7100 | |||
845dabad53 | |||
6b00ccf82b | |||
34a01c2dd1 | |||
320075e376 | |||
d58fb84565 | |||
a049a01aeb | |||
76098d7e76 | |||
a67cee1ee4 | |||
efd24fd12c | |||
bc4c3082b8 | |||
d471a4c959 | |||
4831c20804 | |||
eddcbcc766 | |||
0183d89384 | |||
95e56300db | |||
8e249d46af | |||
50e8d2a890 | |||
7f6059d5b7 | |||
8ca9ee873b | |||
402332340b | |||
4742a07721 | |||
2f8dc01930 | |||
fade6a8d2e | |||
dfed1dd757 | |||
d67277d996 | |||
95aaba43fa | |||
3e7255ff20 | |||
86e376fc03 | |||
899762cddd | |||
063aca948c | |||
25642fbe98 | |||
c2e7390127 | |||
d10a59395c | |||
8fc696620f | |||
c6a202d6ab | |||
7c9ed14909 | |||
bdc1f23107 | |||
e9a09f5447 | |||
d93cd88dd5 | |||
2f04150461 | |||
c816e4053e | |||
4cff2ccabe | |||
26b7fbf2c0 | |||
243cb10e2a | |||
7304acd8e0 | |||
511209a100 | |||
2ce374fe85 | |||
00366967ae | |||
f2c59c23e2 | |||
90d17cacc1 | |||
12c03a868d | |||
b7540b5827 | |||
6f13735a7f | |||
60bb561e49 | |||
e305e23c43 | |||
9d34955836 | |||
a8149c0f1a | |||
21d1eea6b8 | |||
e1ae79cb9c | |||
429efb0c3c | |||
c3e3322a79 | |||
e31c95e32d | |||
f785acb07d | |||
bde5c3d443 | |||
375164ca88 | |||
7f3ac62e0d | |||
ce3ad56ced | |||
efd3b64564 | |||
6b23b87063 | |||
502fb7a705 | |||
c854d27d3d | |||
2803018c5a | |||
0ebc3a574c | |||
958a948456 | |||
52b8a2a78e | |||
661d5cb5b0 | |||
e597e75847 | |||
8b1a4f72fa | |||
502780c5a3 | |||
073a38ef1e | |||
c67df36a29 | |||
d50296385f | |||
068de0c10c | |||
d61ab7e7a0 | |||
2d4143b779 | |||
f55f772659 | |||
982816ff20 | |||
8204c3481a | |||
deeea274da | |||
d34b58811a | |||
dfb4217167 | |||
f8eb0ebafd | |||
fd14b4a172 | |||
1597c2c56e | |||
4c7337b625 | |||
0765d209f2 | |||
7ee4b80a6e | |||
21c8b0d17f | |||
89245455ce | |||
d4bbd9191c | |||
5037383c4c | |||
83e11b0143 | |||
51c9ce078d | |||
8095d9e88a | |||
221884166d | |||
19307d05e7 | |||
dfe288ef16 | |||
b5ea7cceb3 | |||
0ad033fe0a | |||
5a4a39b9d1 | |||
cb17f8d87f | |||
5a803ccd23 | |||
53f6041f42 | |||
dca202427a | |||
6b617f893d | |||
d62dd3e62d | |||
f068fd4290 | |||
bbe81bb2fa | |||
f04a06682d | |||
5e1cbe32f9 | |||
ee8223c200 | |||
cc3302e874 | |||
89ee537364 | |||
e62b169a6a | |||
f6b292107e | |||
bea4545abf | |||
cebc6d069f | |||
0fa203569a | |||
30e9f45fac | |||
117c5fa3fd | |||
621fa92036 | |||
11f7ae98be | |||
a95ab55154 | |||
38bccd3635 | |||
3240997347 | |||
57ed37c1fd | |||
0495761c44 | |||
520646a212 | |||
0cb4791cd0 | |||
740618ca49 | |||
e97216518a | |||
31739b8ac9 | |||
e221e708c1 | |||
c8cd4057bc | |||
d6e1af0e7d | |||
d72d8694bb | |||
80f65c5b72 | |||
bc709af9fe | |||
e7d18a91c0 | |||
ddcee5371a | |||
194b85be4d | |||
f715c833e7 | |||
a15ac88ff2 | |||
4af5fc6f33 | |||
dd05ee3a65 | |||
2e3d9a6265 | |||
c205b89523 | |||
5f7acbf994 | |||
d4cc2dd361 | |||
38f13525e6 | |||
99c48dbcea | |||
9dbb6e5e19 | |||
791205d4f0 | |||
437dd1667d | |||
24bdc46b0a | |||
5a383814f6 | |||
1f39e078f4 | |||
6f64243671 | |||
31b67b7786 | |||
abf8906757 | |||
c8115e22ac | |||
aa7db1e7f7 | |||
da02350725 | |||
c9c8cadb1a | |||
3726a8d00b | |||
605b75c6ed | |||
5e8adafb3e | |||
487d880d32 | |||
7cb8d33122 | |||
bbe103ca6f | |||
ec757c9b69 | |||
7b725ea55c | |||
0d12be138b | |||
7db0d4619d | |||
bdd6e71049 | |||
57b1e51e9f | |||
20ed3e6dc5 | |||
e4840b4d75 | |||
d7bba325a7 | |||
815319810c | |||
37d7de7671 | |||
e362704f6b | |||
48f2b79c37 | |||
3a84290314 | |||
ff886846a8 | |||
b2a363f099 | |||
3796485217 | |||
3202b79990 | |||
a42325d801 | |||
bbc7280c41 | |||
b76058e4cf | |||
ace09d434c | |||
4d9442d9e3 | |||
56bf078e29 | |||
9f7a2e942b | |||
f440b67dbe | |||
ae7020f569 | |||
38fa3bee22 | |||
2e1b2d44f6 | |||
69f3501165 | |||
8a92bc9fb9 | |||
7cf95d3cbd | |||
53f5b8e991 | |||
d145fb3738 | |||
551f85ea51 | |||
66455f2c40 | |||
c12dd6c740 | |||
cb28204517 | |||
e2e050d3c3 | |||
17b174dddb | |||
027c5b4ff7 | |||
1ed4f57afc | |||
53b4d00732 | |||
ba210e853b | |||
bd2a6cc5be | |||
bcd4a060ec | |||
ed3ec6a560 | |||
8edec1a5a8 | |||
9dfb2a3fdb | |||
4be9a282fa | |||
337bfa489b | |||
1e3a7b1250 | |||
3fb18717a7 | |||
76cbec9ac6 | |||
734f5b18d3 | |||
d228a8c4f4 | |||
f07d6b1ea4 | |||
d0fab98c1d | |||
d4d139505f | |||
9528515647 | |||
c0b81902f5 | |||
a86fa168b8 | |||
3e387156f7 | |||
7e0afc90fd | |||
dcce9eba25 | |||
b1aa8528a4 | |||
4489d5c8b8 | |||
1f8881a1d7 | |||
c7e504eeab | |||
4db2d28216 | |||
be498e8f93 | |||
e999171f29 | |||
0d209fce09 | |||
acf520bd9a | |||
26c2562124 | |||
763047889d | |||
e0bbf19d9d | |||
0fce974f2c | |||
cd7916b6d9 | |||
2c79d53c6d | |||
514c6fbf95 | |||
1953e03253 | |||
981de663fb | |||
2c2dd1c76f | |||
ecc0b9183e | |||
4065d5de97 | |||
1c08b6dce6 | |||
0c50c2d274 | |||
96c2d2419b | |||
304d207820 | |||
35d8f4e212 | |||
3c70fab7c6 | |||
0ff9703a28 | |||
dc3a387120 | |||
212212fe70 | |||
9b9b357001 | |||
684d7f2db4 | |||
8040945913 | |||
168f2ba46b | |||
d9f2fed398 | |||
84d779a4d0 | |||
324fb9023e | |||
cda3bb4e7c | |||
117792fb4d | |||
c69588dd10 | |||
361af7f514 | |||
5b76ec45ee | |||
d596c0cee5 | |||
1bb5b74236 | |||
62fd807f78 | |||
5d6746c9c4 | |||
60f4b3a434 | |||
a05dd6c612 | |||
244daea66c | |||
5f78574ecd | |||
e39e9d2f8a | |||
98e38c8947 | |||
40fb2190fa | |||
1160517c2c | |||
f719027566 | |||
cffb838284 | |||
b02001c079 | |||
79e56d1c4b | |||
b54cdf7880 | |||
cb404cd986 | |||
1d5291929c | |||
172cd63739 | |||
368f3f910b | |||
5f7ec9e8da | |||
4419029d2e | |||
af96f7771c | |||
315198ac0b | |||
f4096234d4 | |||
bfdbbce77d | |||
9dbf647f7e | |||
6dfd51bb57 | |||
371ebfb810 | |||
c439e51617 | |||
58110189fe | |||
54d1be6b29 | |||
1199183157 | |||
40a5ee70b6 | |||
3cec329e3b | |||
25d493453e | |||
f3af454c9e | |||
186f8f68cd | |||
59280cfdfd | |||
67503efd21 | |||
7ba977d56a | |||
2a901b3475 | |||
2777cc2db9 | |||
979dfaf3eb | |||
9592563a27 | |||
0381cd11bf | |||
b5e99c0489 | |||
ea619f5463 | |||
3cab39c59b | |||
fd730eeeb1 | |||
a8d438261a | |||
3bb8d2f4b8 | |||
80e0e0fd16 | |||
3da5e8e8ca | |||
411ce5d2f8 | |||
602de668ee | |||
393d4fe591 | |||
a3a9a2cdd9 | |||
044cd2403b | |||
544146d9aa | |||
97f3800785 | |||
8ab486ef0f | |||
cf18d04f06 | |||
5d7e62ed67 | |||
206d554ce1 | |||
578b1947e2 | |||
56d4a6afde | |||
3ba7fb6de4 | |||
21c6abdd1d | |||
80acbc7c06 | |||
1e896a9672 | |||
5d76ed888c | |||
2b634a6ba6 | |||
2693db4274 | |||
1d33e0195a | |||
fcffab1259 | |||
02ccbc1f69 | |||
bb4ab4f168 | |||
8a57f90b65 | |||
9a0ba1da6c | |||
5c614785bf | |||
a48f449c59 | |||
2ab671664e | |||
91a0815bb5 | |||
ebe39b26dc | |||
7b28d3c0f8 | |||
324f3aa30f | |||
e9f0313892 | |||
969d3e4ec1 | |||
7873847a7e | |||
cc0dc6266e | |||
535ac7ca39 | |||
c8cdadeb02 | |||
beb5530c65 | |||
8fcd079204 | |||
0a6c33af57 | |||
162b42d9b0 | |||
03d38812e3 | |||
35fcacb767 | |||
d96704835a | |||
0d839c501e | |||
24316fc304 | |||
11ba27d809 | |||
a59c5d65fb | |||
7fdd363ee8 | |||
75cf4445c4 | |||
18b003db9c | |||
372d74db69 | |||
bbda392c3d | |||
23f6886cc1 | |||
521426bb05 | |||
69c37a535b | |||
98e8640932 | |||
998f8d2beb | |||
058b3155d0 | |||
e0e12c1960 | |||
16177eb9f4 | |||
5e0a12b124 | |||
6439afd5c6 | |||
4dacaa46f3 | |||
426841e6b7 | |||
22801a629e | |||
47b151ab51 | |||
d05cf5fe62 | |||
3e4b22255d | |||
cc97d91ef8 | |||
c5e18e3cdd | |||
79c57ebf38 | |||
d106889127 | |||
b33fd78ed7 | |||
bec25f6243 | |||
646a98270a | |||
5a7781eabc | |||
cbe81968ee | |||
a42a1fc6a2 | |||
a2d23810bf | |||
aa310fe877 | |||
a046bfe8d1 | |||
6b852d14c8 | |||
5a9f3c5f70 | |||
f23cc0712c | |||
7107372a6f | |||
867fff33c0 | |||
e5a6554c9a | |||
38c0c343c3 | |||
53b7c46e69 | |||
e7a8476cfe | |||
a3f0314f6b | |||
3fdc42350e | |||
243f514243 | |||
a93ccd680f | |||
53a7227e2e | |||
8bb2fbbf15 | |||
0fa37a6a05 | |||
d5b6a8521f | |||
c5fcc5d72f | |||
9ec02d1e91 | |||
beb9bcd8d4 | |||
8fc7a6c0df | |||
6182d23758 | |||
c369a764ed | |||
4d310cd545 | |||
fa854fde78 | |||
6f7fd80044 | |||
28c413319f | |||
af236dd280 | |||
58217cffb1 | |||
fc6c916e7c | |||
ad8a315cf9 | |||
42bafe7165 | |||
df5befb60e | |||
db807d0c56 | |||
ab0d9e6200 | |||
adf1b5abec | |||
2c30293ad2 | |||
042cfb7582 | |||
dbf6ff064b | |||
7b1cb88658 | |||
7b298cf439 | |||
a985d763d0 | |||
d097370316 | |||
a3229f1cdf | |||
cadcc4b97c | |||
24a738d521 | |||
b95643e1a6 | |||
3f984fad4b | |||
9b4f55bdb6 | |||
abcad094d1 | |||
0bfcd955e3 | |||
04cf2277d9 | |||
26a38b12ab | |||
bc7a920de5 | |||
78da0eb674 | |||
db8102b058 | |||
4b96682d7f | |||
11aa52687c | |||
bd4e821614 | |||
c5f6b4617f | |||
c66f9c885e | |||
bb41871873 | |||
115d970604 | |||
ef710a2631 | |||
ddff3ac162 | |||
0aabacd549 | |||
0fb6dae8a6 | |||
ee43e7bdf4 | |||
e010fe47cc | |||
89390b3fc5 | |||
c2c66031c0 | |||
846da41b01 | |||
3a6ac4e5ec | |||
1b13905195 | |||
13d4f035df | |||
3fb9c8523a | |||
544594a7ad | |||
e36ae64c83 | |||
be141bea65 | |||
40aaa17c9b | |||
d6a23061fc | |||
de86c144b5 | |||
fe0178c0d2 | |||
49cd90d0e9 | |||
e6856a9e7d | |||
283ef445e5 | |||
08f45eabb2 | |||
db9eb05dfa | |||
df4f78098c | |||
0ccbb90d98 | |||
3a42b5385e | |||
14d76f8023 | |||
a5e9463431 | |||
f213a8973c | |||
9382beb3b1 | |||
c6d7bd4b4a | |||
8dd9bc0e98 | |||
7df94f01a7 | |||
d94d09f4ba | |||
d248f98618 | |||
5cef511469 | |||
c6282b0a50 | |||
1913a3ade6 | |||
386f90614b | |||
d1995a0f7d | |||
aede86bc98 | |||
a496ba8cfc | |||
d600a10f48 | |||
500034ff5d | |||
e4069f8ce9 | |||
4f6659897f | |||
5329483a40 | |||
dc0e233fe0 | |||
6b074a6660 | |||
57bce195de | |||
34086369db | |||
8ce947130b | |||
70fcf6f3ee | |||
f0a3611d1e | |||
df6226c7ca | |||
8dfa7305b3 | |||
850b49d802 | |||
8a14b75a47 | |||
c94ace5843 | |||
19c7a513f1 | |||
fbad88f9da | |||
2f29089bbf | |||
afe136fee8 | |||
9474cd96d3 | |||
c782ca5b93 | |||
e6fc726c91 | |||
7c17662fea | |||
f99e4eef77 | |||
c974ecb14a | |||
ab66c3f487 | |||
32ec420763 | |||
7fef41131a | |||
de12699fa6 | |||
d09f6f6144 | |||
27195dd34a | |||
b29850b2d0 | |||
02c0290ee3 | |||
43a70df1b1 | |||
247f20c8ef | |||
6fc72624aa | |||
3d1834cc5e | |||
eb056c4997 | |||
9aadf1a739 | |||
03377c6ced | |||
877e4acf7d | |||
aa84e13656 | |||
0e7a5f5c9b | |||
8fa8eed1e5 | |||
b2d5cdd4fc | |||
4529118fd9 | |||
651a7ac2e9 | |||
51b0ec1e98 | |||
ee16d98a94 | |||
2bbad443c0 | |||
a4f4d23693 | |||
a76e75f609 | |||
69986b3ee5 | |||
c2e8eaf9df | |||
41831d18b1 | |||
800fc95278 | |||
3e9262a345 | |||
a9fb563c01 | |||
e7a8258ac0 | |||
0322c043e3 | |||
e790a72e59 | |||
6ca3bae73e | |||
c89b2bb0d6 | |||
1c004cbd17 | |||
02357ab9de | |||
d4bf0e365f | |||
61ebd65367 | |||
e622662c16 | |||
7ee0732f56 | |||
75926432d0 | |||
3fde2aa7b9 | |||
27b3e50a64 | |||
b9540636de | |||
eef782fd2c | |||
5b602c72dc | |||
570890f2f9 | |||
ea5c95ac94 | |||
b74b692391 | |||
ac842108f3 | |||
12ceb10c75 | |||
037f09a22f | |||
f66ee9473a | |||
2194f91a55 | |||
df115333ba | |||
4307d2da9d | |||
c89dcca449 | |||
1df4dfad4a | |||
8fb6f291f8 | |||
fedaada5a9 | |||
381b6904e6 | |||
86e570efb2 | |||
1e38262d69 | |||
9e13694b21 | |||
7bf4f68ff8 | |||
19e7017d31 | |||
b398a0696b | |||
14ed73ed9b | |||
b97b145df1 | |||
c29ae50392 | |||
3557bf00fd | |||
67e4a4bbb2 | |||
f63f1abb7c | |||
1ef7d5ed49 | |||
928436a9ce | |||
5861ffadf2 | |||
2d88ae7503 | |||
82c83c5f18 | |||
19d6b7d98f | |||
d588ecea58 | |||
b6782da837 | |||
8bf55527ed | |||
2a11c07ba9 | |||
5720c98869 | |||
7fce28ad90 | |||
8c6460b58a | |||
70937d29e0 | |||
f1fc7a8968 | |||
40954c9a3a | |||
7d6f98d974 | |||
d658b7dfd5 | |||
87299bb893 | |||
0dba477eb3 | |||
9a5d80cecc | |||
89165d798b | |||
ec65fd17af | |||
5a1963647e | |||
d6cace3959 | |||
9502b6adf0 | |||
a8a2bd7755 | |||
a0cfa4900e | |||
3e26611e9f | |||
aaaf498ada | |||
bf12d7f4c3 | |||
07e4663e02 | |||
1de351794d | |||
a7f4008ec8 | |||
9e77dc3cca | |||
1ec728c2b0 | |||
ccea8a35f2 | |||
d63a85a15c | |||
2f434be75d | |||
a2c1d1175b | |||
181a198994 | |||
63a420ac21 | |||
3e5933bfd3 | |||
0ac1fcb471 | |||
531ea920e0 | |||
cf4a776a93 | |||
4f7dd37303 | |||
c0258b847e | |||
af65f11b68 | |||
79beff1d8a | |||
0b7c76ef49 | |||
970208b470 | |||
21c496d534 | |||
b7c5f2031c | |||
b1115475bf | |||
f979c72ca7 | |||
5e6b5f7400 | |||
4becb152bb | |||
761d482572 | |||
189f7d84ba | |||
020323ca45 | |||
d0d3a205b9 | |||
d01d39deda | |||
70f45ab7f4 | |||
d7c641ffc7 | |||
c21ccad823 | |||
5fcfcf4f23 | |||
5f3133a609 | |||
6a0708b676 | |||
fae9d75d6d | |||
67d9317f20 | |||
c5f9ef3e3d | |||
9d7888814c | |||
bba2f21f5e | |||
f8448c2521 | |||
bc862698ea | |||
cf64de66c9 | |||
dbe1427e7a | |||
b6bd869d5f | |||
a6ee6efb6a | |||
6ae3821e12 | |||
9e278dc812 | |||
2323afc0d3 | |||
2ef16d5f9b | |||
b605bf3a95 | |||
91a377b015 | |||
2544b19525 | |||
34abca6af7 | |||
111201f212 | |||
cb2a7d02ba | |||
a3e4d4c99d | |||
32d39410da | |||
7ddfc63327 | |||
21d872519f | |||
8fe9add310 | |||
0d9571e43e | |||
47624efd24 | |||
ff52ee58e4 | |||
f635292dfe | |||
99def3c0ef | |||
91a32d9d51 | |||
9e8033e36e | |||
eebfb7a6c5 | |||
8ec54169f3 | |||
6f58a875de | |||
b7035d3cae | |||
7effc2f873 | |||
2b1d04938a | |||
fa0396a764 | |||
d2d3880f23 | |||
cf842e8ebf | |||
f50a40dfb4 | |||
e3f0cd4fc3 | |||
8f102f7316 | |||
b904b4875c | |||
c3f96472c2 | |||
72b7906949 | |||
612b84ceb7 | |||
a4f0add6e1 | |||
3ac2d2c22a | |||
fd9863d919 | |||
b3ad1b1419 | |||
d29f2fb251 | |||
1918485d8c | |||
eeac12fe49 | |||
78426087d1 | |||
1eafb231af | |||
154ac3a8fa | |||
4888f538d7 | |||
d23f7af439 | |||
bbd9bbeb46 | |||
75ec058910 | |||
05feff3d28 | |||
02da351c3c | |||
c7b78779e5 | |||
bc087ab74d | |||
6315befa32 | |||
984bb76c5f | |||
1299e1e4c9 | |||
f3236538a0 | |||
b4d02c3c56 | |||
3194777b98 | |||
f593af9b12 | |||
889d2f8482 | |||
7ea79d10a2 | |||
cd1171b4c1 | |||
97a2805a8a | |||
a119e5c4a0 | |||
ebcbabde95 | |||
a863c2c553 | |||
253d820225 | |||
f1c04be695 | |||
7d418d6cbb | |||
e7b6fdf0c3 | |||
56d5c77c76 | |||
336504c306 | |||
c54e7f9f47 | |||
a740074fd9 | |||
1de0294642 | |||
9443f4e848 | |||
f4cbcee1b0 | |||
56883f655a | |||
24b53c9ff6 | |||
230f26156a | |||
42715720b1 | |||
31a7228509 | |||
6322132921 | |||
70bff5616d | |||
9bb0b02261 | |||
ff25f43eeb | |||
b8322d6aa7 | |||
23bbdb08aa | |||
ac9bb8442b | |||
eadd225363 | |||
a7c9cf5baa | |||
5de26dbee9 | |||
a9f5f72218 | |||
bd4c4414dd | |||
1453c75c3f | |||
1800c2a892 | |||
7747114b2e | |||
48be6771b2 | |||
abb8566ce7 | |||
bc7bf9870c | |||
c3d8855ec3 | |||
dbc829b5f8 | |||
c4ff314a12 | |||
147d3b2a9d | |||
0e2e26ef88 | |||
a439102403 | |||
e1157642a8 | |||
91333d9009 | |||
9f6c374d36 | |||
aef952678a | |||
b73755b0c4 | |||
67378c7a48 | |||
10d58f2f19 | |||
ca23475620 | |||
5aaac56a1a | |||
6941b3cb7f | |||
b5b5d5e7d4 | |||
39a8bed4ca | |||
033565bfc5 | |||
b507b340a6 | |||
ae276a69eb | |||
5310c90a83 | |||
512fc5ca04 | |||
13a7a4b5c1 | |||
fb9e3e6a53 | |||
06d6dbe3a3 | |||
8263b17861 | |||
24d4276a7c | |||
8d8183eabb | |||
801c7a07c0 | |||
61d8884bef | |||
5d017b09b8 | |||
9a1fb0c0a0 | |||
88644314ce | |||
4ba458b9ea | |||
28c740ab67 | |||
2591ae9e8e | |||
0f3c292098 | |||
16646e1586 | |||
8978be2fd7 | |||
183226e190 | |||
185fcfef33 | |||
7a4ee6db27 | |||
ea11e5cfd9 | |||
ae7426b6ff | |||
f72d29b391 | |||
9a7ae60392 | |||
fc61500a29 | |||
bcdd548238 | |||
2e9d375f36 | |||
f436dfdd41 | |||
480a2576c3 | |||
f0253075d8 | |||
96a983b310 | |||
53bd9c2f68 | |||
ad6569cf06 | |||
4ac25d4bc5 | |||
878ac0d192 | |||
a0d10989ad | |||
36844f948c | |||
2b0afcacf2 | |||
beb9fbd940 | |||
0642ae58ce | |||
635b8b3eef | |||
bcd2e7cb08 | |||
91b48e061d | |||
89edc6aa30 | |||
be78a5809a | |||
ce6f188acc | |||
b8eaae3a50 | |||
9105a3db06 | |||
18a10a9efb | |||
e772c4eab5 | |||
177d4d78ba | |||
fce71f4a7c | |||
6ee71779d1 | |||
8a281bacd8 | |||
f8f692af05 | |||
3900e9dd81 | |||
58553d7691 | |||
f3d2dc1678 | |||
05ff8530cc | |||
0fcaa46095 | |||
1754c77517 | |||
999095b7df | |||
d39a6dd012 | |||
bc4f9b5f51 | |||
8e00e26054 | |||
c1607bd8e7 | |||
4ce2efe86b | |||
99bb58a7a7 | |||
49189d5649 | |||
74181d0783 | |||
b885673341 | |||
013e55a9a6 | |||
2e31325de6 | |||
0ad907982d | |||
2156c6ba97 | |||
e7d0ad93f9 | |||
a5bb486012 | |||
a3490af5a3 | |||
484ec95f24 | |||
d8495bdc54 | |||
2891c1e89c | |||
c8eee85b28 | |||
f0d985637b | |||
dc288c4c66 | |||
d810c3aec9 | |||
5f06cfe483 | |||
9db3b009f3 | |||
de6aa7df90 | |||
b72e49c902 | |||
63f412aab1 | |||
b756de003e | |||
aa96a2ad31 | |||
37cbc1562c | |||
5b555835e2 | |||
119afbdf2f | |||
31f814d66d | |||
d584dd7e11 | |||
ee2e2608a3 | |||
7e321d399c | |||
86d84b70a1 | |||
c5596f658e | |||
16ade38851 | |||
87d902d028 | |||
600313fded | |||
4641cb4b8c | |||
599c4ce769 | |||
b14a8a267a | |||
55e99b299a | |||
20e47aaadb | |||
55767d733d | |||
56dfa5ef40 | |||
39e70670b5 | |||
beac893dd0 | |||
6e655d457e | |||
a7db950a52 | |||
f4528e6f00 | |||
c35412625e | |||
4c3594b832 | |||
52bf7b116e | |||
6de6f8185e | |||
487a438f02 | |||
4bf4b7baf0 | |||
9c2607df89 | |||
05e806d762 | |||
68618da7f1 | |||
eb171c01f5 | |||
881b3eda19 | |||
a46c9e8403 | |||
9da36b9966 | |||
c2e6b13504 | |||
42d568ad2c | |||
4b29cdeb0c | |||
a3c204f978 | |||
0f1e7d5036 | |||
a6f70696f3 | |||
4c177b8d02 | |||
ecda9e225e | |||
a9d2b30d96 | |||
424bc4f7df | |||
52002c3e22 | |||
80b0e8ad12 | |||
b000a594f4 | |||
0ec242738f | |||
5e935189a3 | |||
0035910765 | |||
9e6b6c582f | |||
b7562362c1 | |||
9926c83ba7 | |||
6c0acfcfb7 | |||
babadbd955 | |||
64aa510abf | |||
0b38a88147 | |||
b3fa46ad10 | |||
5e30d3698d | |||
04de1ebc30 | |||
ef473b0f53 | |||
0e62103010 | |||
a4ef328d8d | |||
e5596d9d81 | |||
e47e54b934 | |||
b8de7e68b5 | |||
464c54b2cb | |||
6b46a55def | |||
37f4e0ff93 | |||
9277907c43 | |||
960862df27 | |||
1376177a09 | |||
d2994d501f | |||
1ea9d6c2ac | |||
0a6f8a76ec | |||
c57b42c22b | |||
f93061de29 | |||
06a15181c3 | |||
d11b704f22 | |||
48f96e9ae0 | |||
47b254a29e | |||
7ba2807b01 | |||
6121a3ab0b | |||
d8ab40d8ee | |||
8d119630eb | |||
5233fb2dbb | |||
234938ed4b | |||
3630ab8519 | |||
c463d1ddd3 | |||
44a8925b8c | |||
7216b8124b | |||
eb7f9ab298 | |||
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"}
|
||||||
|
}
|
1004
CHANGELOG.md
1004
CHANGELOG.md
File diff suppressed because it is too large
Load diff
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