Compare commits
1320 commits
production
...
dtth-fork
Author | SHA1 | Date | |
---|---|---|---|
Natsu Kagami | c72bd47bbd | ||
Natsu Kagami | bc0f856d72 | ||
Natsu Kagami | 0e4dd6ee39 | ||
Natsu Kagami | 0fa57fc0aa | ||
Natsu Kagami | bb440c5d28 | ||
Natsu Kagami | 66078a1867 | ||
Natsu Kagami | 3ff14d942e | ||
Natsu Kagami | 109b919c6c | ||
Natsu Kagami | 28fb3e4102 | ||
9bf50615cb | |||
dfa1123ac3 | |||
a0f2eb7305 | |||
1bd9ceb4fc | |||
082409a09f | |||
225eaf4a2d | |||
60289cdb29 | |||
a1c419b675 | |||
89e8bdf77b | |||
b3681a93ee | |||
ad7193d067 | |||
f05e3012e3 | |||
2aff1dc1fd | |||
99ee6c3979 | |||
4ebfb544aa | |||
cf2461add5 | |||
4937c5f77e | |||
0febcacb93 | |||
818f58b460 | |||
57db8778a4 | |||
9806d8ae9d | |||
522a324b0d | |||
5be30e0c80 | |||
379ef7cc11 | |||
2d23b15c8d | |||
fa3a0e23cc | |||
631730f2f2 | |||
f1822d54af | |||
4c0bc62ad0 | |||
84b3106f50 | |||
a2b88f1cdd | |||
b88376569e | |||
00e2ba0b34 | |||
a0d75e7e83 | |||
4b2ec14dcd | |||
808c6262d8 | |||
44d440649f | |||
a2f7638257 | |||
57d6889826 | |||
2a91c005a1 | |||
418895e1c3 | |||
180a23f116 | |||
9ea7a1f4db | |||
f26dbeb79a | |||
f0872e79fb | |||
a72400febf | |||
cb9848fe8c | |||
c950a6552c | |||
95bf9e183e | |||
e6e884f1cb | |||
b6a25f5939 | |||
71823fbad2 | |||
046d3d323a | |||
f7024f7723 | |||
1b3938f3d2 | |||
5ab0ea1b59 | |||
09745e3078 | |||
87be0cad16 | |||
04588874c7 | |||
5d6a43e5d2 | |||
7f5f01b118 | |||
f4a4913889 | |||
7fb4aad089 | |||
f8e72d1808 | |||
527a1551cf | |||
a6e6a7d741 | |||
21bdb6afc1 | |||
4be88da1d6 | |||
93bb1da7c9 | |||
497ede1a3d | |||
8a1fda5a85 | |||
83164c321f | |||
15ebf628f8 | |||
fbe540ca7f | |||
1f8a8f8928 | |||
febd04dd54 | |||
983dd6623f | |||
a79d0613ec | |||
c0c7fdd6e1 | |||
17a3939061 | |||
8a10a81fec | |||
17230fc690 | |||
88e36183c6 | |||
d0bb0c04db | |||
42d761e747 | |||
901725793b | |||
3fbecb2f0d | |||
ef1abbc25c | |||
2f75dfd9e4 | |||
8d91bfb0a3 | |||
04e1d60e54 | |||
1c01e1b0f4 | |||
dea3507053 | |||
9b35119f99 | |||
6d7eddc568 | |||
dac2af4334 | |||
2099953b68 | |||
5931ebb8fc | |||
adcb87679b | |||
5ead17a093 | |||
224cad4d7f | |||
e08817d611 | |||
1ffc1c257a | |||
098014a109 | |||
7546b42c7c | |||
f9a73777e7 | |||
d5584f8dd4 | |||
563b06e680 | |||
b6a64b66c7 | |||
0a4aae51b7 | |||
d16221e296 | |||
ed712d15f1 | |||
bd8817e61b | |||
ef712c62a9 | |||
9aa2bac685 | |||
34077e8467 | |||
b473061845 | |||
64c7b5b4f0 | |||
c11bbbb2b3 | |||
2c1a6c8cb5 | |||
67a85e1eef | |||
2e0ef6494b | |||
012b86d7ce | |||
0c45f515f0 | |||
9cc590be1b | |||
7589ec8803 | |||
cd17ca0b42 | |||
8aab997900 | |||
96c44ed485 | |||
7053fcc96a | |||
ad7cb46547 | |||
1b1af67064 | |||
bdd238de0e | |||
ced4dc86aa | |||
7be1e589ab | |||
7da1745cca | |||
025a5429cc | |||
62f843b4dc | |||
b0a53b7fa1 | |||
9934daeb4d | |||
d4a0a080b5 | |||
bc4e3b0f72 | |||
ac760265da | |||
98b0ccf032 | |||
90f06c511a | |||
e7aad03279 | |||
1c6b0aa0d7 | |||
3e1b9ff53d | |||
5c9a47c31e | |||
65a4c3441c | |||
77bc06545c | |||
11e64a2cc4 | |||
5433e4e119 | |||
c8dc32b884 | |||
1f29aee26e | |||
daae055f4d | |||
044f754d7e | |||
5ae2058c07 | |||
7376cb1e99 | |||
ffbae70178 | |||
9235d2c800 | |||
6ccefaebe1 | |||
5a448c8049 | |||
9bf77fa97a | |||
b9058c6e3d | |||
55ad6500bc | |||
f4b95d254c | |||
effbe189e1 | |||
44e910b8c9 | |||
a68dccd7cf | |||
9a6364a674 | |||
e2f39596f0 | |||
701b9e99b3 | |||
294ab2bf00 | |||
304ce5a3e8 | |||
57390a291b | |||
cd5920114f | |||
06c6360cae | |||
afdfdb86da | |||
6f8f3e4fd0 | |||
342ff20986 | |||
94996d098e | |||
c286562ee8 | |||
5babdc9d63 | |||
260bb8746d | |||
7be620808f | |||
df3aca70fa | |||
ec65163c89 | |||
6f22ec3842 | |||
2faf9b4c20 | |||
501e43207b | |||
e782cc0dde | |||
aefda31c2a | |||
9285a0ba9a | |||
7fb56d9f6c | |||
f7c69e56e9 | |||
c3bcf3d595 | |||
0efa39b825 | |||
a0d2037007 | |||
6e73728e2b | |||
60920966d6 | |||
5083463942 | |||
8b5fee3dfd | |||
c9124bf150 | |||
b85174155c | |||
5c9f6bae3c | |||
4e5940900e | |||
7fa0b4f076 | |||
ecfcc68f15 | |||
015ed5e7eb | |||
2ad9706304 | |||
30382d088b | |||
80196f83ca | |||
419ad34250 | |||
ed0d714cf2 | |||
708976a9e9 | |||
d77ba19308 | |||
b10e22a9a2 | |||
36d8b62e1e | |||
989e788d8e | |||
ebd9f05f69 | |||
5246af4ae9 | |||
e6ba72f4c8 | |||
960dff8b9e | |||
e3c25d25ee | |||
090320150a | |||
7100937e79 | |||
c18efef7b6 | |||
ff336628f8 | |||
28882d98d9 | |||
f6ad22e58f | |||
aa664e15f6 | |||
f2f203c9d8 | |||
ae0e4a0792 | |||
4def6eef5a | |||
1004a5f176 | |||
2b6beee875 | |||
e35e02593a | |||
5e56ba9fb9 | |||
a7cc0785f9 | |||
bb5d34c94c | |||
671d2c9bb1 | |||
49fa48bd28 | |||
32fb406629 | |||
6950698935 | |||
fd9d8059bc | |||
3b975e899b | |||
b1950046d4 | |||
d2af509eaf | |||
311160983f | |||
9d7d5df7f2 | |||
927430853a | |||
1692637e22 | |||
2bc24cc495 | |||
66e58c74ef | |||
e3591514a1 | |||
4abb1aeaed | |||
7cac17a043 | |||
7049166b40 | |||
0a695410d9 | |||
d671178c02 | |||
67a05450cf | |||
438b520970 | |||
c8c96f08ac | |||
c9bbca9e11 | |||
39800e771c | |||
b1c81f7d71 | |||
53e9aac14f | |||
cc268019a0 | |||
9d16c6c12a | |||
27a7bc7627 | |||
1a2914362f | |||
9c8aff6d32 | |||
6816a4b64a | |||
13f5621488 | |||
fd59a39021 | |||
c19096ab1b | |||
0fbc566454 | |||
f6a9f7807e | |||
8378d6fc1d | |||
5ccf8b6842 | |||
d6b65d0413 | |||
8eb67f469c | |||
717633e422 | |||
f6c2097a89 | |||
5695b3ca1e | |||
15c113ecb1 | |||
4a75d6f172 | |||
8f43099840 | |||
a2743f9940 | |||
4c2210c68b | |||
da909e4084 | |||
552ad249e5 | |||
9a5704ee95 | |||
c7f68c8971 | |||
e8219e458d | |||
6157ee105c | |||
4718ef36b0 | |||
2723ef4593 | |||
d1965a84b5 | |||
c7762cc56f | |||
cf05568e0c | |||
69c47489e3 | |||
861ad83423 | |||
cd3ed64e48 | |||
2e28c147b9 | |||
fef033b282 | |||
3dbbba0be2 | |||
0b8cbbef51 | |||
f72ec0aba5 | |||
d63e6c87c4 | |||
f5ea96a093 | |||
0e1be5dbdc | |||
4843970e1b | |||
a0367f4860 | |||
687a08b2a4 | |||
ac07479edd | |||
306a96eec3 | |||
061d769901 | |||
cf1c10b338 | |||
7f6ef4ff96 | |||
ce190cbc50 | |||
e7e4f15234 | |||
c005745ad0 | |||
0b81b5bfd2 | |||
b48d32e503 | |||
ed309b289f | |||
ecc5fc5bbe | |||
7eb77f5d1b | |||
3f4832965d | |||
b7ed27ef70 | |||
c9a48cf482 | |||
c0ad216227 | |||
8a9f1a3c25 | |||
375c4b5d00 | |||
f522d8e932 | |||
bd46af6166 | |||
29e9e15d3f | |||
42dac0720f | |||
d348c458b3 | |||
427207ae5a | |||
531147cbc3 | |||
e0c2570875 | |||
2b2f6c28a9 | |||
4a9cae9cb6 | |||
c578b41105 | |||
cfdbecc608 | |||
7c81548320 | |||
8cab77415e | |||
8b36cef510 | |||
4e67edac5e | |||
0bf5ef52ac | |||
7a7d51f56e | |||
48e1a0753a | |||
195c2e2960 | |||
60c0d1cca0 | |||
6292557bc9 | |||
b79ce92aef | |||
6bb6b9c350 | |||
0b4c720153 | |||
02d1339b29 | |||
93c871353a | |||
641d22a7cc | |||
0fd378811f | |||
afb1f6d520 | |||
fcb0074f49 | |||
8108151fb6 | |||
d8b0adfe97 | |||
cef4e6373e | |||
4d138f5773 | |||
0db10bf7d0 | |||
7ab6da5e9b | |||
beed3ca18c | |||
abd5031602 | |||
346dba9ed7 | |||
0ceb6ffd06 | |||
488aece050 | |||
ecde88d6a1 | |||
94dcd1606a | |||
b479fa1f35 | |||
ab0472de02 | |||
1bf8616957 | |||
631333ba9e | |||
69d77c368e | |||
bb3621e424 | |||
e1447053b3 | |||
aaf64bbc34 | |||
52b60fa38b | |||
3acfc00ec0 | |||
f8b5e9563c | |||
6f3f83a620 | |||
315ce98511 | |||
3cfc35898b | |||
ffc216cfed | |||
35e34c0bc6 | |||
b023a43fee | |||
44f6d9cda0 | |||
c466e0c279 | |||
fa99debabd | |||
58778aba45 | |||
b913c8817d | |||
ffb7ce1c63 | |||
707b51a1a0 | |||
201ca6ce4a | |||
a419bb9b61 | |||
a8b5c8cd64 | |||
a3236ea0f0 | |||
c595b0ee31 | |||
89f34d7942 | |||
f23e4b0dd9 | |||
e7d2d088ba | |||
bf609b979e | |||
6a6162ec6e | |||
03e5c3ff54 | |||
d8e824b548 | |||
e5d36b82bb | |||
b6721fc58f | |||
246862e0a4 | |||
65e048be17 | |||
cd96ba0c59 | |||
9803d18185 | |||
fefc121b11 | |||
3d08349851 | |||
1478aca7a5 | |||
dab0d61ac8 | |||
14b92f3f98 | |||
49cdba2652 | |||
2f94cb34f6 | |||
b7a79c8fdd | |||
2f0d04eca4 | |||
daabd85273 | |||
c84ad73d0d | |||
3295b1ab96 | |||
5d7b67a410 | |||
4e85f92f4b | |||
9d80647b11 | |||
24a481b782 | |||
97cce8a828 | |||
3c31c56306 | |||
92f4371041 | |||
a9d0100087 | |||
3fbe11295f | |||
98f018913d | |||
60ca577f9b | |||
1d0d02f39b | |||
fbd448c152 | |||
038b2b2e6b | |||
169aa2d3d3 | |||
9a9667d824 | |||
afd9d2cf97 | |||
b9c287b29e | |||
436277c6b4 | |||
4f28d3cc6d | |||
46415b87a6 | |||
913d923877 | |||
36f38230c4 | |||
a66a4e238e | |||
aa7fb4441f | |||
f1dbb9ec42 | |||
a59668ea9a | |||
6581bc2881 | |||
28bb66f185 | |||
46d7cba1ea | |||
ff35c458c3 | |||
26d445af7d | |||
3470b9adec | |||
f3d77dd04e | |||
14f5c37721 | |||
94c59c47d1 | |||
a66307b757 | |||
9792700f30 | |||
36e852bebb | |||
6075542071 | |||
0386357688 | |||
9cac63c37d | |||
5cfcfdc98b | |||
a2d995ec07 | |||
4ca9a802e3 | |||
990f2b2e29 | |||
725da37063 | |||
1b41d39032 | |||
23dd7f5a7a | |||
7d95c50c7a | |||
a352f94c2c | |||
38e2b176bc | |||
6b4c1c8505 | |||
46dfd9aab0 | |||
59d0138ca8 | |||
3fbd5b8622 | |||
b6c4045cb4 | |||
37c784dad2 | |||
04d431cf71 | |||
97458b66eb | |||
fadfc6052d | |||
0ca92e7509 | |||
b8484eff79 | |||
1017d1d270 | |||
04179340f6 | |||
9b0889fe23 | |||
79e87b7d89 | |||
0ebc0fa64c | |||
35974cc89c | |||
00675c827f | |||
2b3f65f28c | |||
500f877d4b | |||
4b9ff0ca5b | |||
07f927d4ff | |||
8c6563a671 | |||
ffabd6188d | |||
d71b1a7e36 | |||
c47687e2e4 | |||
5b0d6dd58b | |||
ecd5c7b91e | |||
96387c8abb | |||
35958d429d | |||
99b0b7c096 | |||
e44ac16396 | |||
147a12cbcb | |||
16e2ac9bce | |||
1574be2b35 | |||
7223baaaad | |||
9cffd429b0 | |||
9a5d749b8d | |||
e43f2283dd | |||
be5fcc35ac | |||
54314de976 | |||
bc2886f7e2 | |||
2bc1b8387e | |||
3989b218d0 | |||
a8331375ba | |||
6919975c6d | |||
c0987209a8 | |||
d25c2df392 | |||
848433365d | |||
3d4ebb8abe | |||
72dc4cc81b | |||
92c0a8b4f0 | |||
1adcca5666 | |||
b4d4c61128 | |||
764125e6b9 | |||
098df0ad2c | |||
e41e49884f | |||
852f7090f6 | |||
d54511aa10 | |||
d8ceb03d74 | |||
df393ae959 | |||
0ebbc5b34e | |||
cf52e0776e | |||
b168707c14 | |||
d2fb86036c | |||
62c8a51307 | |||
f056d7407a | |||
c3e40297e0 | |||
d6099df51b | |||
096bc69584 | |||
32d32b72f4 | |||
796b365fd8 | |||
bd38122f1b | |||
d7d838ebf8 | |||
de3787209e | |||
6500be2782 | |||
2240380f68 | |||
f21a65da9a | |||
a97478097b | |||
71d2db31e0 | |||
88547fa403 | |||
1765defa56 | |||
437d721c26 | |||
e13a2feec8 | |||
39bcb01894 | |||
7fb0044471 | |||
f645815b84 | |||
f5b1b924a5 | |||
fe54eb11a7 | |||
cfe41cb802 | |||
53b1755e51 | |||
ef8dda2dbb | |||
66a519f4dc | |||
ce6d14fa04 | |||
bc5a4eaf3c | |||
b89463d412 | |||
72c5411347 | |||
d59ee9169f | |||
3a3858bd72 | |||
69571bf817 | |||
a539cfea0a | |||
976f0a5592 | |||
f520e30858 | |||
563a7bf03b | |||
ae57d95045 | |||
2923c23672 | |||
7cfa839e1c | |||
94075086ce | |||
60fdd3f522 | |||
6dd54633e0 | |||
088d795595 | |||
c54a15de11 | |||
8ca768b957 | |||
6703b27bfb | |||
3cab36f24c | |||
30403f835c | |||
486a707f49 | |||
5d95d602a7 | |||
b00033129f | |||
768477ea6c | |||
da58336285 | |||
6bcee318e4 | |||
49fd8a5dc9 | |||
5f48f92c11 | |||
3e4e4d179b | |||
92d6fe7ebe | |||
33b55c937b | |||
cdc19f83b0 | |||
22b9a33d64 | |||
c1fe8c2b0e | |||
a2189bf44b | |||
ccecc16a2c | |||
7b246fc660 | |||
dfe727b702 | |||
bee32cc781 | |||
9983c8086c | |||
8ce720f305 | |||
c16532d4c2 | |||
ac60890c9a | |||
ec4320d53e | |||
4c7c518d4d | |||
86a362c619 | |||
4344a4fa82 | |||
2921d098b3 | |||
fede3b5f0c | |||
e0c2e41755 | |||
df16cabec5 | |||
60e86c1eaf | |||
2eea5ae053 | |||
aa8cbe046c | |||
b34ef09411 | |||
9985ffa5b2 | |||
5ac0d413cb | |||
aefbfd59e5 | |||
5fef0b3fb5 | |||
f213a8e094 | |||
82195a8db0 | |||
a9c624dc59 | |||
71454d40a9 | |||
433d8b3bcc | |||
7dd0b0a4fb | |||
a039f84c9d | |||
ceb92a4bfc | |||
8009a8d743 | |||
5be3e22467 | |||
94c2f43c38 | |||
92a109d25e | |||
66746eb579 | |||
222786f202 | |||
99b4842586 | |||
2563b23a31 | |||
ac05fabf05 | |||
902149e9a7 | |||
f7e755d0f0 | |||
cbb7378601 | |||
012e944a53 | |||
f98306ed18 | |||
810596b7cf | |||
2ad72a667d | |||
bfcb314324 | |||
Natsu Kagami | d1bfbf1ef2 | ||
34e2fe320d | |||
af503ac865 | |||
89fb1bbc07 | |||
d27de2337a | |||
910b72ba8c | |||
cbf4ea5060 | |||
a579f27d55 | |||
4f41646000 | |||
7019c09e5b | |||
1422c5da33 | |||
25e13144a3 | |||
b7a0d4fe28 | |||
7967194b89 | |||
4b617b7b9a | |||
b74f6b3168 | |||
6553ae0b6e | |||
b22e7c06a7 | |||
fecebc24a8 | |||
b269d9d660 | |||
1383296861 | |||
eb203a0498 | |||
85bdaace58 | |||
d0e0248dd6 | |||
d87f60665a | |||
19ed85f298 | |||
d6afb473ee | |||
fc842e776e | |||
7248095a92 | |||
e049539b91 | |||
770f4d9205 | |||
3a326194ad | |||
911ee288df | |||
91f6efe736 | |||
b40357c54e | |||
97188391df | |||
82a9a7212d | |||
dc2eb1163f | |||
469ce0df6b | |||
1dc397c066 | |||
1882338078 | |||
51ddf9b030 | |||
98d1f44244 | |||
d16cd501d4 | |||
6d5b2ef9a6 | |||
a1b0d6e3bd | |||
a8cf7879a2 | |||
b027967168 | |||
bca205182e | |||
ea660f9146 | |||
8f34d98f47 | |||
180466160b | |||
90df455d6e | |||
6e3494488a | |||
f73a942b61 | |||
8d41ff6884 | |||
540b9a15a4 | |||
678fc100c8 | |||
305710fa8c | |||
83bdc82049 | |||
7c8d310ed9 | |||
5a4f1fb686 | |||
b461823d60 | |||
986187141e | |||
d0890e3633 | |||
42df8e62c5 | |||
87d0b86ecb | |||
e5d5025299 | |||
2c6d18bcfc | |||
9f31cc8e07 | |||
660cbebbc4 | |||
f8674963b3 | |||
fbfb5e5441 | |||
5038e1988d | |||
5f50df1721 | |||
7ad6151637 | |||
8c8ff72e53 | |||
e42d660756 | |||
674e1fd1ff | |||
44ffd69941 | |||
21007e0a4d | |||
a53be08b3a | |||
8e341ff7ed | |||
e0cf2e22fd | |||
f726f47fcb | |||
dc1452ab30 | |||
1f039a4d73 | |||
0bc1b598c3 | |||
1cdc4ebbe8 | |||
e1434e15d9 | |||
bd798865d8 | |||
fa9e0059c0 | |||
89f82707d6 | |||
7f327e5980 | |||
05ab42684b | |||
131b91e2c1 | |||
490d776a70 | |||
6b3602c6ae | |||
ab5a115084 | |||
fd7caca039 | |||
48b505b382 | |||
0c2d79c159 | |||
93e19f549d | |||
38ee094405 | |||
a9c3c6fdb4 | |||
bf7acb6eab | |||
030728bc93 | |||
706f3f0cc8 | |||
d9dab6b5ee | |||
d35d0cbe18 | |||
ff7db6212d | |||
0c3449aba4 | |||
3361ffc366 | |||
616b9fcf02 | |||
7119a78711 | |||
33f807de73 | |||
caeeffaa72 | |||
ecb1be5776 | |||
0cc956b8c0 | |||
e6ef2f9064 | |||
478271348e | |||
6ec7073151 | |||
22abc2fb31 | |||
bc0197a5f1 | |||
f3dcd9f4ee | |||
f5808b6f3b | |||
5cb0621f34 | |||
3f6402349c | |||
b17977a5c7 | |||
afb80d3dc6 | |||
1f78bb9c09 | |||
c67192bb81 | |||
33b989fffc | |||
39d97a51c5 | |||
d5b257b130 | |||
3c790ebff4 | |||
99f81c49c4 | |||
5f64553d17 | |||
75bca8ed3a | |||
290243df0a | |||
5fae5d8cf5 | |||
85f966bfc9 | |||
3760b52860 | |||
3092a8bba1 | |||
146e5d1a7e | |||
b28d2d590f | |||
ab29c8c89e | |||
77312f3fb2 | |||
b40bbb32c2 | |||
35f7cae01f | |||
8180cc357e | |||
25ff0d7835 | |||
173728536a | |||
0599c0d2c9 | |||
a1021e1aee | |||
087e282677 | |||
4efc922b7b | |||
372e86415b | |||
6dd6e0e77c | |||
c022e2fd00 | |||
713865a094 | |||
0678366566 | |||
b6d8c46e2c | |||
065add5575 | |||
35dced8eaf | |||
2310664065 | |||
8858ce3e89 | |||
1c87dd6e41 | |||
0038c2225b | |||
c35f4bb161 | |||
8426a011b0 | |||
81644e67bb | |||
c03f39b10c | |||
a1b81562db | |||
c82ccf5957 | |||
8ee1c3a2e3 | |||
5d5ab906ba | |||
3a32cbf974 | |||
b9afe4fb66 | |||
a192554b8b | |||
c2ba149563 | |||
44a20b42a7 | |||
163ef4ce91 | |||
beff01c976 | |||
f19326528b | |||
05ee27e045 | |||
576dcf7701 | |||
0247c041f2 | |||
7555bda8e9 | |||
61756fac1d | |||
4897847601 | |||
8bf3f31056 | |||
f2c2983663 | |||
2c4dd0cdb7 | |||
58d36d2403 | |||
72842c663a | |||
8d694ecf1b | |||
cafadd0980 | |||
3a1341fb17 | |||
ced30a9602 | |||
4e53b1e17f | |||
1c5453cfb6 | |||
e7ef20f265 | |||
9c4252315a | |||
2149c4c35a | |||
18b00f7b28 | |||
a6cdd0a01a | |||
ddc8c1e9d9 | |||
0d4303861a | |||
a222828306 | |||
5850485207 | |||
839647bee7 | |||
749d6880b8 | |||
5a616633c6 | |||
47c2efacfb | |||
4c4e89ac9d | |||
4da968df2e | |||
c6f368ac0b | |||
87e243ea58 | |||
66f9c3b918 | |||
137ad7f4dd | |||
8ddc44fba6 | |||
3721acf3d3 | |||
ab7df0f66c | |||
d1aedcaef2 | |||
691aea3389 | |||
72f204771f | |||
dba921a3fd | |||
4646859177 | |||
66fa6fbe52 | |||
861619ce57 | |||
71bf8608e6 | |||
2916d1146b | |||
d62712d587 | |||
a37c3d6081 | |||
73e995f494 | |||
1dc0069cdc | |||
a5532488aa | |||
c9545cdc34 | |||
e9075906f8 | |||
3339c5c1d6 | |||
965f948899 | |||
c0c2bb45fe | |||
106cd16e41 | |||
7145c20136 | |||
e4b6637680 | |||
cd57e97e2b | |||
c1588322aa | |||
3eda1e2267 | |||
3617bdc9cb | |||
26cf40dcea | |||
8ae9131543 | |||
1b0a77dfae | |||
e3f58442aa | |||
119dae29ca | |||
c538cfeaaa | |||
e153f9f541 | |||
42db913b22 | |||
834b1fe1e1 | |||
809b7cc2d2 | |||
673001e4e0 | |||
54e69ed23b | |||
7e1bb08b1b | |||
32b72f9297 | |||
57dead7960 | |||
9786752a4f | |||
ed8c9e994b | |||
8cf30773ce | |||
6540dd5642 | |||
c80c8b3294 | |||
e1ae89b00e | |||
f9299ac15c | |||
df9eeeb0b3 | |||
32bf258bbf | |||
f56a44ac97 | |||
0a7f158b70 | |||
ab1b34d4d2 | |||
f2f7b7fe1f | |||
7264f543bd | |||
66e4ba4991 | |||
f6864f96bd | |||
f67d4fd916 | |||
cd403fe605 | |||
5481aa12be | |||
806ad2c6a2 | |||
d1b8d737cc | |||
a095a30500 | |||
5de7eec2ca | |||
b8767f3618 | |||
68759e64d1 | |||
78a6f13380 | |||
a697fb04df | |||
39f7d4e00d | |||
12d0e6aed8 | |||
769a5cb099 | |||
d6d10d091e | |||
5c6e9756d0 | |||
eace6c4d9b | |||
4723358d2d | |||
aad855cafc | |||
643b6bce07 | |||
5faf911b17 | |||
ddd1ec5819 | |||
8cd3e38f22 | |||
be964f933c | |||
d429ef9161 | |||
9885c8f388 | |||
8be2c738df | |||
faa7ffc310 | |||
4ac2e4aa7b | |||
60d55d45c2 | |||
4436c337dd | |||
c335655896 | |||
48f1527cc6 | |||
fcbf99f121 | |||
028b30a334 | |||
5793476223 | |||
715357c8c9 | |||
56365ebc39 | |||
a1a78370cc | |||
7e993704cc | |||
f05267b216 | |||
634e81e9d0 | |||
52c63690a3 | |||
348efe0069 | |||
9f6236762d | |||
8a4ab1bdb9 | |||
a32a264159 | |||
a364488895 | |||
d05f0a4f23 | |||
49fdcf7837 | |||
baa2605d27 | |||
359fd92ae0 | |||
6a16b25722 | |||
4dd706ff96 | |||
30f6d50a68 | |||
3042dea886 | |||
ac14e61b6d | |||
27b0813e49 | |||
99d7525436 | |||
f9cb9502b1 | |||
01c90150a8 | |||
c1da6b8767 | |||
dc06508aa5 | |||
8c4a88b333 | |||
8a10ffd477 | |||
b6c59d4ee1 | |||
13cf7b3f92 | |||
fd1b45900d | |||
0f5edef199 | |||
4dfc0d0b41 | |||
b7416bc17d | |||
173cad2275 | |||
0403fc35f4 | |||
077b655c44 | |||
eeb89212d2 | |||
cb04659ab1 | |||
d478dbddba | |||
cb36308790 | |||
d4dca0e81f | |||
f8fc24aca4 | |||
7ba5ee5fe2 | |||
4c3666df6a | |||
e3b0c31798 | |||
da03de4115 | |||
34fcf5e8bd | |||
d6499cf7fd | |||
1e9f0bdf39 | |||
cd3ab50a18 | |||
f6ab5e9afa | |||
b1dec8810b | |||
a10e2804ba | |||
bd7e099f6e | |||
3d06662559 | |||
1f584f945a | |||
a816b69ee9 | |||
85a4b382da | |||
7ec1cd1e3d | |||
5661729748 | |||
551de5a37c | |||
38bd5c0b5d | |||
9387e37baa | |||
baca2b5851 | |||
7e01b4a33a | |||
674c99a05d | |||
b3501d158f | |||
c955427d8f | |||
56e846bec6 | |||
4acfb2a1cf | |||
f9b2ab3b94 | |||
42f9483491 | |||
fe80215325 | |||
f7ffce1b46 | |||
64db69af63 | |||
59dae782b2 | |||
dafff4b635 | |||
887503e40b | |||
1a714d214b | |||
941d2efeb1 | |||
908efb17ff | |||
7d28744234 | |||
679fba4f66 | |||
ad831fae35 | |||
e102a9f925 | |||
9571271d83 | |||
b116cbfe8c | |||
b1030cb38a | |||
72438bbf06 | |||
f3b81bc540 | |||
020d8e3631 | |||
dac07a35d8 | |||
6db40d7d3e | |||
0b5693ae27 | |||
7a30cc4b12 | |||
d18db56032 | |||
27274eeab1 | |||
fce5e45bc9 | |||
fa145d3ed0 | |||
244f3325ae | |||
ec57c75fa0 | |||
5ac255f808 | |||
62201b0250 | |||
f02cd50d7b | |||
61e1a5042f | |||
2145f761b5 | |||
979c3b1498 | |||
c4961b26bb | |||
aa3033b4ff | |||
641d274d7b | |||
3fc3641437 | |||
b57d8adf18 | |||
dd2ca7bf35 | |||
f5184bd608 | |||
671c68b8f8 | |||
fcfc61c93b | |||
98e82a68fd | |||
71f177bebe | |||
a0f16057a0 | |||
2d94f229c3 | |||
33698c91cc | |||
f4ce2e8367 | |||
059fed4b84 | |||
886d78bde8 | |||
6b5a98ebb3 | |||
696a46311d | |||
fea1d77342 | |||
8018d06cdf | |||
5147efd123 | |||
d4fc54eaf4 | |||
c82edd2778 | |||
301b2576c0 | |||
5969ce7d06 | |||
10288411be | |||
7c09485e26 | |||
3ce8b75e3f | |||
61f2132abd | |||
1c295c585b | |||
aa12010b80 | |||
10471090f5 | |||
6e4110714c | |||
67fb1a9b19 | |||
0d090eb555 | |||
167fa70fd5 | |||
e4174b49d5 | |||
2540135962 | |||
e7833d5b8c | |||
20c80adfc6 | |||
4fede554e4 | |||
20dd843409 | |||
b472e496d1 | |||
17a289ac22 | |||
eed9b70a7d | |||
0fd719d3e7 | |||
3511ba760a | |||
bb1850a330 | |||
a9109f4839 | |||
c5766e431c | |||
6c3a700f01 | |||
8cc85ecb1a | |||
6cbbd0aa1b | |||
d4dce2fa45 | |||
39d96f22a0 | |||
3ac05d8cdd | |||
1257ce8636 | |||
062f42a05d | |||
852bb27e81 | |||
0e745663f0 | |||
0b04e01d60 | |||
5461b06130 | |||
91419b3243 | |||
a5865825da | |||
4e47d679bc | |||
2ebf421140 | |||
8bfc9892ed | |||
d64bbb7acb | |||
aae74aa476 | |||
12b8651d18 | |||
1fae2f3208 | |||
1b3112de1b | |||
0792df1adb | |||
de7248fbfd | |||
e8cc26fe2b | |||
b22ea39e6c | |||
4aaf308d6e | |||
e88b24fe6f | |||
aede10d71e | |||
95f71115d4 | |||
e0c2a5aed1 | |||
ccd79e5348 | |||
a325630c20 | |||
1559052361 | |||
d2e417eaa4 | |||
4a423b134d | |||
ff3ef9fa45 | |||
bce8456ac6 | |||
de10faee88 | |||
d64a363d60 | |||
6755626259 | |||
6ddbcacd76 | |||
271601dc2c | |||
f7343fd4fd | |||
932e66f330 | |||
b922c2f096 | |||
8790b20354 | |||
4817eddc2a | |||
c1f947a9c3 | |||
e3c77cb516 | |||
fe8eb74242 | |||
d0bd257a8e | |||
8141513fa9 | |||
ac8a4c7fbf | |||
a382efee5b | |||
635f4c1b0d | |||
d237fb8320 | |||
2ba2696e9e | |||
31d7016bd9 | |||
8b74a32168 | |||
37ce48ae6e | |||
5b8744ac55 | |||
339b66f42f | |||
84d1500331 | |||
889fdc87a1 | |||
1ecd568c29 | |||
bf39f9eafc | |||
79aa3faf51 | |||
0ca29cb181 | |||
3d458826cf | |||
58c6b6349c | |||
fb798ce895 | |||
c3f80cec9b | |||
9a44dfafa6 | |||
a8c7e08f3f | |||
e53f0efde9 | |||
794ee3cb74 | |||
9b23e051e2 | |||
0b3875c2cf | |||
c13e148b36 | |||
6b8ae97d98 | |||
d36ea02a02 | |||
76823b8497 | |||
1887a34fc5 | |||
88accb2a78 | |||
c91cda1a2c | |||
dc7083a11d | |||
b0ed0be47d | |||
75cfd02134 | |||
e7f624c33c | |||
Natsu Kagami | 509efd2ce0 | ||
c30eaee4e2 | |||
72ff229dfb | |||
30d532c2e3 | |||
b1b1ed0f3f | |||
48a5fc6327 | |||
c28bae7708 | |||
031bdc0a88 | |||
8cd00a053c | |||
3fe99050e0 | |||
ba9cf70f44 | |||
507d8f449a | |||
cf59b9dda1 | |||
760fdb66db | |||
c003724108 | |||
fad286e617 | |||
14091fbc7b | |||
6fe182a7a3 | |||
871fe11d0f | |||
b0808305ab | |||
4bf6b00b94 | |||
5fa02f9cc4 | |||
32a853ecc0 | |||
d8b385a742 | |||
bc3e946f61 | |||
eb13fe8ce0 | |||
28ad18bd0b | |||
9869c9dc5b | |||
ac9962b051 | |||
075c729807 | |||
587864893c | |||
658872cbd9 | |||
5502d08d28 | |||
58bf8e16c2 | |||
4aab2d39cc | |||
6f28db2532 | |||
8112f0a9d6 | |||
9b0e63d289 | |||
da425b4a70 | |||
7286a4e03b | |||
1f0d2eebe6 | |||
38a13b07c5 | |||
92a4f502a0 | |||
ff41cd3563 | |||
10fa537a56 | |||
473dac1fde | |||
18a5742bfc | |||
df047131bb | |||
3192c319ee | |||
42633f87ea | |||
1ef9613358 | |||
48b21ec42d | |||
afc13c0d7e | |||
5791338393 | |||
1e28efd9bb | |||
fa21eec06a | |||
e26473f607 | |||
0c86416489 | |||
fd1fc9c5fc | |||
4dbc26dbb6 | |||
7fa7276a43 | |||
32f2a6d99b | |||
4bfd36fa9b | |||
6956628369 | |||
470f7aa353 | |||
db0261f8dd | |||
44eef9ee3b | |||
41d1956ae5 | |||
b02cae4967 | |||
41fa08536e | |||
7d793f19b3 | |||
06c533cd47 | |||
471ab69182 | |||
5f67a29e1a | |||
d7a46ba0d6 | |||
2eba4eaf59 | |||
e6880859ee | |||
97f7a066e2 | |||
f67fdd5759 |
7
.env
7
.env
|
@ -1,3 +1,4 @@
|
|||
VITE_CLIENT_NAME=Phanpy
|
||||
VITE_CLIENT_ID=social.phanpy
|
||||
VITE_WEBSITE=https://phanpy.social
|
||||
PHANPY_CLIENT_NAME=Phanpy
|
||||
PHANPY_WEBSITE=https://phanpy.social
|
||||
PHANPY_LINGVA_INSTANCES="lingva.phanpy.social lingva.lunar.icu lingva.garudalinux.org translate.plausibility.cloud"
|
||||
PHANPY_PRIVACY_POLICY_URL="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -10,6 +10,8 @@ assignees: ''
|
|||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
- Which site: [e.g. dev.phanpy.social OR phanpy.social]
|
||||
- Which site version: [On Phanpy, go to Settings -> About]
|
||||
- Which instance: [e.g. mastodon.social]
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
32
.github/workflows/prodtag.yml
vendored
Normal file
32
.github/workflows/prodtag.yml
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
name: Auto-create tag/release on every push to `production`
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: production
|
||||
# - run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
|
||||
# - run: git push --tags
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci && npm run build
|
||||
- run: cd dist && zip -r ../phanpy-dist.zip . && tar -czf ../phanpy-dist.tar.gz . && cd ..
|
||||
- id: tag_name
|
||||
run: echo ::set-output name=tag_name::$(date +%Y.%m.%d).$(git rev-parse --short HEAD)
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.tag_name.outputs.tag_name }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
phanpy-dist.zip
|
||||
phanpy-dist.tar.gz
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -24,4 +24,10 @@ dist-ssr
|
|||
*.sw?
|
||||
|
||||
# Custom
|
||||
.env.dev
|
||||
.env.dev
|
||||
phanpy-dist.zip
|
||||
phanpy-dist.tar.gz
|
||||
|
||||
# Nix
|
||||
.direnv
|
||||
result
|
||||
|
|
14
.prettierrc
14
.prettierrc
|
@ -3,16 +3,20 @@
|
|||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrder": [
|
||||
"^[^.].*.css$",
|
||||
"index.css$",
|
||||
".css$",
|
||||
"",
|
||||
"./polyfills",
|
||||
"",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"",
|
||||
"/assets/",
|
||||
"",
|
||||
"^../",
|
||||
"",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"importOrderGroupNamespaceSpecifiers": true,
|
||||
"importOrderCaseInsensitive": true
|
||||
]
|
||||
}
|
||||
|
|
|
@ -6,6 +6,10 @@ Phanpy does not collect or process any personal information from its users. The
|
|||
|
||||
Phanpy is hosted on [Cloudflare Pages](https://pages.cloudflare.com/) as a static website. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/).
|
||||
|
||||
## Translations
|
||||
|
||||
Phanpy uses [Lingva API](https://github.com/cheeaun/lingva-api) and [Lingva Translate](https://github.com/thedaviddelta/lingva-translate) as fallbacks for translating post content, profile bio and media description.
|
||||
|
||||
## Error logging
|
||||
|
||||
Phanpy dev site (*dev.phanpy.social*) uses [Rollbar](https://rollbar.com/) to log errors for debugging purposes. Read more about [Rollbar's privacy policy](https://rollbar.com/privacy/). The production site (*phanpy.social*) does not use error logging.
|
||||
|
|
153
README.md
153
README.md
|
@ -7,7 +7,7 @@ Phanpy
|
|||
**Minimalistic opinionated Mastodon web client.**
|
||||
</div>
|
||||
|
||||
<br>
|
||||
![Fancy screenshot](readme-assets/fancy-screenshot.jpg)
|
||||
|
||||
**🗣️ Pronunciation**: [`/fænpi/`](https://ythi.net/how-do-you-pronounce/phanpy/english/) ([`FAN-pee`](https://www.smogon.com/forums/threads/the-official-name-pronunciation-guide.3474941/)) [🔊 Listen](https://www.youtube.com/watch?v=DIUbWe-ysJI)
|
||||
|
||||
|
@ -43,7 +43,7 @@ Everything is designed and engineered following my taste and vision. This is a p
|
|||
|
||||
- **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.<br>They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here.
|
||||
- **Boost is represented with the rocket icon**.<br>The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple.
|
||||
- **Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.
|
||||
- ~~**Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.~~ DTTHDon fork displays the full username by default.
|
||||
- **No autoplay for video/GIF/whatever in timeline**.<br>The timeline is already a huge mess with lots of people, brands, news and media trying to grab your attention. Let's not make it worse. (Current exception now would be animated emojis.)
|
||||
- **Hash-based URLs**.<br>This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day.
|
||||
|
||||
|
@ -74,6 +74,18 @@ Everything is designed and engineered following my taste and vision. This is a p
|
|||
- Limit up to 3 API requests as the root post may be very old or the thread is super long.
|
||||
- If index number couldn't be found, badge will fallback to showing `Thread` without the number.
|
||||
|
||||
### Hashtag stuffing collapsing
|
||||
|
||||
![Hashtag stuffing collapsing](readme-assets/hashtag-stuffing-collapsing.jpg)
|
||||
|
||||
- First paragraph of post content with more than 3 hashtags will be collapsed to max 3 lines.
|
||||
- Subsequent paragraphs after first paragraph with more than 3 hashtags will be collapsed to 1 line.
|
||||
- Adjacent paragraphs with more than 1 hashtag after collapsed paragraphs will be collapsed to 1 line.
|
||||
- If there are text around or between the hashtags, they will not be collapsed.
|
||||
- Collapsed hashtags will be appended with `...` at the end.
|
||||
- They are also slightly faded out to reduce visual noise.
|
||||
- Opening the post view will reveal the hashtags uncollapsed.
|
||||
|
||||
### Filtered posts
|
||||
|
||||
- "Hide completely"-filtered posts will be hidden, with no UI to reveal it.
|
||||
|
@ -81,7 +93,7 @@ Everything is designed and engineered following my taste and vision. This is a p
|
|||
- Content can be partially revealed by hovering over the post, with tooltip showing the post text.
|
||||
- Clicking it will open the Post page.
|
||||
- Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI.
|
||||
- On boosts carousel, they are not partially hidden, but sorted to the end of the carousel.
|
||||
- On boosts carousel, they are sorted to the end of the carousel.
|
||||
|
||||
## Development
|
||||
|
||||
|
@ -91,16 +103,9 @@ Prerequisites: Node.js 18+
|
|||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview the production build
|
||||
- `npm run fetch-instances` - Fetch instances list from [instances.social](https://instances.social/), save it to `src/data/instances.json`
|
||||
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
|
||||
- `npm run fetch-instances` - Fetch instances list from [joinmastodon.org/servers](https://joinmastodon.org/servers), save it to `src/data/instances.json`
|
||||
- `npm run sourcemap` - Run `source-map-explorer` on the production build
|
||||
|
||||
## Self-hosting
|
||||
|
||||
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` and serve the `dist` folder.
|
||||
|
||||
Try search for "how to self-host static sites" as there are many ways to do it.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- [Vite](https://vitejs.dev/) - Build tool
|
||||
|
@ -109,18 +114,125 @@ Try search for "how to self-host static sites" as there are many ways to do it.
|
|||
- [React Router](https://reactrouter.com/) - Routing
|
||||
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
|
||||
- [Iconify](https://iconify.design/) - Icon library
|
||||
- Vanilla CSS - *Yes, I'm old school.*
|
||||
- [MingCute icons](https://www.mingcute.com/)
|
||||
- Vanilla CSS - _Yes, I'm old school._
|
||||
|
||||
Some of these may change in the future. The front-end world is ever-changing.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
This is a **pure static web app**. You can host it anywhere you want.
|
||||
|
||||
Two ways (choose one):
|
||||
|
||||
### Easy way
|
||||
|
||||
Go to [Releases](https://github.com/cheeaun/phanpy/releases) and download the latest `phanpy-dist.zip` or `phanpy-dist.tar.gz`. It's pre-built so don't need to run any install/build commands. Extract it. Serve the folder of extracted files.
|
||||
|
||||
### Custom-build way
|
||||
|
||||
Requires [Node.js](https://nodejs.org/).
|
||||
|
||||
Download or `git clone` this repository. Use `production` branch for *stable* releases, `main` for *latest*. Build it by running `npm run build` (after `npm install`). Serve the `dist` folder.
|
||||
|
||||
Customization can be done by passing environment variables to the build command. Examples:
|
||||
|
||||
```bash
|
||||
PHANPY_CLIENT_NAME="Phanpy Dev" \
|
||||
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
||||
npm run build
|
||||
```
|
||||
|
||||
```bash
|
||||
PHANPY_DEFAULT_INSTANCE=hachyderm.io \
|
||||
PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL=https://hachyderm.io/auth/sign_up \
|
||||
PHANPY_PRIVACY_POLICY_URL=https://hachyderm.io/privacy-policy \
|
||||
npm run build
|
||||
```
|
||||
|
||||
It's also possible to set them in the `.env` file.
|
||||
|
||||
Available variables:
|
||||
|
||||
- `PHANPY_CLIENT_NAME` (optional, default: `Phanpy`) affects:
|
||||
- Web page title, shown in the browser window or tab title
|
||||
- App title, when installed as PWA, shown in the Home screen, macOS dock, Windows taskbar, etc
|
||||
- OpenGraph card title, when shared on social networks
|
||||
- Client name, when [registering the app for authentication](https://docs.joinmastodon.org/client/token/#app) and shown as client used on posts in some apps/clients
|
||||
- `PHANPY_WEBSITE` (optional but recommended, default: `https://phanpy.social`) affects:
|
||||
- Canonical URL of the website
|
||||
- OpenGraph card URL, when shared on social networks
|
||||
- Root path for the OpenGraph card image
|
||||
- Client URL, when [registering the app for authentication](https://docs.joinmastodon.org/client/token/#app) and shown as client used on posts in some apps/clients
|
||||
- `PHANPY_DEFAULT_INSTANCE` (optional, no defaults):
|
||||
- e.g. 'mastodon.social', without `https://`
|
||||
- Default instance for log-in
|
||||
- When logging in, the user will be redirected instantly to the instance's authentication page instead of having to manually type the instance URL and submit
|
||||
- `PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL` (optional, no defaults):
|
||||
- URL of the instance registration page
|
||||
- E.g. `https://mastodon.social/auth/sign_up`
|
||||
- `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy):
|
||||
- URL of the privacy policy page
|
||||
- May specify the instance's own privacy policy
|
||||
- `PHANPY_LINGVA_INSTANCES` (optional, space-separated list, default: `lingva.phanpy.social [...hard-coded list of fallback instances]`):
|
||||
- Specify a space-separated list of instances. First will be used as default before falling back to the subsequent instances. If there's only 1 instance, means no fallback.
|
||||
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
|
||||
- List of fallback instances hard-coded in `/.env`
|
||||
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
|
||||
- `PHANPY_IMG_ALT_API_URL` (optional, no defaults):
|
||||
- API endpoint for self-hosted instance of [img-alt-api](https://github.com/cheeaun/img-alt-api).
|
||||
- If provided, a setting will appear for users to enable the image description generator in the composer. Disabled by default.
|
||||
- `PHANPY_GIPHY_API_KEY` (optional, no defaults):
|
||||
- API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/).
|
||||
- If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default.
|
||||
- This is not self-hosted.
|
||||
|
||||
### Static site hosting
|
||||
|
||||
Try online search for "how to self-host static sites" as there are many ways to do it.
|
||||
|
||||
#### Lingva-translate or lingva-api hosting
|
||||
|
||||
See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api).
|
||||
|
||||
## Community deployments
|
||||
|
||||
These are self-hosted by other wonderful folks.
|
||||
|
||||
- [ferengi.one](https://m.ferengi.one/) by [@david@weaknotes.com](https://weaknotes.com/@david)
|
||||
- [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy)
|
||||
- [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop)
|
||||
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
|
||||
- [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin)
|
||||
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
|
||||
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
|
||||
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
|
||||
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
|
||||
- [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
|
||||
- [social.qrk.one](https://social.qrk.one) by [@kev@fosstodon.org](https://fosstodon.org/@kev)
|
||||
- [phanpy.cz](https://phanpy.cz) by [@zdendys@mamutovo.cz](https://mamutovo.cz/@zdendys)
|
||||
- [phanpy.social.tchncs.de](https://phanpy.social.tchncs.de) by [@milan@social.tchncs.de](https://social.tchncs.de/@milan)
|
||||
|
||||
> Note: Add yours by creating a pull request.
|
||||
|
||||
## Costs
|
||||
|
||||
Costs involved in running and developing this web app:
|
||||
|
||||
- Domain name (.social): **USD$23.18/year** (USD$6.87 1st year)
|
||||
- Hosting: Free
|
||||
- Development, design, maintenance: "Free" (My precious time)
|
||||
|
||||
## Mascot
|
||||
|
||||
[Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon.
|
||||
|
||||
## Maintainers
|
||||
## Maintainers + contributors
|
||||
|
||||
- [Chee Aun](https://github.com/cheeaun) ([Mastodon](https://mastodon.social/@cheeaun)) ([Twitter](https://twitter.com/cheeaun))
|
||||
|
||||
[![Contributors](https://contrib.rocks/image?repo=cheeaun/phanpy)](https://github.com/cheeaun/phanpy/graphs/contributors)
|
||||
|
||||
## Backstory
|
||||
|
||||
I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006.
|
||||
|
@ -135,16 +247,25 @@ And here I am. Building a Mastodon web client.
|
|||
|
||||
## Alternative web clients
|
||||
|
||||
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) → [Semaphore](https://semaphore.social/)
|
||||
- Phanpy forks ↓
|
||||
- [Agora](https://agorasocial.app/)
|
||||
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
|
||||
- [Semaphore](https://semaphore.social/)
|
||||
- [Enafore](https://enafore.social/)
|
||||
- [Cuckoo+](https://www.cuckoo.social/)
|
||||
- [Sengi](https://nicolasconstant.github.io/sengi/)
|
||||
- [Soapbox](https://fe.soapbox.pub/)
|
||||
- [Elk](https://elk.zone/)
|
||||
- [Elk](https://elk.zone/) - forks ↓
|
||||
- [elk.fedified.com](https://elk.fedified.com/)
|
||||
- [Mastodeck](https://mastodeck.com/)
|
||||
- [Trunks (alpha)](https://alpha.trunks.social/)
|
||||
- [Trunks](https://trunks.social/)
|
||||
- [Tooty](https://github.com/n1k0/tooty)
|
||||
- [Litterbox](https://litterbox.koyu.space/)
|
||||
- [Statuzer](https://statuzer.com/)
|
||||
- [Tusked](https://tusked.app/)
|
||||
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
|
||||
- [Mangane](https://github.com/BDX-town/Mangane)
|
||||
- [TheDesk](https://github.com/cutls/TheDesk)
|
||||
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||
|
||||
## 💁♂️ Notice to all other social media client developers
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Compose / %VITE_CLIENT_NAME%</title>
|
||||
<title>Compose / %PHANPY_CLIENT_NAME%</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="google" content="notranslate" />
|
||||
</head>
|
||||
|
|
Binary file not shown.
61
flake.lock
Normal file
61
flake.lock
Normal file
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1723175592,
|
||||
"narHash": "sha256-M0xJ3FbDUc4fRZ84dPGx5VvgFsOzds77KiBMW/mMTnI=",
|
||||
"owner": "nixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5e0ca22929f3342b19569b21b2f3462f053e497b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
60
flake.nix
Normal file
60
flake.nix
Normal file
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
inputs.nixpkgs.url = github:nixOS/nixpkgs/nixos-unstable;
|
||||
inputs.flake-utils.url = github:numtide/flake-utils;
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
lib = pkgs.lib;
|
||||
|
||||
esbuild = pkgs.buildGoModule rec {
|
||||
pname = "esbuild";
|
||||
version = "0.21.5";
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "evanw";
|
||||
repo = "esbuild";
|
||||
rev = "v${version}";
|
||||
hash = "sha256-FpvXWIlt67G8w3pBKZo/mcp57LunxDmRUaCU/Ne89B8=";
|
||||
};
|
||||
|
||||
vendorHash = "sha256-+BfxCyg0KkDQpHt/wycy/8CTG6YBA/VJvJFhhzUnSiQ=";
|
||||
subPackages = [ "cmd/esbuild" ];
|
||||
ldflags = [ "-s" "-w" ];
|
||||
|
||||
meta.mainProgram = "esbuild";
|
||||
};
|
||||
in
|
||||
rec {
|
||||
packages.default = pkgs.buildNpmPackage {
|
||||
pname = "dtth-phanpy";
|
||||
version = "0.1.0";
|
||||
|
||||
nativeBuildInputs = with pkgs; [ git ];
|
||||
ESBUILD_BINARY_PATH = lib.getExe esbuild;
|
||||
|
||||
src = lib.cleanSource ./.;
|
||||
|
||||
npmFlags = [ "--legacy-peer-deps" ];
|
||||
npmDepsHash = "sha256-VROK9Emxi+jFqwidA/CUxQwxitKf7Y6mx0yuOCUwrzI=";
|
||||
# npmDepsHash = lib.fakeHash;
|
||||
|
||||
# DTTH-specific env variables
|
||||
PHANPY_CLIENT_NAME = "DTTH Phanpy";
|
||||
PHANPY_CLIENT_ID = "ch.dtth.phanpy";
|
||||
PHANPY_WEBSITE = "https://social.dtth.ch";
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/lib
|
||||
cp -r dist $out/lib/phanpy
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = [ packages.default ];
|
||||
buildInputs = with pkgs; [ nodejs ];
|
||||
};
|
||||
});
|
||||
}
|
27
index.html
27
index.html
|
@ -6,7 +6,7 @@
|
|||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>%VITE_CLIENT_NAME%</title>
|
||||
<title>%PHANPY_CLIENT_NAME%</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Minimalistic opinionated Mastodon web client"
|
||||
|
@ -14,18 +14,33 @@
|
|||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_CLIENT_NAME%" />
|
||||
<meta name="apple-mobile-web-app-title" content="%PHANPY_CLIENT_NAME%" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="canonical" href="%VITE_WEBSITE%" />
|
||||
<link rel="canonical" href="%PHANPY_WEBSITE%" />
|
||||
<meta
|
||||
name=""
|
||||
data-theme-setting="manual"
|
||||
content="#242526"
|
||||
data-theme-light-color="#fff"
|
||||
data-theme-light-color-temp="#ffff"
|
||||
data-theme-dark-color="#242526"
|
||||
data-theme-dark-color-temp="#242526ff"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
data-theme-setting="auto"
|
||||
content="#fff"
|
||||
data-content="#fff"
|
||||
data-content-temp="#fffa"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
data-theme-setting="auto"
|
||||
content="#242526"
|
||||
data-content="#242526"
|
||||
data-content-temp="#242526aa"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<meta name="google" content="notranslate" />
|
||||
|
@ -33,13 +48,13 @@
|
|||
|
||||
<!-- Metacrap https://broken-links.com/2015/12/01/little-less-metacrap/ -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="og:url" content="%VITE_WEBSITE%" />
|
||||
<meta property="og:title" content="%VITE_CLIENT_NAME%" />
|
||||
<meta property="og:url" content="%PHANPY_WEBSITE%" />
|
||||
<meta property="og:title" content="%PHANPY_CLIENT_NAME%" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Minimalistic opinionated Mastodon web client"
|
||||
/>
|
||||
<meta property="og:image" content="%VITE_WEBSITE%/og-image-2.jpg" />
|
||||
<meta property="og:image" content="%PHANPY_WEBSITE%/og-image-2.jpg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
11275
package-lock.json
generated
11275
package-lock.json
generated
File diff suppressed because it is too large
Load diff
85
package.json
85
package.json
|
@ -6,56 +6,64 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"fetch-instances": "env $(cat .env.dev | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
|
||||
"sourcemap": "npx source-map-explorer dist/assets/*.js"
|
||||
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
|
||||
"sourcemap": "npx source-map-explorer dist/assets/*.js",
|
||||
"bundle-visualizer": "npx vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "~0.4.0",
|
||||
"@github/text-expander-element": "~2.5.0",
|
||||
"@iconify-icons/mingcute": "~1.2.5",
|
||||
"@formatjs/intl-localematcher": "~0.5.4",
|
||||
"@formatjs/intl-segmenter": "~11.5.7",
|
||||
"@formkit/auto-animate": "~0.8.2",
|
||||
"@github/text-expander-element": "~2.7.1",
|
||||
"@iconify-icons/mingcute": "~1.2.9",
|
||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||
"@szhsin/react-menu": "~4.0.0",
|
||||
"@uidotdev/usehooks": "~2.0.1",
|
||||
"dayjs": "~1.11.8",
|
||||
"@szhsin/react-menu": "~4.2.1",
|
||||
"compare-versions": "~6.1.1",
|
||||
"dayjs": "~1.11.12",
|
||||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-deep-equal": "~3.1.3",
|
||||
"fast-blurhash": "~1.1.4",
|
||||
"fast-equals": "~5.0.1",
|
||||
"fuse.js": "~7.0.0",
|
||||
"html-prettify": "~1.0.7",
|
||||
"idb-keyval": "~6.2.1",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~5.11.3",
|
||||
"mem": "~9.0.2",
|
||||
"p-retry": "~5.1.2",
|
||||
"p-throttle": "~5.1.0",
|
||||
"preact": "~10.15.1",
|
||||
"react-hotkeys-hook": "~4.4.0",
|
||||
"react-intersection-observer": "~9.4.4",
|
||||
"react-quick-pinch-zoom": "~4.9.0",
|
||||
"lz-string": "~1.5.0",
|
||||
"masto": "~6.8.0",
|
||||
"moize": "~6.1.6",
|
||||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.23.1",
|
||||
"punycode": "~2.3.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.13.0",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
"react-router-dom": "6.6.2",
|
||||
"string-length": "5.0.1",
|
||||
"swiped-events": "~1.1.7",
|
||||
"string-length": "6.0.0",
|
||||
"swiped-events": "~1.2.0",
|
||||
"tinyld": "~1.3.4",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.2",
|
||||
"use-debounce": "~9.0.4",
|
||||
"use-long-press": "~3.1.5",
|
||||
"use-debounce": "~10.0.2",
|
||||
"use-long-press": "~3.2.0",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "1.9.0"
|
||||
"valtio": "1.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.5.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.1.1",
|
||||
"postcss": "~8.4.24",
|
||||
"postcss-dark-theme-class": "~0.7.3",
|
||||
"postcss-preset-env": "~8.5.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "~4.3.1",
|
||||
"@preact/preset-vite": "~2.9.0",
|
||||
"postcss": "~8.4.40",
|
||||
"postcss-dark-theme-class": "~1.3.0",
|
||||
"postcss-preset-env": "~10.0.0",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~4.3.9",
|
||||
"vite-plugin-generate-file": "~0.0.4",
|
||||
"vite": "~5.3.5",
|
||||
"vite-plugin-generate-file": "~0.2.0",
|
||||
"vite-plugin-html-config": "~1.0.11",
|
||||
"vite-plugin-pwa": "~0.16.4",
|
||||
"vite-plugin-remove-console": "~2.1.1",
|
||||
"workbox-cacheable-response": "~7.0.0",
|
||||
"workbox-expiration": "~7.0.0",
|
||||
"workbox-routing": "~7.0.0",
|
||||
"workbox-strategies": "~7.0.0"
|
||||
"vite-plugin-pwa": "~0.20.1",
|
||||
"vite-plugin-remove-console": "~2.2.0",
|
||||
"workbox-cacheable-response": "~7.1.0",
|
||||
"workbox-expiration": "~7.1.0",
|
||||
"workbox-routing": "~7.1.0",
|
||||
"workbox-strategies": "~7.1.0"
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
|
@ -67,6 +75,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"vite": {
|
||||
"rollup": ">=4.5.1"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults",
|
||||
"android >= 4"
|
||||
|
|
32
public/404.html
Normal file
32
public/404.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>Page not found</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<style>
|
||||
body {
|
||||
text-align: center;
|
||||
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI,
|
||||
Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Page not found</h1>
|
||||
<p><a href="/">Go home</a></p>
|
||||
</body>
|
||||
</html>
|
BIN
public/logo-badge-72.png
Normal file
BIN
public/logo-badge-72.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
131
public/sw.js
131
public/sw.js
|
@ -9,13 +9,33 @@ import {
|
|||
|
||||
self.__WB_DISABLE_DEV_LOGS = true;
|
||||
|
||||
const assetsRoute = new Route(
|
||||
({ request, sameOrigin }) => {
|
||||
const isAsset =
|
||||
request.destination === 'style' || request.destination === 'script';
|
||||
const hasHash = /-[0-9a-f]{4,}\./i.test(request.url);
|
||||
return sameOrigin && isAsset && hasHash;
|
||||
},
|
||||
new NetworkFirst({
|
||||
cacheName: 'assets',
|
||||
networkTimeoutSeconds: 5,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
registerRoute(assetsRoute);
|
||||
|
||||
const imageRoute = new Route(
|
||||
({ request, sameOrigin }) => {
|
||||
const isRemote = !sameOrigin;
|
||||
const isImage = request.destination === 'image';
|
||||
const isAvatar = request.url.includes('/avatars/');
|
||||
const isCustomEmoji = request.url.includes('/custom/_emojis');
|
||||
const isEmoji = request.url.includes('/emoji/');
|
||||
return isRemote && isImage && (isAvatar || isEmoji);
|
||||
return isRemote && isImage && (isAvatar || isCustomEmoji || isEmoji);
|
||||
},
|
||||
new CacheFirst({
|
||||
cacheName: 'remote-images',
|
||||
|
@ -42,7 +62,7 @@ const iconsRoute = new Route(
|
|||
cacheName: 'icons',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxEntries: 300,
|
||||
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
|
@ -76,6 +96,28 @@ const apiExtendedRoute = new RegExpRoute(
|
|||
);
|
||||
registerRoute(apiExtendedRoute);
|
||||
|
||||
// Note: expiration is not working as expected
|
||||
// https://github.com/GoogleChrome/workbox/issues/3316
|
||||
//
|
||||
// const apiIntermediateRoute = new RegExpRoute(
|
||||
// // Matches:
|
||||
// // - trends/*
|
||||
// // - timelines/link
|
||||
// /^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/,
|
||||
// new StaleWhileRevalidate({
|
||||
// cacheName: 'api-intermediate',
|
||||
// plugins: [
|
||||
// new ExpirationPlugin({
|
||||
// maxAgeSeconds: 1 * 60, // 1min
|
||||
// }),
|
||||
// new CacheableResponsePlugin({
|
||||
// statuses: [0, 200],
|
||||
// }),
|
||||
// ],
|
||||
// }),
|
||||
// );
|
||||
// registerRoute(apiIntermediateRoute);
|
||||
|
||||
const apiRoute = new RegExpRoute(
|
||||
// Matches:
|
||||
// - statuses/:id/context - some contexts are really huge
|
||||
|
@ -94,3 +136,88 @@ const apiRoute = new RegExpRoute(
|
|||
}),
|
||||
);
|
||||
registerRoute(apiRoute);
|
||||
|
||||
// PUSH NOTIFICATIONS
|
||||
// ==================
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const { data } = event;
|
||||
if (data) {
|
||||
const payload = data.json();
|
||||
console.log('PUSH payload', payload);
|
||||
const {
|
||||
access_token,
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
notification_id,
|
||||
notification_type,
|
||||
preferred_locale,
|
||||
} = payload;
|
||||
|
||||
if (!!navigator.setAppBadge) {
|
||||
if (notification_type === 'mention') {
|
||||
navigator.setAppBadge(1);
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon,
|
||||
dir: 'auto',
|
||||
badge: '/logo-badge-72.png',
|
||||
lang: preferred_locale,
|
||||
tag: notification_id,
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
access_token,
|
||||
notification_type,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
const payload = event.notification;
|
||||
console.log('NOTIFICATION CLICK payload', payload);
|
||||
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
|
||||
const { access_token, notification_type } = data;
|
||||
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
console.log('NOTIFICATION CLICK clients 1', clients);
|
||||
if (clients.length && 'navigate' in clients[0]) {
|
||||
console.log('NOTIFICATION CLICK clients 2', clients);
|
||||
const bestClient =
|
||||
clients.find(
|
||||
(client) => client.focused || client.visibilityState === 'visible',
|
||||
) || clients[0];
|
||||
console.log('NOTIFICATION CLICK navigate', url);
|
||||
if (bestClient) {
|
||||
console.log('NOTIFICATION CLICK postMessage', bestClient);
|
||||
bestClient.focus();
|
||||
bestClient.postMessage?.({
|
||||
type: 'notification',
|
||||
id: tag,
|
||||
accessToken: access_token,
|
||||
});
|
||||
} else {
|
||||
console.log('NOTIFICATION CLICK openWindow', url);
|
||||
await self.clients.openWindow(url);
|
||||
}
|
||||
// }
|
||||
} else {
|
||||
console.log('NOTIFICATION CLICK openWindow', url);
|
||||
await self.clients.openWindow(url);
|
||||
}
|
||||
await event.notification.close();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
|
BIN
readme-assets/fancy-screenshot.jpg
Normal file
BIN
readme-assets/fancy-screenshot.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
BIN
readme-assets/hashtag-stuffing-collapsing.jpg
Normal file
BIN
readme-assets/hashtag-stuffing-collapsing.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
|
@ -1,24 +1,12 @@
|
|||
import fs from 'fs';
|
||||
|
||||
const { INSTANCES_SOCIAL_SECRET_TOKEN } = process.env;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
count: 0,
|
||||
min_users: 500,
|
||||
sort_by: 'active_users',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
|
||||
const url = `https://instances.social/api/1.0/instances/list?${params.toString()}`;
|
||||
const results = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${INSTANCES_SOCIAL_SECRET_TOKEN}`,
|
||||
},
|
||||
});
|
||||
const url = 'https://api.joinmastodon.org/servers';
|
||||
const results = await fetch(url);
|
||||
|
||||
const json = await results.json();
|
||||
const names = json.instances.map((instance) => instance.name);
|
||||
|
||||
const domains = json.map((instance) => instance.domain);
|
||||
|
||||
// Write to file
|
||||
const path = './src/data/instances.json';
|
||||
fs.writeFileSync(path, JSON.stringify(names, null, '\t'), 'utf8');
|
||||
fs.writeFileSync(path, JSON.stringify(domains, null, '\t'), 'utf8');
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// Fetch https://lingva.ml/api/v1/languages/{source|target}
|
||||
import fs from 'fs';
|
||||
|
||||
fetch('https://lingva.ml/api/v1/languages/source')
|
||||
fetch('https://lingva.phanpy.social/api/v1/languages/source')
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
const file = './src/data/lingva-source-languages.json';
|
||||
|
@ -9,7 +8,7 @@ fetch('https://lingva.ml/api/v1/languages/source')
|
|||
fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
|
||||
});
|
||||
|
||||
fetch('https://lingva.ml/api/v1/languages/target')
|
||||
fetch('https://lingva.phanpy.social/api/v1/languages/target')
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
const file = './src/data/lingva-target-languages.json';
|
||||
|
|
1415
src/app.css
1415
src/app.css
File diff suppressed because it is too large
Load diff
784
src/app.jsx
784
src/app.jsx
|
@ -1,5 +1,6 @@
|
|||
import './app.css';
|
||||
|
||||
import debounce from 'just-debounce-it';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
|
@ -7,36 +8,32 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import {
|
||||
matchPath,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import 'swiped-events';
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import AccountSheet from './components/account-sheet';
|
||||
import Compose from './components/compose';
|
||||
import Drafts from './components/drafts';
|
||||
import Icon from './components/icon';
|
||||
import 'swiped-events';
|
||||
|
||||
import { subscribe } from 'valtio';
|
||||
|
||||
import BackgroundService from './components/background-service';
|
||||
import ComposeButton from './components/compose-button';
|
||||
import { ICONS } from './components/ICONS';
|
||||
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
||||
import Loader from './components/loader';
|
||||
import MediaModal from './components/media-modal';
|
||||
import Modal from './components/modal';
|
||||
import Modals from './components/modals';
|
||||
import NotificationService from './components/notification-service';
|
||||
import SearchCommand from './components/search-command';
|
||||
import Shortcuts from './components/shortcuts';
|
||||
import ShortcutsSettings from './components/shortcuts-settings';
|
||||
import NotFound from './pages/404';
|
||||
import AccountStatuses from './pages/account-statuses';
|
||||
import Accounts from './pages/accounts';
|
||||
import Bookmarks from './pages/bookmarks';
|
||||
import Catchup from './pages/catchup';
|
||||
import Favourites from './pages/favourites';
|
||||
import Filters from './pages/filters';
|
||||
import FollowedHashtags from './pages/followed-hashtags';
|
||||
import Following from './pages/following';
|
||||
import Hashtag from './pages/hashtag';
|
||||
import Home from './pages/home';
|
||||
import HttpRoute from './pages/HttpRoute';
|
||||
import HttpRoute from './pages/http-route';
|
||||
import List from './pages/list';
|
||||
import Lists from './pages/lists';
|
||||
import Login from './pages/login';
|
||||
|
@ -44,8 +41,7 @@ import Mentions from './pages/mentions';
|
|||
import Notifications from './pages/notifications';
|
||||
import Public from './pages/public';
|
||||
import Search from './pages/search';
|
||||
import Settings from './pages/settings';
|
||||
import Status from './pages/status';
|
||||
import StatusRoute from './pages/status-route';
|
||||
import Trending from './pages/trending';
|
||||
import Welcome from './pages/welcome';
|
||||
import {
|
||||
|
@ -56,38 +52,253 @@ import {
|
|||
initPreferences,
|
||||
} from './utils/api';
|
||||
import { getAccessToken } from './utils/auth';
|
||||
import openCompose from './utils/open-compose';
|
||||
import showToast from './utils/show-toast';
|
||||
import states, { getStatus, saveStatus } from './utils/states';
|
||||
import focusDeck from './utils/focus-deck';
|
||||
import states, { initStates, statusKey } from './utils/states';
|
||||
import store from './utils/store';
|
||||
import { getCurrentAccount } from './utils/store-utils';
|
||||
import useInterval from './utils/useInterval';
|
||||
import usePageVisibility from './utils/usePageVisibility';
|
||||
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
|
||||
|
||||
import './utils/toast-alert';
|
||||
|
||||
window.__STATES__ = states;
|
||||
window.__STATES_STATS__ = () => {
|
||||
const keys = [
|
||||
'statuses',
|
||||
'accounts',
|
||||
'spoilers',
|
||||
'unfurledLinks',
|
||||
'statusQuotes',
|
||||
];
|
||||
const counts = {};
|
||||
keys.forEach((key) => {
|
||||
counts[key] = Object.keys(states[key]).length;
|
||||
});
|
||||
console.warn('STATE stats', counts);
|
||||
|
||||
const { statuses } = states;
|
||||
const unmountedPosts = [];
|
||||
for (const key in statuses) {
|
||||
const $post = document.querySelector(
|
||||
`[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
|
||||
);
|
||||
if (!$post) {
|
||||
unmountedPosts.push(key);
|
||||
}
|
||||
}
|
||||
console.warn('Unmounted posts', unmountedPosts.length, unmountedPosts);
|
||||
};
|
||||
|
||||
// Experimental "garbage collection" for states
|
||||
// Every 15 minutes
|
||||
// Only posts for now
|
||||
setInterval(() => {
|
||||
if (!window.__IDLE__) return;
|
||||
const { statuses, unfurledLinks, notifications } = states;
|
||||
let keysCount = 0;
|
||||
const { instance } = api();
|
||||
for (const key in statuses) {
|
||||
if (!window.__IDLE__) break;
|
||||
try {
|
||||
const $post = document.querySelector(
|
||||
`[data-state-post-id~="${key}"], [data-state-post-ids~="${key}"]`,
|
||||
);
|
||||
const postInNotifications = notifications.some(
|
||||
(n) => key === statusKey(n.status?.id, instance),
|
||||
);
|
||||
if (!$post && !postInNotifications) {
|
||||
delete states.statuses[key];
|
||||
delete states.statusQuotes[key];
|
||||
for (const link in unfurledLinks) {
|
||||
const unfurled = unfurledLinks[link];
|
||||
const sKey = statusKey(unfurled.id, unfurled.instance);
|
||||
if (sKey === key) {
|
||||
delete states.unfurledLinks[link];
|
||||
break;
|
||||
}
|
||||
}
|
||||
keysCount++;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (keysCount) {
|
||||
console.info(`GC: Removed ${keysCount} keys`);
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
// Preload icons
|
||||
// There's probably a better way to do this
|
||||
// Related: https://github.com/vitejs/vite/issues/10600
|
||||
setTimeout(() => {
|
||||
for (const icon in ICONS) {
|
||||
setTimeout(() => {
|
||||
if (Array.isArray(ICONS[icon])) {
|
||||
ICONS[icon][0]?.();
|
||||
} else if (typeof ICONS[icon] === 'object') {
|
||||
ICONS[icon].module?.();
|
||||
} else {
|
||||
ICONS[icon]?.();
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
(() => {
|
||||
window.__IDLE__ = true;
|
||||
const nonIdleEvents = [
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'resize',
|
||||
'keydown',
|
||||
'touchstart',
|
||||
'pointerdown',
|
||||
'pointermove',
|
||||
'wheel',
|
||||
];
|
||||
const setIdle = () => {
|
||||
window.__IDLE__ = true;
|
||||
};
|
||||
const IDLE_TIME = 3_000; // 3 seconds
|
||||
const debouncedSetIdle = debounce(setIdle, IDLE_TIME);
|
||||
const onNonIdle = () => {
|
||||
window.__IDLE__ = false;
|
||||
debouncedSetIdle();
|
||||
};
|
||||
nonIdleEvents.forEach((event) => {
|
||||
window.addEventListener(event, onNonIdle, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
});
|
||||
window.addEventListener('blur', setIdle, {
|
||||
passive: true,
|
||||
});
|
||||
// When cursor leaves the window, set idle
|
||||
document.documentElement.addEventListener(
|
||||
'mouseleave',
|
||||
(e) => {
|
||||
if (!e.relatedTarget && !e.toElement) {
|
||||
setIdle();
|
||||
}
|
||||
},
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
// document.addEventListener(
|
||||
// 'visibilitychange',
|
||||
// () => {
|
||||
// if (document.visibilityState === 'visible') {
|
||||
// onNonIdle();
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// passive: true,
|
||||
// },
|
||||
// );
|
||||
})();
|
||||
|
||||
// Possible fix for iOS PWA theme-color bug
|
||||
// It changes when loading web pages in "webview"
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
if (isIOS) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const theme = store.local.get('theme');
|
||||
let $meta;
|
||||
if (theme) {
|
||||
// Get current meta
|
||||
$meta = document.querySelector(
|
||||
`meta[name="theme-color"][data-theme-setting="manual"]`,
|
||||
);
|
||||
if ($meta) {
|
||||
const color = $meta.content;
|
||||
const tempColor =
|
||||
theme === 'light'
|
||||
? $meta.dataset.themeLightColorTemp
|
||||
: $meta.dataset.themeDarkColorTemp;
|
||||
$meta.content = tempColor || '';
|
||||
setTimeout(() => {
|
||||
$meta.content = color;
|
||||
}, 10);
|
||||
}
|
||||
} else {
|
||||
// Get current color scheme
|
||||
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
// Get current theme-color
|
||||
$meta = document.querySelector(
|
||||
`meta[name="theme-color"][media*="${colorScheme}"]`,
|
||||
);
|
||||
if ($meta) {
|
||||
const color = $meta.dataset.content;
|
||||
const tempColor = $meta.dataset.contentTemp;
|
||||
$meta.content = tempColor || '';
|
||||
setTimeout(() => {
|
||||
$meta.content = color;
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const theme = store.local.get('theme');
|
||||
// If there's a theme, it's NOT auto
|
||||
if (theme) {
|
||||
// dark | light
|
||||
document.documentElement.classList.add(`is-${theme}`);
|
||||
document
|
||||
.querySelector('meta[name="color-scheme"]')
|
||||
.setAttribute('content', theme || 'dark light');
|
||||
|
||||
// Enable manual theme <meta>
|
||||
const $manualMeta = document.querySelector(
|
||||
'meta[data-theme-setting="manual"]',
|
||||
);
|
||||
if ($manualMeta) {
|
||||
$manualMeta.name = 'theme-color';
|
||||
$manualMeta.content =
|
||||
theme === 'light'
|
||||
? $manualMeta.dataset.themeLightColor
|
||||
: $manualMeta.dataset.themeDarkColor;
|
||||
}
|
||||
// Disable auto theme <meta>s
|
||||
const $autoMetas = document.querySelectorAll(
|
||||
'meta[data-theme-setting="auto"]',
|
||||
);
|
||||
$autoMetas.forEach((m) => {
|
||||
m.name = '';
|
||||
});
|
||||
}
|
||||
const textSize = store.local.get('textSize');
|
||||
if (textSize) {
|
||||
document.documentElement.style.setProperty('--text-size', `${textSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(states, (changes) => {
|
||||
for (const [action, path, value, prevValue] of changes) {
|
||||
// Change #app dataset based on settings.shortcutsViewMode
|
||||
if (path.join('.') === 'settings.shortcutsViewMode') {
|
||||
const $app = document.getElementById('app');
|
||||
if ($app) {
|
||||
$app.dataset.shortcutsViewMode = states.shortcuts?.length ? value : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Remove cloak class to body
|
||||
if (path.join('.') === 'settings.cloakMode') {
|
||||
const $body = document.body;
|
||||
$body.classList.toggle('cloak', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function App() {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [uiState, setUIState] = useState('loading');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const theme = store.local.get('theme');
|
||||
if (theme) {
|
||||
document.documentElement.classList.add(`is-${theme}`);
|
||||
document
|
||||
.querySelector('meta[name="color-scheme"]')
|
||||
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
|
||||
}
|
||||
const textSize = store.local.get('textSize');
|
||||
if (textSize) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--text-size',
|
||||
`${textSize}px`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const instanceURL = store.local.get('instanceURL');
|
||||
|
@ -98,10 +309,15 @@ function App() {
|
|||
if (code) {
|
||||
console.log({ code });
|
||||
// Clear the code from the URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname || '/',
|
||||
);
|
||||
|
||||
const clientID = store.session.get('clientID');
|
||||
const clientSecret = store.session.get('clientSecret');
|
||||
const vapidKey = store.session.get('vapidKey');
|
||||
|
||||
(async () => {
|
||||
setUIState('loading');
|
||||
|
@ -112,27 +328,31 @@ function App() {
|
|||
code,
|
||||
});
|
||||
|
||||
const masto = initClient({ instance: instanceURL, accessToken });
|
||||
const client = initClient({ instance: instanceURL, accessToken });
|
||||
await Promise.allSettled([
|
||||
initInstance(masto, instanceURL),
|
||||
initAccount(masto, instanceURL, accessToken),
|
||||
initPreferences(client),
|
||||
initInstance(client, instanceURL),
|
||||
initAccount(client, instanceURL, accessToken, vapidKey),
|
||||
]);
|
||||
initPreferences(masto);
|
||||
initStates();
|
||||
|
||||
setIsLoggedIn(true);
|
||||
setUIState('default');
|
||||
})();
|
||||
} else {
|
||||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
||||
const account = getCurrentAccount();
|
||||
if (account) {
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
const { masto, instance } = api({ account });
|
||||
console.log('masto', masto);
|
||||
initPreferences(masto);
|
||||
setCurrentAccountID(account.info.id);
|
||||
const { client } = api({ account });
|
||||
const { instance } = client;
|
||||
// console.log('masto', masto);
|
||||
initStates();
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await initInstance(masto, instance);
|
||||
await initPreferences(client);
|
||||
await initInstance(client, instance);
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setIsLoggedIn(true);
|
||||
|
@ -147,47 +367,81 @@ function App() {
|
|||
|
||||
let location = useLocation();
|
||||
states.currentLocation = location.pathname;
|
||||
// useLayoutEffect(() => {
|
||||
// states.currentLocation = location.pathname;
|
||||
// }, [location.pathname]);
|
||||
|
||||
const focusDeck = () => {
|
||||
let timer = setTimeout(() => {
|
||||
const columns = document.getElementById('columns');
|
||||
if (columns) {
|
||||
// Focus first column
|
||||
// columns.querySelector('.deck-container')?.focus?.();
|
||||
} else {
|
||||
const backDrop = document.querySelector('.deck-backdrop');
|
||||
if (backDrop) return;
|
||||
// Focus last deck
|
||||
const pages = document.querySelectorAll('.deck-container');
|
||||
const page = pages[pages.length - 1]; // last one
|
||||
if (page && page.tabIndex === -1) {
|
||||
console.log('FOCUS', page);
|
||||
page.focus();
|
||||
useEffect(focusDeck, [location, isLoggedIn]);
|
||||
|
||||
if (/\/https?:/.test(location.pathname)) {
|
||||
return <HttpRoute />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrimaryRoutes isLoggedIn={isLoggedIn} loading={uiState === 'loading'} />
|
||||
<SecondaryRoutes isLoggedIn={isLoggedIn} />
|
||||
{uiState === 'default' && (
|
||||
<Routes>
|
||||
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
||||
</Routes>
|
||||
)}
|
||||
{isLoggedIn && <ComposeButton />}
|
||||
{isLoggedIn && <Shortcuts />}
|
||||
<Modals />
|
||||
{isLoggedIn && <NotificationService />}
|
||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
||||
<KeyboardShortcutsHelp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryRoutes({ isLoggedIn, loading }) {
|
||||
const location = useLocation();
|
||||
const nonRootLocation = useMemo(() => {
|
||||
const { pathname } = location;
|
||||
return !/^\/(login|welcome)/i.test(pathname);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Routes location={nonRootLocation || location}>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
isLoggedIn ? (
|
||||
<Home />
|
||||
) : loading ? (
|
||||
<Loader id="loader-root" />
|
||||
) : (
|
||||
<Welcome />
|
||||
)
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
useEffect(focusDeck, [location]);
|
||||
const showModal =
|
||||
snapStates.showCompose ||
|
||||
snapStates.showSettings ||
|
||||
snapStates.showAccounts ||
|
||||
snapStates.showAccount ||
|
||||
snapStates.showDrafts ||
|
||||
snapStates.showMediaModal ||
|
||||
snapStates.showShortcutsSettings;
|
||||
useEffect(() => {
|
||||
if (!showModal) focusDeck();
|
||||
}, [showModal]);
|
||||
/>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
const { prevLocation } = snapStates;
|
||||
const backgroundLocation = useRef(prevLocation || null);
|
||||
const isModalPage =
|
||||
matchPath('/:instance/s/:id', location.pathname) ||
|
||||
matchPath('/s/:id', location.pathname);
|
||||
function getPrevLocation() {
|
||||
return states.prevLocation || null;
|
||||
}
|
||||
function SecondaryRoutes({ isLoggedIn }) {
|
||||
// const snapStates = useSnapshot(states);
|
||||
const location = useLocation();
|
||||
// const prevLocation = snapStates.prevLocation;
|
||||
const backgroundLocation = useRef(getPrevLocation());
|
||||
|
||||
const isModalPage = useMemo(() => {
|
||||
return (
|
||||
matchPath('/:instance/s/:id', location.pathname) ||
|
||||
matchPath('/s/:id', location.pathname)
|
||||
);
|
||||
}, [location.pathname, matchPath]);
|
||||
if (isModalPage) {
|
||||
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
|
||||
if (!backgroundLocation.current)
|
||||
backgroundLocation.current = getPrevLocation();
|
||||
} else {
|
||||
backgroundLocation.current = null;
|
||||
}
|
||||
|
@ -196,337 +450,35 @@ function App() {
|
|||
location,
|
||||
});
|
||||
|
||||
if (/\/https?:/.test(location.pathname)) {
|
||||
return <HttpRoute />;
|
||||
}
|
||||
|
||||
const nonRootLocation = useMemo(() => {
|
||||
const { pathname } = location;
|
||||
return !/^\/(login|welcome)/.test(pathname);
|
||||
}, [location]);
|
||||
|
||||
// Change #app dataset based on snapStates.settings.shortcutsViewMode
|
||||
useEffect(() => {
|
||||
const $app = document.getElementById('app');
|
||||
if ($app) {
|
||||
$app.dataset.shortcutsViewMode = snapStates.settings.shortcutsViewMode;
|
||||
}
|
||||
}, [snapStates.settings.shortcutsViewMode]);
|
||||
|
||||
// Add/Remove cloak class to body
|
||||
useEffect(() => {
|
||||
const $body = document.body;
|
||||
$body.classList.toggle('cloak', snapStates.settings.cloakMode);
|
||||
}, [snapStates.settings.cloakMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes location={nonRootLocation || location}>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
isLoggedIn ? (
|
||||
<Home />
|
||||
) : uiState === 'loading' ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<Welcome />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
</Routes>
|
||||
<Routes location={backgroundLocation.current || location}>
|
||||
{isLoggedIn && (
|
||||
<Routes location={backgroundLocation.current || location}>
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
)}
|
||||
{isLoggedIn && <Route path="/mentions" element={<Mentions />} />}
|
||||
{isLoggedIn && <Route path="/following" element={<Following />} />}
|
||||
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
|
||||
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
|
||||
{isLoggedIn && (
|
||||
<Route path="/mentions" element={<Mentions />} />
|
||||
<Route path="/following" element={<Following />} />
|
||||
<Route path="/b" element={<Bookmarks />} />
|
||||
<Route path="/f" element={<Favourites />} />
|
||||
<Route path="/l">
|
||||
<Route index element={<Lists />} />
|
||||
<Route path=":id" element={<List />} />
|
||||
</Route>
|
||||
)}
|
||||
{isLoggedIn && <Route path="/ft" element={<FollowedHashtags />} />}
|
||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||
<Route path="/:instance?/a/:id" element={<AccountStatuses />} />
|
||||
<Route path="/:instance?/p">
|
||||
<Route index element={<Public />} />
|
||||
<Route path="l" element={<Public local />} />
|
||||
</Route>
|
||||
<Route path="/:instance?/trending" element={<Trending />} />
|
||||
<Route path="/:instance?/search" element={<Search />} />
|
||||
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
||||
</Routes>
|
||||
{uiState === 'default' && (
|
||||
<Routes>
|
||||
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
||||
</Routes>
|
||||
<Route path="/fh" element={<FollowedHashtags />} />
|
||||
<Route path="/ft" element={<Filters />} />
|
||||
<Route path="/catchup" element={<Catchup />} />
|
||||
</>
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
id="compose-button"
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) {
|
||||
const newWin = openCompose();
|
||||
if (!newWin) {
|
||||
alert('Looks like your browser is blocking popups.');
|
||||
states.showCompose = true;
|
||||
}
|
||||
} else {
|
||||
states.showCompose = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="quill" size="xl" alt="Compose" />
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
!snapStates.settings.shortcutsColumnsMode &&
|
||||
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
|
||||
<Shortcuts />
|
||||
)}
|
||||
{!!snapStates.showCompose && (
|
||||
<Modal>
|
||||
<Compose
|
||||
replyToStatus={
|
||||
typeof snapStates.showCompose !== 'boolean'
|
||||
? snapStates.showCompose.replyToStatus
|
||||
: window.__COMPOSE__?.replyToStatus || null
|
||||
}
|
||||
editStatus={
|
||||
states.showCompose?.editStatus ||
|
||||
window.__COMPOSE__?.editStatus ||
|
||||
null
|
||||
}
|
||||
draftStatus={
|
||||
states.showCompose?.draftStatus ||
|
||||
window.__COMPOSE__?.draftStatus ||
|
||||
null
|
||||
}
|
||||
onClose={(results) => {
|
||||
const { newStatus, instance } = results || {};
|
||||
states.showCompose = false;
|
||||
window.__COMPOSE__ = null;
|
||||
if (newStatus) {
|
||||
states.reloadStatusPage++;
|
||||
showToast({
|
||||
text: 'Post published. Check it out.',
|
||||
delay: 1000,
|
||||
duration: 10_000, // 10 seconds
|
||||
onClick: (toast) => {
|
||||
toast.hideToast();
|
||||
states.prevLocation = location;
|
||||
navigate(
|
||||
instance
|
||||
? `/${instance}/s/${newStatus.id}`
|
||||
: `/s/${newStatus.id}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showSettings && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showSettings = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Settings
|
||||
onClose={() => {
|
||||
states.showSettings = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showAccounts && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showAccounts = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Accounts
|
||||
onClose={() => {
|
||||
states.showAccounts = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showAccount && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showAccount = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccountSheet
|
||||
account={snapStates.showAccount?.account || snapStates.showAccount}
|
||||
instance={snapStates.showAccount?.instance}
|
||||
onClose={({ destination }) => {
|
||||
states.showAccount = false;
|
||||
if (destination) {
|
||||
states.showAccounts = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showDrafts && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showDrafts = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Drafts onClose={() => (states.showDrafts = false)} />
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showMediaModal && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target === e.currentTarget ||
|
||||
e.target.classList.contains('media')
|
||||
) {
|
||||
states.showMediaModal = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MediaModal
|
||||
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
|
||||
instance={snapStates.showMediaModal.instance}
|
||||
index={snapStates.showMediaModal.index}
|
||||
statusID={snapStates.showMediaModal.statusID}
|
||||
onClose={() => {
|
||||
states.showMediaModal = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showShortcutsSettings && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showShortcutsSettings = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShortcutsSettings
|
||||
onClose={() => (states.showShortcutsSettings = false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||
</>
|
||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||
<Route path="/:instance?/a/:id" element={<AccountStatuses />} />
|
||||
<Route path="/:instance?/p">
|
||||
<Route index element={<Public />} />
|
||||
<Route path="l" element={<Public local />} />
|
||||
</Route>
|
||||
<Route path="/:instance?/trending" element={<Trending />} />
|
||||
<Route path="/:instance?/search" element={<Search />} />
|
||||
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundService({ isLoggedIn }) {
|
||||
// Notifications service
|
||||
// - WebSocket to receive notifications when page is visible
|
||||
const [visible, setVisible] = useState(true);
|
||||
usePageVisibility(setVisible);
|
||||
const notificationStream = useRef();
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && visible) {
|
||||
const { masto, instance } = api();
|
||||
(async () => {
|
||||
// 1. Get the latest notification
|
||||
if (states.notificationsLast) {
|
||||
const notificationsIterator = masto.v1.notifications.list({
|
||||
limit: 1,
|
||||
since_id: states.notificationsLast.id,
|
||||
});
|
||||
const { value: notifications } = await notificationsIterator.next();
|
||||
if (notifications?.length) {
|
||||
states.notificationsShowNew = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Start streaming
|
||||
notificationStream.current = await masto.ws.stream(
|
||||
'/api/v1/streaming',
|
||||
{
|
||||
stream: 'user:notification',
|
||||
},
|
||||
);
|
||||
console.log('🎏 Streaming notification', notificationStream.current);
|
||||
|
||||
notificationStream.current.on('notification', (notification) => {
|
||||
console.log('🔔🔔 Notification', notification);
|
||||
if (notification.status) {
|
||||
saveStatus(notification.status, instance, {
|
||||
skipThreading: true,
|
||||
});
|
||||
}
|
||||
states.notificationsShowNew = true;
|
||||
});
|
||||
|
||||
notificationStream.current.ws.onclose = () => {
|
||||
console.log('🔔🔔 Notification stream closed');
|
||||
};
|
||||
})();
|
||||
}
|
||||
return () => {
|
||||
if (notificationStream.current) {
|
||||
notificationStream.current.ws.close();
|
||||
notificationStream.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible, isLoggedIn]);
|
||||
|
||||
// Check for updates service
|
||||
const lastCheckDate = useRef();
|
||||
const checkForUpdates = () => {
|
||||
lastCheckDate.current = Date.now();
|
||||
console.log('✨ Check app update');
|
||||
fetch('./version.json')
|
||||
.then((r) => r.json())
|
||||
.then((info) => {
|
||||
if (info) states.appVersion = info;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
|
||||
usePageVisibility((visible) => {
|
||||
if (visible) {
|
||||
if (!lastCheckDate.current) {
|
||||
checkForUpdates();
|
||||
} else {
|
||||
const diff = Date.now() - lastCheckDate.current;
|
||||
if (diff > 1000 * 60 * 60) {
|
||||
// 1 hour
|
||||
checkForUpdates();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function StatusRoute() {
|
||||
const params = useParams();
|
||||
const { id, instance } = params;
|
||||
return <Status id={id} instance={instance} />;
|
||||
}
|
||||
|
||||
export { App };
|
||||
|
|
BIN
src/assets/features/catch-up.png
Normal file
BIN
src/assets/features/catch-up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
3
src/assets/powered-by-giphy.svg
Normal file
3
src/assets/powered-by-giphy.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
|
||||
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
|
@ -1,39 +1,71 @@
|
|||
body.cloak a {
|
||||
text-decoration-color: var(--link-color);
|
||||
}
|
||||
body.cloak,
|
||||
.cloak {
|
||||
a {
|
||||
text-decoration-color: var(--link-color);
|
||||
}
|
||||
|
||||
body.cloak .name-text,
|
||||
body.cloak .name-text *,
|
||||
body.cloak .status .content-container,
|
||||
body.cloak .status .content-container *,
|
||||
body.cloak .status .content-compact,
|
||||
body.cloak .account-container :is(header, main > *:not(.actions)),
|
||||
body.cloak .account-container :is(header, main > *:not(.actions)) *,
|
||||
body.cloak .header-account,
|
||||
body.cloak .account-block {
|
||||
text-decoration-thickness: 1.1em;
|
||||
text-decoration-line: line-through;
|
||||
text-rendering: optimizeSpeed;
|
||||
filter: opacity(0.5);
|
||||
}
|
||||
body.cloak .name-text *,
|
||||
body.cloak .status .content-container *,
|
||||
body.cloak .account-container :is(header, main > *:not(.actions)) * {
|
||||
filter: none;
|
||||
}
|
||||
.name-text,
|
||||
.name-text *,
|
||||
.status .content-container,
|
||||
.status .content-container *,
|
||||
.status .content-compact > *,
|
||||
.account-container .actions small,
|
||||
.account-container :is(header, main > *:not(.actions)),
|
||||
.account-container :is(header, main > *:not(.actions)) *,
|
||||
.header-double-lines *,
|
||||
.account-block,
|
||||
.catchup-filters .filter-author *,
|
||||
.post-peek-html *,
|
||||
.post-peek-content > *,
|
||||
.request-notifications-account *,
|
||||
.status.compact-thread *,
|
||||
.status .content-compact {
|
||||
text-decoration-thickness: 1.1em;
|
||||
text-decoration-line: line-through;
|
||||
/* text-rendering: optimizeSpeed; */
|
||||
filter: opacity(0.5);
|
||||
}
|
||||
.name-text *,
|
||||
.status .content-container *,
|
||||
.account-container :is(header, main > *:not(.actions)) *,
|
||||
.post-peek-content > * {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
body.cloak .status :is(img, video, audio),
|
||||
body.cloak .avatar,
|
||||
body.cloak .emoji,
|
||||
body.cloak .header-banner {
|
||||
filter: contrast(0) !important;
|
||||
background-color: #000 !important;
|
||||
.status :is(img, video, audio),
|
||||
.media-post .media,
|
||||
.avatar *,
|
||||
.emoji,
|
||||
.header-banner,
|
||||
.post-peek-media {
|
||||
filter: contrast(0) !important;
|
||||
background-color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* SPECIAL CASES */
|
||||
|
||||
@supports (display: -webkit-box) {
|
||||
body.cloak .card :is(.title, .meta) {
|
||||
background-color: var(--text-color) !important;
|
||||
:is(body.cloak, .cloak) .card :is(.title, .meta) {
|
||||
background-color: currentColor !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.cloak,
|
||||
.cloak {
|
||||
.header-double-lines *,
|
||||
.account-container .profile-metadata b,
|
||||
.account-container .actions small,
|
||||
.account-container .stats *,
|
||||
.media-container figcaption,
|
||||
.media-container figcaption > *,
|
||||
.catchup-filters .filter-author *,
|
||||
.request-notifications-account * {
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.account-container .actions small,
|
||||
.status .content-compact {
|
||||
background-color: currentColor !important;
|
||||
}
|
||||
}
|
||||
|
|
178
src/components/ICONS.jsx
Normal file
178
src/components/ICONS.jsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
export const ICONS = {
|
||||
x: () => import('@iconify-icons/mingcute/close-line'),
|
||||
heart: () => import('@iconify-icons/mingcute/heart-line'),
|
||||
bookmark: () => import('@iconify-icons/mingcute/bookmark-line'),
|
||||
'check-circle': () => import('@iconify-icons/mingcute/check-circle-line'),
|
||||
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
|
||||
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
|
||||
rocket: () => import('@iconify-icons/mingcute/rocket-line'),
|
||||
'arrow-left': {
|
||||
module: () => import('@iconify-icons/mingcute/arrow-left-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'arrow-right': {
|
||||
module: () => import('@iconify-icons/mingcute/arrow-right-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'arrow-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
|
||||
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
|
||||
earth: () => import('@iconify-icons/mingcute/earth-line'),
|
||||
lock: () => import('@iconify-icons/mingcute/lock-line'),
|
||||
unlock: () => import('@iconify-icons/mingcute/unlock-line'),
|
||||
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
|
||||
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
|
||||
message: () => import('@iconify-icons/mingcute/mail-line'),
|
||||
comment: {
|
||||
module: () => import('@iconify-icons/mingcute/chat-3-line'),
|
||||
rtl: true,
|
||||
},
|
||||
comment2: {
|
||||
module: () => import('@iconify-icons/mingcute/comment-2-line'),
|
||||
rtl: true,
|
||||
},
|
||||
home: () => import('@iconify-icons/mingcute/home-3-line'),
|
||||
notification: () => import('@iconify-icons/mingcute/notification-line'),
|
||||
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
|
||||
'follow-add': () => import('@iconify-icons/mingcute/user-add-line'),
|
||||
poll: [() => import('@iconify-icons/mingcute/chart-bar-line'), '90deg'],
|
||||
pencil: () => import('@iconify-icons/mingcute/pencil-line'),
|
||||
quill: () => import('@iconify-icons/mingcute/quill-pen-line'),
|
||||
at: () => import('@iconify-icons/mingcute/at-line'),
|
||||
attachment: () => import('@iconify-icons/mingcute/attachment-line'),
|
||||
upload: () => import('@iconify-icons/mingcute/upload-3-line'),
|
||||
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
|
||||
more: () => import('@iconify-icons/mingcute/more-3-line'),
|
||||
more2: () => import('@iconify-icons/mingcute/more-1-fill'),
|
||||
external: {
|
||||
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
rtl: true,
|
||||
},
|
||||
popout: {
|
||||
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
rtl: true,
|
||||
},
|
||||
popin: {
|
||||
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||
rotate: '180deg',
|
||||
rtl: true,
|
||||
},
|
||||
plus: () => import('@iconify-icons/mingcute/add-circle-line'),
|
||||
'chevron-left': {
|
||||
module: () => import('@iconify-icons/mingcute/left-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'chevron-right': {
|
||||
module: () => import('@iconify-icons/mingcute/right-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
|
||||
reply: {
|
||||
module: () => import('@iconify-icons/mingcute/share-forward-line'),
|
||||
rotate: '180deg',
|
||||
flip: 'horizontal',
|
||||
rtl: true,
|
||||
},
|
||||
thread: () => import('@iconify-icons/mingcute/route-line'),
|
||||
group: {
|
||||
module: () => import('@iconify-icons/mingcute/group-line'),
|
||||
rtl: true,
|
||||
},
|
||||
bot: () => import('@iconify-icons/mingcute/android-2-line'),
|
||||
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
|
||||
list: {
|
||||
module: () => import('@iconify-icons/mingcute/list-check-line'),
|
||||
rtl: true,
|
||||
},
|
||||
search: () => import('@iconify-icons/mingcute/search-2-line'),
|
||||
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
|
||||
info: () => import('@iconify-icons/mingcute/information-line'),
|
||||
shortcut: () => import('@iconify-icons/mingcute/lightning-line'),
|
||||
user: () => import('@iconify-icons/mingcute/user-4-line'),
|
||||
following: () => import('@iconify-icons/mingcute/walk-line'),
|
||||
pin: () => import('@iconify-icons/mingcute/pin-line'),
|
||||
unpin: [() => import('@iconify-icons/mingcute/pin-line'), '180deg'],
|
||||
bus: () => import('@iconify-icons/mingcute/bus-2-line'),
|
||||
link: () => import('@iconify-icons/mingcute/link-2-line'),
|
||||
history: () => import('@iconify-icons/mingcute/history-line'),
|
||||
share: () => import('@iconify-icons/mingcute/share-2-line'),
|
||||
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
|
||||
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
|
||||
exit: {
|
||||
module: () => import('@iconify-icons/mingcute/exit-line'),
|
||||
rtl: true,
|
||||
},
|
||||
translate: () => import('@iconify-icons/mingcute/translate-line'),
|
||||
play: () => import('@iconify-icons/mingcute/play-fill'),
|
||||
trash: () => import('@iconify-icons/mingcute/delete-2-line'),
|
||||
mute: {
|
||||
module: () => import('@iconify-icons/mingcute/volume-mute-line'),
|
||||
rtl: true,
|
||||
},
|
||||
unmute: {
|
||||
module: () => import('@iconify-icons/mingcute/volume-line'),
|
||||
rtl: true,
|
||||
},
|
||||
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
|
||||
unblock: [
|
||||
() => import('@iconify-icons/mingcute/forbid-circle-line'),
|
||||
'180deg',
|
||||
],
|
||||
flag: () => import('@iconify-icons/mingcute/flag-1-line'),
|
||||
time: () => import('@iconify-icons/mingcute/time-line'),
|
||||
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
||||
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
||||
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
|
||||
filters: () => import('@iconify-icons/mingcute/filter-line'),
|
||||
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
||||
react: () => import('@iconify-icons/mingcute/react-line'),
|
||||
layout4: {
|
||||
module: () => import('@iconify-icons/mingcute/layout-4-line'),
|
||||
rtl: true,
|
||||
},
|
||||
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
|
||||
announce: {
|
||||
module: () => import('@iconify-icons/mingcute/announcement-line'),
|
||||
rtl: true,
|
||||
},
|
||||
alert: () => import('@iconify-icons/mingcute/alert-line'),
|
||||
round: () => import('@iconify-icons/mingcute/round-fill'),
|
||||
'arrow-up-circle': () =>
|
||||
import('@iconify-icons/mingcute/arrow-up-circle-line'),
|
||||
'arrow-down-circle': () =>
|
||||
import('@iconify-icons/mingcute/arrow-down-circle-line'),
|
||||
clipboard: {
|
||||
module: () => import('@iconify-icons/mingcute/clipboard-line'),
|
||||
rtl: true,
|
||||
},
|
||||
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
|
||||
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
|
||||
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
|
||||
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
|
||||
month: {
|
||||
module: () => import('@iconify-icons/mingcute/calendar-month-line'),
|
||||
rtl: true,
|
||||
},
|
||||
media: () => import('@iconify-icons/mingcute/photo-album-line'),
|
||||
speak: () => import('@iconify-icons/mingcute/radar-line'),
|
||||
building: () => import('@iconify-icons/mingcute/building-5-line'),
|
||||
history2: {
|
||||
module: () => import('@iconify-icons/mingcute/history-2-line'),
|
||||
rtl: true,
|
||||
},
|
||||
document: () => import('@iconify-icons/mingcute/document-line'),
|
||||
'arrows-right': {
|
||||
module: () => import('@iconify-icons/mingcute/arrows-right-line'),
|
||||
rtl: true,
|
||||
},
|
||||
code: () => import('@iconify-icons/mingcute/code-line'),
|
||||
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
|
||||
quote: {
|
||||
module: () => import('@iconify-icons/mingcute/quote-left-line'),
|
||||
rtl: true,
|
||||
},
|
||||
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
||||
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
|
||||
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
|
||||
'user-setting': () => import('@iconify-icons/mingcute/user-setting-line'),
|
||||
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
|
||||
};
|
|
@ -4,6 +4,10 @@
|
|||
gap: 8px;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
|
||||
.account-block-acct {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
.account-block:hover b {
|
||||
text-decoration: underline;
|
||||
|
@ -12,3 +16,57 @@
|
|||
.account-block.skeleton {
|
||||
color: var(--bg-faded-color);
|
||||
}
|
||||
|
||||
.account-block .verified-field {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
|
||||
* {
|
||||
-webkit-box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
unicode-bidi: isolate;
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
a {
|
||||
pointer-events: none;
|
||||
color: color-mix(
|
||||
in lch,
|
||||
var(--green-color) 20%,
|
||||
var(--text-insignificant-color) 80%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--green-color);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.invisible {
|
||||
display: none;
|
||||
}
|
||||
.ellipsis:after {
|
||||
content: '…';
|
||||
}
|
||||
}
|
||||
|
||||
.account-block .account-block-stats {
|
||||
line-height: 1.25;
|
||||
margin-top: 2px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-insignificant-color);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 4px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
import './account-block.css';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import niceDateTime from '../utils/nice-date-time';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import EmojiText from './emoji-text';
|
||||
import Icon from './icon';
|
||||
|
||||
function AccountBlock({
|
||||
skeleton,
|
||||
account,
|
||||
avatarSize = 'xl',
|
||||
useAvatarStatic = false,
|
||||
instance,
|
||||
external,
|
||||
internal,
|
||||
onClick,
|
||||
showActivity = false,
|
||||
showStats = false,
|
||||
accountInstance,
|
||||
hideDisplayName = false,
|
||||
relationship = {},
|
||||
excludeRelationshipAttrs = [],
|
||||
}) {
|
||||
if (skeleton) {
|
||||
return (
|
||||
|
@ -25,13 +33,17 @@ function AccountBlock({
|
|||
<span>
|
||||
<b>████████</b>
|
||||
<br />
|
||||
<span class="account-block-acct">@██████</span>
|
||||
<span class="account-block-acct">██████</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
id,
|
||||
|
@ -45,21 +57,44 @@ function AccountBlock({
|
|||
statusesCount,
|
||||
lastStatusAt,
|
||||
bot,
|
||||
fields,
|
||||
note,
|
||||
group,
|
||||
followersCount,
|
||||
createdAt,
|
||||
locked,
|
||||
} = account;
|
||||
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||
if (accountInstance) {
|
||||
acct2 = `@${accountInstance}`;
|
||||
}
|
||||
|
||||
const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value);
|
||||
|
||||
const excludedRelationship = {};
|
||||
for (const r in relationship) {
|
||||
if (!excludeRelationshipAttrs.includes(r)) {
|
||||
excludedRelationship[r] = relationship[r];
|
||||
}
|
||||
}
|
||||
const hasRelationship =
|
||||
excludedRelationship.following ||
|
||||
excludedRelationship.followedBy ||
|
||||
excludedRelationship.requested;
|
||||
|
||||
return (
|
||||
<a
|
||||
class="account-block"
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`@${acct}`}
|
||||
title={acct2 ? acct : `@${acct}`}
|
||||
onClick={(e) => {
|
||||
if (external) return;
|
||||
e.preventDefault();
|
||||
if (onClick) return onClick(e);
|
||||
if (internal) {
|
||||
navigate(`/${instance}/a/${id}`);
|
||||
// navigate(`/${instance}/a/${id}`);
|
||||
location.hash = `/${instance}/a/${id}`;
|
||||
} else {
|
||||
states.showAccount = {
|
||||
account,
|
||||
|
@ -68,37 +103,113 @@ function AccountBlock({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Avatar url={avatar} size={avatarSize} squircle={bot} />
|
||||
<span>
|
||||
{displayName ? (
|
||||
<b>
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
) : (
|
||||
<b>{username}</b>
|
||||
)}
|
||||
<br />
|
||||
<span class="account-block-acct">
|
||||
@{acct1}
|
||||
<Avatar
|
||||
url={useAvatarStatic ? avatarStatic : avatar || avatarStatic}
|
||||
size={avatarSize}
|
||||
squircle={bot}
|
||||
/>
|
||||
<span class="account-block-content">
|
||||
{!hideDisplayName && (
|
||||
<>
|
||||
{displayName ? (
|
||||
<b>
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
) : (
|
||||
<b>{username}</b>
|
||||
)}
|
||||
</>
|
||||
)}{' '}
|
||||
<span class="account-block-acct bidi-isolate">
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
<wbr />
|
||||
{acct2}
|
||||
{locked && (
|
||||
<>
|
||||
{' '}
|
||||
<Icon icon="lock" size="s" alt="Locked" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{showActivity && (
|
||||
<>
|
||||
<br />
|
||||
<small class="last-status-at insignificant">
|
||||
Posts: {statusesCount}
|
||||
{!!lastStatusAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Last posted:{' '}
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</>
|
||||
<div class="account-block-stats">
|
||||
Posts: {shortenNumber(statusesCount)}
|
||||
{!!lastStatusAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Last posted:{' '}
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showStats && (
|
||||
<div class="account-block-stats">
|
||||
{bot && (
|
||||
<>
|
||||
<span class="tag collapsed">
|
||||
<Icon icon="bot" /> Automated
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!!group && (
|
||||
<>
|
||||
<span class="tag collapsed">
|
||||
<Icon icon="group" /> Group
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasRelationship && (
|
||||
<div key={relationship.id} class="shazam-container-horizontal">
|
||||
<div class="shazam-container-inner">
|
||||
{excludedRelationship.following &&
|
||||
excludedRelationship.followedBy ? (
|
||||
<span class="tag minimal">Mutual</span>
|
||||
) : excludedRelationship.requested ? (
|
||||
<span class="tag minimal">Requested</span>
|
||||
) : excludedRelationship.following ? (
|
||||
<span class="tag minimal">Following</span>
|
||||
) : excludedRelationship.followedBy ? (
|
||||
<span class="tag minimal">Follows you</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!followersCount && (
|
||||
<span class="ib">
|
||||
{shortenNumber(followersCount)}{' '}
|
||||
{followersCount === 1 ? 'follower' : 'followers'}
|
||||
</span>
|
||||
)}
|
||||
{!!verifiedField && (
|
||||
<span class="verified-field">
|
||||
<Icon icon="check-circle" size="s" />{' '}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: enhanceContent(verifiedField.value, { emojis }),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!bot &&
|
||||
!group &&
|
||||
!hasRelationship &&
|
||||
!followersCount &&
|
||||
!verifiedField &&
|
||||
!!createdAt && (
|
||||
<span class="created-at">
|
||||
Joined{' '}
|
||||
<time datetime={createdAt}>
|
||||
{niceDateTime(createdAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</time>
|
||||
</span>
|
||||
)}
|
||||
</small>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
|
|
|
@ -1,16 +1,152 @@
|
|||
.account-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* display: flex; */
|
||||
/* flex-direction: column; */
|
||||
/* overflow: hidden; */
|
||||
overflow-y: auto;
|
||||
max-width: 100%;
|
||||
--banner-overlap: 44px;
|
||||
--posting-stats-size: 8px;
|
||||
--original-color: var(--link-color);
|
||||
|
||||
.note {
|
||||
font-size: 0.95em;
|
||||
line-height: 1.4;
|
||||
text-wrap: pretty;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:not(:has(p)):not(:empty) {
|
||||
/* Some notes don't have <p> tags, so we need to add some padding */
|
||||
padding: 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.posting-stats {
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 8px 12px;
|
||||
|
||||
&:is(:hover, :focus-within) {
|
||||
background-color: var(--link-bg-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
.posting-stats-bar {
|
||||
--gap: 0.5px;
|
||||
--gap-color: var(--outline-color);
|
||||
height: var(--posting-stats-size);
|
||||
border-radius: var(--posting-stats-size);
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
box-shadow: inset 0 0 0 1px var(--outline-color),
|
||||
inset 0 0 0 1.5px var(--bg-blur-color);
|
||||
background-color: var(--bg-color);
|
||||
background-repeat: no-repeat;
|
||||
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
|
||||
background-image: linear-gradient(
|
||||
var(--to-forward),
|
||||
var(--original-color) 0%,
|
||||
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
|
||||
var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
|
||||
var(--gap-color) calc(var(--originals-percentage) + var(--gap)),
|
||||
var(--reply-to-color) calc(var(--originals-percentage) + var(--gap)),
|
||||
var(--reply-to-color) calc(var(--replies-percentage) - var(--gap)),
|
||||
var(--gap-color) calc(var(--replies-percentage) - var(--gap)),
|
||||
var(--gap-color) calc(var(--replies-percentage) + var(--gap)),
|
||||
var(--reblog-color) calc(var(--replies-percentage) + var(--gap)),
|
||||
var(--reblog-color) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.posting-stats-legends {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.posting-stats-legend-item {
|
||||
display: inline-block;
|
||||
width: var(--posting-stats-size);
|
||||
height: var(--posting-stats-size);
|
||||
border-radius: var(--posting-stats-size);
|
||||
background-color: var(--text-insignificant-color);
|
||||
vertical-align: middle;
|
||||
margin: 0 4px 2px;
|
||||
/* border: 1px solid var(--outline-color); */
|
||||
box-shadow: inset 0 0 0 1px var(--outline-color),
|
||||
inset 0 0 0 1.5px var(--bg-blur-color);
|
||||
|
||||
&.posting-stats-legend-item-originals {
|
||||
background-color: var(--original-color);
|
||||
}
|
||||
&.posting-stats-legend-item-replies {
|
||||
background-color: var(--reply-to-color);
|
||||
}
|
||||
&.posting-stats-legend-item-boosts {
|
||||
background-color: var(--reblog-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.account-container.skeleton {
|
||||
color: var(--outline-color);
|
||||
}
|
||||
|
||||
.account-container .account-moved {
|
||||
animation: fade-in 0.3s both ease-in-out 0.3s;
|
||||
padding: 16px;
|
||||
background-color: var(--bg-color);
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline: 8px;
|
||||
z-index: 3;
|
||||
border: 1px solid var(--outline-color);
|
||||
box-shadow: 0 8px 16px var(--drop-shadow-color);
|
||||
border-radius: calc(16px - 8px);
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.account-block {
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--link-faded-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--link-bg-hover-color);
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
b {
|
||||
color: var(--link-color);
|
||||
}
|
||||
}
|
||||
|
||||
~ * {
|
||||
/* pointer-events: none; */
|
||||
filter: grayscale(0.75) opacity(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.account-container .header-banner {
|
||||
/* pointer-events: none; */
|
||||
vertical-align: top;
|
||||
aspect-ratio: 6 / 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
@ -35,7 +171,7 @@
|
|||
hsla(0, 0%, 0%, 0.013) 95.3%,
|
||||
hsla(0, 0%, 0%, 0) 100%
|
||||
);
|
||||
margin-bottom: -44px;
|
||||
margin-bottom: calc(-1 * var(--banner-overlap));
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
|
@ -45,8 +181,8 @@
|
|||
opacity: 1;
|
||||
}
|
||||
.sheet .account-container .header-banner {
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
border-start-start-radius: 16px;
|
||||
border-start-end-radius: 16px;
|
||||
}
|
||||
.account-container .header-banner.header-is-avatar {
|
||||
mask-image: linear-gradient(
|
||||
|
@ -76,18 +212,26 @@
|
|||
}
|
||||
.account-container .header-banner:active {
|
||||
mask-image: none;
|
||||
}
|
||||
.account-container .header-banner:active + header .avatar + * {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
.account-container .header-banner:active + header .avatar {
|
||||
transition: filter 0.3s ease-in-out;
|
||||
filter: none !important;
|
||||
}
|
||||
.account-container .header-banner:active + header .avatar img {
|
||||
transition: border-radius 0.3s ease-in-out;
|
||||
border-radius: 8px;
|
||||
|
||||
& + header {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
& + header .avatar + * {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
&,
|
||||
& + header .avatar {
|
||||
transition: filter 0.3s ease-in-out;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
& + header .avatar img {
|
||||
transition: border-radius 0.3s ease-in-out;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 480px) {
|
||||
|
@ -125,35 +269,140 @@
|
|||
animation: fade-in 0.3s both ease-in-out 0.2s;
|
||||
}
|
||||
|
||||
.account-container .note {
|
||||
font-size: 95%;
|
||||
line-height: 1.4;
|
||||
.account-container .account-block .account-block-acct {
|
||||
display: block;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.account-container .note:not(:has(p)):not(:empty) {
|
||||
/* Some notes don't have <p> tags, so we need to add some padding */
|
||||
padding: 1em 0;
|
||||
|
||||
.private-note-tag {
|
||||
z-index: 1;
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
color: var(--private-note-text-color);
|
||||
background-color: var(--private-note-bg-color);
|
||||
border: 1px solid var(--private-note-border-color);
|
||||
padding: 4px;
|
||||
line-height: normal;
|
||||
font-size: smaller;
|
||||
border-radius: 0;
|
||||
align-self: center !important;
|
||||
/* clip a dog ear on top right */
|
||||
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
|
||||
&:dir(rtl) {
|
||||
/* top left */
|
||||
clip-path: polygon(4px 0, 100% 0, 100% 100%, 0 100%, 0 4px);
|
||||
}
|
||||
/* 4x4px square on top right */
|
||||
background-size: 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top right;
|
||||
&:dir(rtl) {
|
||||
background-position: top left;
|
||||
}
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--private-note-border-color),
|
||||
var(--private-note-border-color)
|
||||
);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
span {
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: box;
|
||||
-webkit-box-orient: vertical;
|
||||
box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
&:hover:not(:active) {
|
||||
filter: none !important;
|
||||
transform: rotate(-0.5deg) scale(1.05);
|
||||
|
||||
span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.account-container .private-note {
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-faded-color);
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
|
||||
b {
|
||||
font-size: 90%;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.account-container .stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
gap: 16px;
|
||||
opacity: 0.75;
|
||||
/* flex-wrap: wrap; */
|
||||
column-gap: 24px;
|
||||
row-gap: 8px;
|
||||
/* opacity: 0.75; */
|
||||
font-size: 90%;
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
/* border-radius: 16px; */
|
||||
line-height: 1.25;
|
||||
overflow-x: auto !important;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
|
||||
[tabindex='0']:is(:hover, :focus) {
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
text-decoration-color: var(--text-insignificant-color);
|
||||
}
|
||||
|
||||
.stats-avatars-bunch {
|
||||
animation: appear 1s both ease-in-out;
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin: 0;
|
||||
margin-inline-start: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.timeline-start .account-container .stats {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.account-container .stats > * {
|
||||
text-align: center;
|
||||
/* text-align: center; */
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.account-container .stats a {
|
||||
.account-container .stats a:not(.insignificant) {
|
||||
color: inherit;
|
||||
}
|
||||
.account-container .stats a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.account-container footer {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.account-container .actions {
|
||||
/* margin-block: 8px; */
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
@ -161,18 +410,47 @@
|
|||
align-items: center;
|
||||
}
|
||||
.account-container .actions button {
|
||||
align-self: flex-end;
|
||||
/* align-self: flex-end; */
|
||||
}
|
||||
.account-container .actions .buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-container .account-metadata-box {
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
|
||||
& > * {
|
||||
margin-bottom: 2px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:has(+ .account-metadata-box) {
|
||||
border-end-start-radius: 4px;
|
||||
border-end-end-radius: 4px;
|
||||
}
|
||||
|
||||
+ .account-metadata-box {
|
||||
border-start-start-radius: 4px;
|
||||
border-start-end-radius: 4px;
|
||||
border-end-start-radius: 16px;
|
||||
border-end-end-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-container .profile-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* flex-wrap: wrap; */
|
||||
gap: 2px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.timeline-start .account-container .profile-metadata {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.account-container .profile-field {
|
||||
min-width: 0;
|
||||
|
@ -183,6 +461,15 @@
|
|||
border-radius: 4px;
|
||||
filter: saturate(0.75);
|
||||
line-height: 1.25;
|
||||
flex-shrink: 0;
|
||||
max-width: calc(100% - 12px - 2em);
|
||||
}
|
||||
.account-container .profile-field:only-child {
|
||||
max-width: 100%;
|
||||
}
|
||||
.timeline-start .account-container .profile-field {
|
||||
flex-shrink: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-container :is(.note, .profile-field) .invisible {
|
||||
|
@ -204,67 +491,210 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.account-container .common-followers p {
|
||||
.account-container .common-followers {
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
border-top: 1px solid var(--outline-color);
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
padding: 8px 0;
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline-start .account-container {
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
position: relative;
|
||||
}
|
||||
.timeline-start .account-container header {
|
||||
padding: 16px 16px 1px;
|
||||
padding: 16px;
|
||||
animation: none;
|
||||
}
|
||||
.timeline-start .account-container main {
|
||||
padding: 1px 16px 1px;
|
||||
padding: 1px 16px 16px;
|
||||
}
|
||||
.timeline-start .account-container main > * {
|
||||
animation: none;
|
||||
}
|
||||
.timeline-start .account-container .account-block .account-block-acct {
|
||||
opacity: 0.5;
|
||||
|
||||
.faux-header-bg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
0% {
|
||||
left: -100%;
|
||||
@keyframes bye-banner {
|
||||
20% {
|
||||
filter: blur(0) opacity(1);
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
filter: blur(16px) opacity(0.2);
|
||||
}
|
||||
}
|
||||
.timeline-start .account-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@keyframes surface-header {
|
||||
0% {
|
||||
border-bottom-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
100% {
|
||||
border-bottom-color: var(--outline-color);
|
||||
box-shadow: 0 8px 16px -8px var(--drop-shadow-color);
|
||||
}
|
||||
}
|
||||
.timeline-start .account-container:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
rgba(255, 255, 255, 0) 30%,
|
||||
rgba(255, 255, 255, 0.25),
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
);
|
||||
top: 0;
|
||||
left: -100%;
|
||||
pointer-events: none;
|
||||
@keyframes shrink-avatar {
|
||||
0% {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
100% {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.timeline-start .account-container:before {
|
||||
.sheet .account-container {
|
||||
border-radius: 16px 16px 0 0;
|
||||
overflow-x: hidden;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior: none;
|
||||
scroll-timeline: --account-scroll;
|
||||
|
||||
header {
|
||||
padding-bottom: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 30%,
|
||||
var(--bg-color) var(--banner-overlap),
|
||||
var(--bg-color) calc(100% - 8px),
|
||||
transparent
|
||||
);
|
||||
|
||||
.account-block-content {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.faux-header-bg {
|
||||
display: block;
|
||||
height: var(--banner-overlap);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bg-color);
|
||||
margin-top: calc(-1 * var(--banner-overlap));
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.header-banner:not(.header-is-avatar):not(:hover):not(:active) {
|
||||
animation: bye-banner 1s linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: contain 100% cover 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 30%,
|
||||
var(--bg-color) var(--banner-overlap)
|
||||
);
|
||||
border-bottom: 1px solid transparent;
|
||||
animation: surface-header 1s linear both;
|
||||
animation-timeline: --account-scroll;
|
||||
animation-range: 0 150px;
|
||||
}
|
||||
|
||||
header .avatar {
|
||||
animation: shrink-avatar 1s linear both;
|
||||
animation-timeline: --account-scroll;
|
||||
animation-range: 0 150px;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
/* margin-top: -8px; */
|
||||
padding-top: 1px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
min-height: calc(40px + 16px);
|
||||
animation: slide-up 0.3s ease-out 0.3s both;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
backdrop-filter: blur(16px) saturate(3);
|
||||
padding: 8px 16px;
|
||||
border-top: var(--hairline-width) solid var(--outline-color);
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -8px 16px -8px var(--drop-shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swoosh-bg-image {
|
||||
0% {
|
||||
background-position: -320px 0;
|
||||
opacity: 0.25;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.timeline-start .account-container:hover:before {
|
||||
animation: shine 1s ease-in-out 1s;
|
||||
.account-container .posting-stats-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 8px 12px;
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
line-height: 1;
|
||||
vertical-align: text-top;
|
||||
border-radius: 4px;
|
||||
|
||||
&:is(:hover, :focus-within) {
|
||||
color: var(--text-color);
|
||||
background-color: var(--link-bg-hover-color);
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wobble {
|
||||
0% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(4deg);
|
||||
}
|
||||
}
|
||||
@keyframes loading-spin {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(0.75);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(0.75);
|
||||
}
|
||||
}
|
||||
.posting-stats-icon {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 8px;
|
||||
filter: opacity(0.75);
|
||||
animation: wobble 2s linear both infinite alternate !important;
|
||||
|
||||
&.loading {
|
||||
animation: loading-spin 0.35s linear both infinite !important;
|
||||
}
|
||||
}
|
||||
|
||||
#list-add-remove-container .list-add-remove {
|
||||
|
@ -297,6 +727,7 @@
|
|||
|
||||
@media (min-width: 40em) {
|
||||
.timeline-start .account-container {
|
||||
--banner-overlap: 77px;
|
||||
--item-radius: 16px;
|
||||
border: 1px solid var(--divider-color);
|
||||
margin: 16px 0;
|
||||
|
@ -313,12 +744,12 @@
|
|||
var(--shadow-offset) var(--shadow-offset) var(--shadow-blur)
|
||||
var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color));
|
||||
}
|
||||
.timeline-start .account-container .header-banner {
|
||||
/* .timeline-start .account-container .header-banner {
|
||||
margin-bottom: -77px;
|
||||
}
|
||||
} */
|
||||
.timeline-start .account-container header .account-block {
|
||||
font-size: 175%;
|
||||
margin-bottom: -8px;
|
||||
/* margin-bottom: -8px; */
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.5px;
|
||||
mix-blend-mode: multiply;
|
||||
|
@ -331,3 +762,135 @@
|
|||
drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
|
||||
}
|
||||
}
|
||||
|
||||
#private-note-container {
|
||||
textarea {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
height: 33vh;
|
||||
min-height: 25vh;
|
||||
max-height: 50vh;
|
||||
color: var(--private-note-text-color);
|
||||
background-color: var(--private-note-bg-color);
|
||||
border: 1px solid var(--private-note-border-color);
|
||||
box-shadow: 0 2px 8px var(--drop-shadow-color);
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#edit-profile-container {
|
||||
p {
|
||||
margin-block: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
input,
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 5em;
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
color: var(--text-insignificant-color);
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody tr td:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.handle-info {
|
||||
.handle-handle {
|
||||
display: inline-block;
|
||||
margin-block: 5px;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 0 5px var(--bg-blur-color);
|
||||
|
||||
&.handle-username {
|
||||
color: var(--orange-fg-color);
|
||||
background-color: var(--orange-bg-color);
|
||||
}
|
||||
|
||||
&.handle-server {
|
||||
color: var(--purple-fg-color);
|
||||
background-color: var(--purple-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.handle-at {
|
||||
display: inline-block;
|
||||
margin-inline: -3px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.handle-legend {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.handle-legend-icon {
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 4px solid transparent;
|
||||
border-radius: 8px;
|
||||
background-clip: padding-box;
|
||||
|
||||
&.username {
|
||||
background-color: var(--orange-fg-color);
|
||||
border-color: var(--orange-bg-color);
|
||||
}
|
||||
&.server {
|
||||
background-color: var(--purple-fg-color);
|
||||
border-color: var(--purple-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,8 +1,8 @@
|
|||
import { useEffect } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import states from '../utils/states';
|
||||
import useLocationChange from '../utils/useLocationChange';
|
||||
|
||||
import AccountInfo from './account-info';
|
||||
import Icon from './icon';
|
||||
|
@ -11,26 +11,25 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
|
|||
const { masto, instance, authenticated } = api({ instance: propInstance });
|
||||
const isString = typeof account === 'string';
|
||||
|
||||
const escRef = useHotkeys('esc', onClose, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isString) {
|
||||
states.accounts[`${account.id}@${instance}`] = account;
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
useLocationChange(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={escRef}
|
||||
class="sheet"
|
||||
onClick={(e) => {
|
||||
const accountBlock = e.target.closest('.account-block');
|
||||
if (accountBlock) {
|
||||
onClose({
|
||||
destination: 'account-statuses',
|
||||
});
|
||||
}
|
||||
}}
|
||||
// onClick={(e) => {
|
||||
// const accountBlock = e.target.closest('.account-block');
|
||||
// if (accountBlock) {
|
||||
// onClose({
|
||||
// destination: 'account-statuses',
|
||||
// });
|
||||
// }
|
||||
// }}
|
||||
>
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
|
@ -50,7 +49,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
|
|||
});
|
||||
return info;
|
||||
} catch (e) {
|
||||
const result = await masto.v2.search({
|
||||
const result = await masto.v2.search.fetch({
|
||||
q: account,
|
||||
type: 'accounts',
|
||||
limit: 1,
|
||||
|
@ -58,6 +57,22 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
|
|||
});
|
||||
if (result.accounts.length) {
|
||||
return result.accounts[0];
|
||||
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
|
||||
const accountURL = URL.parse(account);
|
||||
const { hostname, pathname } = accountURL;
|
||||
const acct =
|
||||
pathname.replace(/^\//, '').replace(/\/$/, '') +
|
||||
'@' +
|
||||
hostname;
|
||||
const result = await masto.v2.search.fetch({
|
||||
q: acct,
|
||||
type: 'accounts',
|
||||
limit: 1,
|
||||
resolve: authenticated,
|
||||
});
|
||||
if (result.accounts.length) {
|
||||
return result.accounts[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -8,23 +8,31 @@
|
|||
box-shadow: 0 0 0 1px var(--bg-blur-color);
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.avatar.has-alpha {
|
||||
border-radius: 0;
|
||||
}
|
||||
.avatar:not(.has-alpha).squircle {
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--img-bg-color);
|
||||
}
|
||||
&.has-alpha {
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
.avatar[data-loaded],
|
||||
.avatar[data-loaded] img {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
img {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
&:not(.has-alpha).squircle {
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--img-bg-color);
|
||||
contain: none;
|
||||
}
|
||||
|
||||
&[data-loaded],
|
||||
&[data-loaded] img {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import './avatar.css';
|
|||
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import mem from '../utils/mem';
|
||||
|
||||
const SIZES = {
|
||||
s: 16,
|
||||
m: 20,
|
||||
|
@ -16,7 +18,10 @@ const alphaCache = {};
|
|||
const canvas = window.OffscreenCanvas
|
||||
? new OffscreenCanvas(1, 1)
|
||||
: document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||
size = SIZES[size] || size || SIZES.m;
|
||||
|
@ -58,29 +63,32 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
|||
if (avatarRef.current) avatarRef.current.dataset.loaded = true;
|
||||
if (alphaCache[url] !== undefined) return;
|
||||
if (isMissing) return;
|
||||
try {
|
||||
// Check if image has alpha channel
|
||||
const { width, height } = e.target;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
ctx.drawImage(e.target, 0, 0);
|
||||
const allPixels = ctx.getImageData(0, 0, width, height);
|
||||
// At least 10% of pixels have alpha <= 128
|
||||
const hasAlpha =
|
||||
allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128)
|
||||
.length /
|
||||
(allPixels.data.length / 4) >
|
||||
0.1;
|
||||
if (hasAlpha) {
|
||||
// console.log('hasAlpha', hasAlpha, allPixels.data);
|
||||
avatarRef.current.classList.add('has-alpha');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Check if image has alpha channel
|
||||
const { width, height } = e.target;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
ctx.drawImage(e.target, 0, 0);
|
||||
const allPixels = ctx.getImageData(0, 0, width, height);
|
||||
// At least 10% of pixels have alpha <= 128
|
||||
const hasAlpha =
|
||||
allPixels.data.filter(
|
||||
(pixel, i) => i % 4 === 3 && pixel <= 128,
|
||||
).length /
|
||||
(allPixels.data.length / 4) >
|
||||
0.1;
|
||||
if (hasAlpha) {
|
||||
// console.log('hasAlpha', hasAlpha, allPixels.data);
|
||||
avatarRef.current.classList.add('has-alpha');
|
||||
}
|
||||
alphaCache[url] = hasAlpha;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
alphaCache[url] = false;
|
||||
}
|
||||
alphaCache[url] = hasAlpha;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
alphaCache[url] = false;
|
||||
}
|
||||
}, 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -88,4 +96,4 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
export default mem(Avatar);
|
||||
|
|
142
src/components/background-service.jsx
Normal file
142
src/components/background-service.jsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import useInterval from '../utils/useInterval';
|
||||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
|
||||
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
|
||||
const POLL_INTERVAL = 20_000; // 20 seconds
|
||||
|
||||
export default memo(function BackgroundService({ isLoggedIn }) {
|
||||
// Notifications service
|
||||
// - WebSocket to receive notifications when page is visible
|
||||
const [visible, setVisible] = useState(true);
|
||||
usePageVisibility(setVisible);
|
||||
const checkLatestNotification = async (masto, instance, skipCheckMarkers) => {
|
||||
if (states.notificationsLast) {
|
||||
const notificationsIterator = masto.v1.notifications.list({
|
||||
limit: 1,
|
||||
sinceId: states.notificationsLast.id,
|
||||
});
|
||||
const { value: notifications } = await notificationsIterator.next();
|
||||
if (notifications?.length) {
|
||||
if (skipCheckMarkers) {
|
||||
states.notificationsShowNew = true;
|
||||
} else {
|
||||
let lastReadId;
|
||||
try {
|
||||
const markers = await masto.v1.markers.fetch({
|
||||
timeline: 'notifications',
|
||||
});
|
||||
lastReadId = markers?.notifications?.lastReadId;
|
||||
} catch (e) {}
|
||||
if (lastReadId) {
|
||||
states.notificationsShowNew = notifications[0].id !== lastReadId;
|
||||
} else {
|
||||
states.notificationsShowNew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sub;
|
||||
let streamTimeout;
|
||||
let pollNotifications;
|
||||
if (isLoggedIn && visible) {
|
||||
const { masto, streaming, instance } = api();
|
||||
(async () => {
|
||||
// 1. Get the latest notification
|
||||
await checkLatestNotification(masto, instance);
|
||||
|
||||
let hasStreaming = false;
|
||||
// 2. Start streaming
|
||||
if (streaming) {
|
||||
streamTimeout = setTimeout(() => {
|
||||
(async () => {
|
||||
try {
|
||||
hasStreaming = true;
|
||||
sub = streaming.user.notification.subscribe();
|
||||
console.log('🎏 Streaming notification', sub);
|
||||
for await (const entry of sub) {
|
||||
if (!sub) break;
|
||||
if (!visible) break;
|
||||
console.log('🔔🔔 Notification entry', entry);
|
||||
if (entry.event === 'notification') {
|
||||
console.log('🔔🔔 Notification', entry);
|
||||
saveStatus(entry.payload, instance, {
|
||||
skipThreading: true,
|
||||
});
|
||||
}
|
||||
states.notificationsShowNew = true;
|
||||
}
|
||||
console.log('💥 Streaming notification loop STOPPED');
|
||||
} catch (e) {
|
||||
hasStreaming = false;
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (!hasStreaming) {
|
||||
console.log('🎏 Streaming failed, fallback to polling');
|
||||
pollNotifications = setInterval(() => {
|
||||
checkLatestNotification(masto, instance, true);
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
})();
|
||||
}, STREAMING_TIMEOUT);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return () => {
|
||||
sub?.unsubscribe?.();
|
||||
sub = null;
|
||||
clearTimeout(streamTimeout);
|
||||
clearInterval(pollNotifications);
|
||||
};
|
||||
}, [visible, isLoggedIn]);
|
||||
|
||||
// Check for updates service
|
||||
const lastCheckDate = useRef();
|
||||
const checkForUpdates = () => {
|
||||
lastCheckDate.current = Date.now();
|
||||
console.log('✨ Check app update');
|
||||
fetch('./version.json')
|
||||
.then((r) => r.json())
|
||||
.then((info) => {
|
||||
if (info) states.appVersion = info;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
|
||||
usePageVisibility((visible) => {
|
||||
if (visible) {
|
||||
if (!lastCheckDate.current) {
|
||||
checkForUpdates();
|
||||
} else {
|
||||
const diff = Date.now() - lastCheckDate.current;
|
||||
if (diff > 1000 * 60 * 60) {
|
||||
// 1 hour
|
||||
checkForUpdates();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts "service"
|
||||
useHotkeys('shift+alt+k', () => {
|
||||
const currentCloakMode = states.settings.cloakMode;
|
||||
states.settings.cloakMode = !currentCloakMode;
|
||||
showToast({
|
||||
text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`,
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
|
@ -9,6 +9,7 @@ import List from '../pages/list';
|
|||
import Mentions from '../pages/mentions';
|
||||
import Notifications from '../pages/notifications';
|
||||
import Public from '../pages/public';
|
||||
import Search from '../pages/search';
|
||||
import Trending from '../pages/trending';
|
||||
import states from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
@ -18,6 +19,8 @@ function Columns() {
|
|||
const snapStates = useSnapshot(states);
|
||||
const { shortcuts } = snapStates;
|
||||
|
||||
console.debug('RENDER Columns', shortcuts);
|
||||
|
||||
const components = shortcuts.map((shortcut) => {
|
||||
if (!shortcut) return null;
|
||||
const { type, ...params } = shortcut;
|
||||
|
@ -31,9 +34,16 @@ function Columns() {
|
|||
hashtag: Hashtag,
|
||||
mentions: Mentions,
|
||||
trending: Trending,
|
||||
search: Search,
|
||||
}[type];
|
||||
if (!Component) return null;
|
||||
return <Component {...params} />;
|
||||
// Don't show Search column with no query, for now
|
||||
if (type === 'search' && !params.query) return null;
|
||||
// Don't show List column with no list, for now
|
||||
if (type === 'list' && !params.id) return null;
|
||||
return (
|
||||
<Component key={type + JSON.stringify(params)} {...params} columnMode />
|
||||
);
|
||||
});
|
||||
|
||||
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
||||
|
@ -45,7 +55,24 @@ function Columns() {
|
|||
}
|
||||
});
|
||||
|
||||
return <div id="columns">{components}</div>;
|
||||
return (
|
||||
<div
|
||||
id="columns"
|
||||
onContextMenu={(e) => {
|
||||
// If right-click on header, but not links or buttons
|
||||
if (
|
||||
e.target.closest('.deck > header') &&
|
||||
!e.target.closest('a') &&
|
||||
!e.target.closest('button')
|
||||
) {
|
||||
e.preventDefault();
|
||||
states.showShortcutsSettings = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{components}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Columns;
|
||||
|
|
51
src/components/compose-button.jsx
Normal file
51
src/components/compose-button.jsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import openCompose from '../utils/open-compose';
|
||||
import openOSK from '../utils/open-osk';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
export default function ComposeButton() {
|
||||
const snapStates = useSnapshot(states);
|
||||
|
||||
function handleButton(e) {
|
||||
if (snapStates.composerState.minimized) {
|
||||
states.composerState.minimized = false;
|
||||
openOSK();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const newWin = openCompose();
|
||||
|
||||
if (!newWin) {
|
||||
states.showCompose = true;
|
||||
}
|
||||
} else {
|
||||
openOSK();
|
||||
states.showCompose = true;
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys('c, shift+c', handleButton, {
|
||||
ignoreEventWhen: (e) => {
|
||||
const hasModal = !!document.querySelector('#modal-container > *');
|
||||
return hasModal;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id="compose-button"
|
||||
onClick={handleButton}
|
||||
class={`${snapStates.composerState.minimized ? 'min' : ''} ${
|
||||
snapStates.composerState.publishing ? 'loading' : ''
|
||||
} ${snapStates.composerState.publishingError ? 'error' : ''}`}
|
||||
>
|
||||
<Icon icon="quill" size="xl" alt="Compose" />
|
||||
</button>
|
||||
);
|
||||
}
|
48
src/components/compose-suspense.jsx
Normal file
48
src/components/compose-suspense.jsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Loader from './loader';
|
||||
|
||||
const supportsIntlSegmenter = !shouldPolyfill();
|
||||
|
||||
function importIntlSegmenter() {
|
||||
if (!supportsIntlSegmenter) {
|
||||
return import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function importCompose() {
|
||||
return import('./compose');
|
||||
}
|
||||
|
||||
export async function preload() {
|
||||
try {
|
||||
await importIntlSegmenter();
|
||||
importCompose();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ComposeSuspense(props) {
|
||||
const [Compose, setCompose] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (supportsIntlSegmenter) {
|
||||
const component = await importCompose();
|
||||
setCompose(component);
|
||||
} else {
|
||||
await importIntlSegmenter();
|
||||
const component = await importCompose();
|
||||
setCompose(component);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return Compose?.default ? <Compose.default {...props} /> : <Loader />;
|
||||
}
|
|
@ -16,7 +16,6 @@
|
|||
}
|
||||
|
||||
#compose-container .compose-top {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
@ -25,28 +24,19 @@
|
|||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#compose-container textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 5em;
|
||||
min-height: 5em;
|
||||
max-height: 50vh;
|
||||
resize: vertical;
|
||||
line-height: 1.4;
|
||||
border-color: transparent;
|
||||
}
|
||||
#compose-container textarea:hover {
|
||||
border-color: var(--divider-color);
|
||||
}
|
||||
|
||||
@media (min-width: 40em) {
|
||||
#compose-container textarea {
|
||||
font-size: 150%;
|
||||
font-size: calc(100% + 50% / var(--text-weight));
|
||||
max-height: 65vh;
|
||||
}
|
||||
#compose-container .compose-top .account-block {
|
||||
text-align: start;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
color: var(--text-insignificant-color);
|
||||
line-height: 1.1;
|
||||
font-size: 90%;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
backdrop-filter: blur(16px);
|
||||
padding-inline-end: 1em;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
@keyframes appear-up {
|
||||
|
@ -71,7 +61,7 @@
|
|||
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
|
||||
}
|
||||
#compose-container .status-preview:has(.status-badge:not(:empty)) {
|
||||
border-top-right-radius: 8px;
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
#compose-container .status-preview :is(.content-container, .time) {
|
||||
pointer-events: none;
|
||||
|
@ -104,6 +94,10 @@
|
|||
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||
0 1px 10px var(--bg-color);
|
||||
z-index: 2;
|
||||
|
||||
strong {
|
||||
color: var(--red-color);
|
||||
}
|
||||
}
|
||||
#_compose-container .status-preview-legend.reply-to {
|
||||
color: var(--reply-to-color);
|
||||
|
@ -116,19 +110,45 @@
|
|||
}
|
||||
|
||||
#compose-container form {
|
||||
border-radius: 16px;
|
||||
padding: 4px 12px;
|
||||
--form-padding-inline: 8px;
|
||||
--form-padding-block: 0;
|
||||
/* border-radius: 16px; */
|
||||
padding: var(--form-padding-block) var(--form-padding-inline);
|
||||
background-color: var(--bg-blur-color);
|
||||
/* background-image: linear-gradient(var(--bg-color) 85%, transparent); */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
--drop-shadow: 0 3px 6px -3px var(--drop-shadow-color);
|
||||
box-shadow: var(--drop-shadow);
|
||||
|
||||
@media (min-width: 40em) {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
#compose-container .status-preview ~ form {
|
||||
box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color);
|
||||
}
|
||||
|
||||
#compose-container textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 5em;
|
||||
min-height: 5em;
|
||||
max-height: 50vh;
|
||||
resize: vertical;
|
||||
line-height: 1.4;
|
||||
border-color: transparent;
|
||||
|
||||
&.compose-field {
|
||||
@media (min-width: 40em) {
|
||||
max-height: 65vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
#compose-container textarea:hover {
|
||||
border-color: var(--divider-color);
|
||||
}
|
||||
|
||||
#compose-container .toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -192,10 +212,11 @@
|
|||
padding: 0 0 0 8px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
line-height: 1em;
|
||||
}
|
||||
#compose-container .toolbar-button:not(.show-field) select {
|
||||
right: 0;
|
||||
left: auto !important;
|
||||
inset-inline-end: 0;
|
||||
inset-inline-start: auto !important;
|
||||
}
|
||||
#compose-container
|
||||
.toolbar-button:not(:disabled):is(
|
||||
|
@ -256,6 +277,15 @@
|
|||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 90%;
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 80%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
#compose-container .text-expander-menu li b img {
|
||||
/* The shortcode emojis */
|
||||
|
@ -267,19 +297,28 @@
|
|||
height: 2.2em;
|
||||
}
|
||||
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
|
||||
color: var(--bg-color);
|
||||
background-color: var(--link-color);
|
||||
}
|
||||
#compose-container
|
||||
.text-expander-menu:hover
|
||||
li[aria-selected]:not(:hover, :focus) {
|
||||
background-color: var(--link-bg-color);
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
#compose-container .text-expander-menu li[aria-selected] {
|
||||
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
|
||||
:dir(rtl) & {
|
||||
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
|
||||
}
|
||||
}
|
||||
#compose-container .text-expander-menu li[data-more] {
|
||||
&:not(:hover, :focus, [aria-selected]) {
|
||||
color: var(--text-insignificant-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
|
||||
font-size: 0.8em;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#compose-container .form-visibility-direct {
|
||||
--yellow-stripes: repeating-linear-gradient(
|
||||
-45deg,
|
||||
135deg,
|
||||
var(--reply-to-faded-color),
|
||||
var(--reply-to-faded-color) 10px,
|
||||
var(--reply-to-faded-color) 10px,
|
||||
|
@ -303,6 +342,21 @@
|
|||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.media-error {
|
||||
padding: 2px;
|
||||
color: var(--orange-fg-color);
|
||||
background-color: transparent;
|
||||
border: 1.5px dashed transparent;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--orange-fg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
#compose-container .media-preview {
|
||||
flex-shrink: 0;
|
||||
|
@ -326,7 +380,7 @@
|
|||
#compose-container .media-preview > * {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
object-fit: scale-down;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
@ -442,14 +496,14 @@
|
|||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
border-left: 1px solid var(--outline-color);
|
||||
padding-left: 8px;
|
||||
border-inline-start: 1px solid var(--outline-color);
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
|
||||
#compose-container .expires-in {
|
||||
flex-grow: 1;
|
||||
border-left: 1px solid var(--outline-color);
|
||||
padding-left: 8px;
|
||||
border-inline-start: 1px solid var(--outline-color);
|
||||
padding-inline-start: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
|
@ -469,12 +523,51 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
#compose-container button[type='submit'] {
|
||||
#compose-container button[type='submit'] {
|
||||
border-radius: 8px;
|
||||
@media (min-width: 480px) {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
40% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#media-sheet {
|
||||
.media-form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 50vh;
|
||||
|
||||
textarea {
|
||||
flex-grow: 1;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
/* height: 10em; */
|
||||
|
||||
&.loading {
|
||||
animation: skeleton-breathe 1.5s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
#media-sheet main {
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
|
@ -482,10 +575,6 @@
|
|||
flex: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
#media-sheet textarea {
|
||||
width: 100%;
|
||||
height: 10em;
|
||||
}
|
||||
#media-sheet .media-preview {
|
||||
border: 2px solid var(--outline-color);
|
||||
border-radius: 8px;
|
||||
|
@ -502,12 +591,13 @@
|
|||
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
flex: 0.8;
|
||||
}
|
||||
#media-sheet .media-preview > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 50vh;
|
||||
object-fit: contain;
|
||||
object-fit: scale-down;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
@ -521,49 +611,455 @@
|
|||
#media-sheet .media-preview > * {
|
||||
max-height: none;
|
||||
}
|
||||
#media-sheet textarea {
|
||||
/* #media-sheet textarea {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
} */
|
||||
}
|
||||
|
||||
#mention-sheet {
|
||||
height: 50vh;
|
||||
|
||||
.accounts-list {
|
||||
--list-gap: 1px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: var(--list-gap);
|
||||
|
||||
&.loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
/* align-items: center; */
|
||||
margin: 0 -8px;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
border-radius: 8px;
|
||||
/* align-items: center; */
|
||||
|
||||
&:hover {
|
||||
background-image: linear-gradient(
|
||||
var(--to-forward),
|
||||
transparent 75%,
|
||||
var(--link-bg-color)
|
||||
);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-image: linear-gradient(
|
||||
var(--to-forward),
|
||||
var(--bg-faded-color) 75%,
|
||||
var(--link-bg-color)
|
||||
);
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
border-top: var(--hairline-width) solid var(--divider-color);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline-start: 58px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
&:has(+ li:is(.selected, :hover)):before,
|
||||
&:is(.selected, :hover):before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> button {
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
outline: 2px solid var(--button-bg-blur-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#custom-emojis-sheet {
|
||||
max-height: 50vh;
|
||||
max-height: 50dvh;
|
||||
|
||||
header {
|
||||
.loader-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 8px 0 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
mask-image: none;
|
||||
min-height: 40vh;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.custom-emojis-matches {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.custom-emojis-list {
|
||||
.section-header {
|
||||
font-size: 80%;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-insignificant-color);
|
||||
padding: 8px 0 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-color);
|
||||
z-index: 1;
|
||||
}
|
||||
section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
color: var(--text-color);
|
||||
border-radius: 8px;
|
||||
background-image: radial-gradient(
|
||||
closest-side,
|
||||
var(--img-bg-color),
|
||||
transparent
|
||||
);
|
||||
text-shadow: 0 1px 0 var(--bg-color);
|
||||
position: relative;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-feature-settings: 'ss01';
|
||||
|
||||
&[data-title]:after {
|
||||
max-width: 50vw;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
content: attr(data-title);
|
||||
left: 50%;
|
||||
top: 0;
|
||||
background-color: var(--bg-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--text-color);
|
||||
transform: translate(-50%, -110%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease-out 0.1s;
|
||||
font-family: var(--monospace-font);
|
||||
line-height: 1;
|
||||
}
|
||||
&.edge-left[data-title]:after {
|
||||
left: 0;
|
||||
transform: translate(0, -110%);
|
||||
}
|
||||
&.edge-right[data-title]:after {
|
||||
left: 100%;
|
||||
transform: translate(-100%, -110%);
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
z-index: 1;
|
||||
filter: none;
|
||||
background-color: var(--bg-faded-color);
|
||||
|
||||
&[data-title]:after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) img {
|
||||
transform: scale(2);
|
||||
}
|
||||
&.edge-left img {
|
||||
transform-origin: left center;
|
||||
}
|
||||
&.edge-right img {
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#custom-emojis-sheet main {
|
||||
mask-image: none;
|
||||
|
||||
.compose-field-container {
|
||||
display: grid !important;
|
||||
|
||||
@media (width < 30em) {
|
||||
margin-inline: calc(-1 * var(--form-padding-inline));
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
|
||||
.compose-field {
|
||||
border-radius: 0;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.debug {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
> .compose-field,
|
||||
> .compose-highlight {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
|
||||
.compose-highlight {
|
||||
user-drag: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
padding: 8px;
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
border: 2px solid transparent;
|
||||
line-height: 1.4;
|
||||
overflow: auto;
|
||||
unicode-bidi: plaintext;
|
||||
-webkit-rtl-ordering: logical;
|
||||
rtl-ordering: logical;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
min-height: 5em;
|
||||
max-height: 50vh;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Follow textarea styles */
|
||||
@media (min-width: 40em) {
|
||||
max-height: 65vh;
|
||||
}
|
||||
|
||||
mark {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.compose-highlight-url,
|
||||
.compose-highlight-hashtag {
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--link-faded-color);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.compose-highlight-mention,
|
||||
.compose-highlight-emoji-shortcode,
|
||||
.compose-highlight-exceeded {
|
||||
mix-blend-mode: multiply;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px;
|
||||
}
|
||||
.compose-highlight-mention {
|
||||
background-color: var(--orange-light-bg-color);
|
||||
box-shadow-color: var(--orange-light-bg-color);
|
||||
}
|
||||
.compose-highlight-emoji-shortcode {
|
||||
background-color: var(--bg-faded-color);
|
||||
box-shadow-color: var(--bg-faded-color);
|
||||
}
|
||||
.compose-highlight-exceeded {
|
||||
background-color: var(--red-bg-color);
|
||||
box-shadow-color: var(--red-bg-color);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.compose-highlight-mention,
|
||||
.compose-highlight-emoji-shortcode,
|
||||
.compose-highlight-exceeded {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list .section-header {
|
||||
font-size: 80%;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-insignificant-color);
|
||||
padding: 8px 0 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(1px);
|
||||
|
||||
@keyframes gif-shake {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.gif-picker-button {
|
||||
span {
|
||||
font-weight: bold;
|
||||
font-size: 11.5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
span {
|
||||
animation: gif-shake 0.3s 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button {
|
||||
border-radius: 8px;
|
||||
background-image: radial-gradient(
|
||||
closest-side,
|
||||
var(--img-bg-color),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
|
||||
filter: none;
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button img {
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
|
||||
transform: scale(1.5);
|
||||
|
||||
#gif-picker-sheet {
|
||||
height: 50vh;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
input[type='search'] {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
mask-image: linear-gradient(
|
||||
var(--to-forward),
|
||||
transparent 2px,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
transparent calc(100% - 2px)
|
||||
);
|
||||
|
||||
@media (min-height: 480px) {
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ui-state {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
ul {
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 8px 2px;
|
||||
margin: 0;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-faded-color);
|
||||
|
||||
@media (min-height: 480px) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
background-color: var(--link-bg-color);
|
||||
box-shadow: 0 0 0 2px var(--link-light-color);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: var(--figure-width);
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
font-size: 0.8em;
|
||||
padding: 2px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
background-color: var(--img-bg-color);
|
||||
border-radius: 4px;
|
||||
vertical-align: top;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
19
src/components/custom-emoji.jsx
Normal file
19
src/components/custom-emoji.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
export default function CustomEmoji({ staticUrl, alt, url }) {
|
||||
return (
|
||||
<picture>
|
||||
{staticUrl && (
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
)}
|
||||
<img
|
||||
key={alt || url}
|
||||
src={url}
|
||||
alt={alt}
|
||||
class="shortcode-emoji emoji"
|
||||
width="16"
|
||||
height="16"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
|
@ -27,7 +27,7 @@ button.draft-item {
|
|||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--link-faded-color);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
padding: 0;
|
||||
}
|
||||
button.draft-item:is(:hover, :focus) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { getCurrentAccountNS } from '../utils/store-utils';
|
|||
|
||||
import Icon from './icon';
|
||||
import Loader from './loader';
|
||||
import MenuConfirm from './menu-confirm';
|
||||
|
||||
function Drafts({ onClose }) {
|
||||
const { masto } = api();
|
||||
|
@ -89,26 +90,33 @@ function Drafts({ onClose }) {
|
|||
{niceDateTime(updatedAtDate)}
|
||||
</time>
|
||||
</b>
|
||||
<button
|
||||
type="button"
|
||||
class="small light"
|
||||
<MenuConfirm
|
||||
confirmLabel={<span>Delete this draft?</span>}
|
||||
menuItemClassName="danger"
|
||||
align="end"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
(async () => {
|
||||
try {
|
||||
const yes = confirm('Delete this draft?');
|
||||
if (yes) {
|
||||
await db.drafts.del(key);
|
||||
reload();
|
||||
}
|
||||
// const yes = confirm('Delete this draft?');
|
||||
// if (yes) {
|
||||
await db.drafts.del(key);
|
||||
reload();
|
||||
// }
|
||||
} catch (e) {
|
||||
alert('Error deleting draft! Please try again.');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="small light"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -120,9 +128,9 @@ function Drafts({ onClose }) {
|
|||
if (replyTo) {
|
||||
setUIState('loading');
|
||||
try {
|
||||
replyToStatus = await masto.v1.statuses.fetch(
|
||||
replyTo.id,
|
||||
);
|
||||
replyToStatus = await masto.v1.statuses
|
||||
.$select(replyTo.id)
|
||||
.fetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error fetching reply-to status!');
|
||||
|
@ -145,15 +153,16 @@ function Drafts({ onClose }) {
|
|||
);
|
||||
})}
|
||||
</ul>
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
class="light danger"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
(async () => {
|
||||
const yes = confirm('Delete all drafts?');
|
||||
if (yes) {
|
||||
{drafts.length > 1 && (
|
||||
<p>
|
||||
<MenuConfirm
|
||||
confirmLabel={<span>Delete all drafts?</span>}
|
||||
menuItemClassName="danger"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
(async () => {
|
||||
// const yes = confirm('Delete all drafts?');
|
||||
// if (yes) {
|
||||
setUIState('loading');
|
||||
try {
|
||||
await db.drafts.delMany(
|
||||
|
@ -166,13 +175,20 @@ function Drafts({ onClose }) {
|
|||
alert('Error deleting drafts! Please try again.');
|
||||
setUIState('error');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Delete all drafts…
|
||||
</button>
|
||||
</p>
|
||||
// }
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="light danger"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete all…
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>No drafts found.</p>
|
||||
|
|
31
src/components/embed-modal.css
Normal file
31
src/components/embed-modal.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.embed-modal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: none;
|
||||
|
||||
.top-controls {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.embed-content {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
iframe {
|
||||
pointer-events: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: max(var(--width), 480px);
|
||||
height: auto;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
}
|
||||
}
|
||||
}
|
36
src/components/embed-modal.jsx
Normal file
36
src/components/embed-modal.jsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import './embed-modal.css';
|
||||
|
||||
import Icon from './icon';
|
||||
|
||||
function EmbedModal({ html, url, width, height, onClose = () => {} }) {
|
||||
return (
|
||||
<div class="embed-modal-container">
|
||||
<div class="top-controls">
|
||||
<button type="button" class="light" onClick={() => onClose()}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="button plain"
|
||||
>
|
||||
<span>Open link</span> <Icon icon="external" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="embed-content"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{
|
||||
'--width': width + 'px',
|
||||
'--height': height + 'px',
|
||||
'--aspect-ratio': `${width}/${height}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmbedModal;
|
|
@ -1,42 +1,29 @@
|
|||
import { memo } from 'preact/compat';
|
||||
|
||||
import CustomEmoji from './custom-emoji';
|
||||
|
||||
function EmojiText({ text, emojis }) {
|
||||
if (!text) return '';
|
||||
if (!emojis?.length) return text;
|
||||
if (text.indexOf(':') === -1) return text;
|
||||
|
||||
const components = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
emojis.forEach((shortcodeObj) => {
|
||||
const { shortcode, staticUrl, url } = shortcodeObj;
|
||||
const regex = new RegExp(`:${shortcode}:`, 'g');
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text))) {
|
||||
const beforeText = text.substring(lastIndex, match.index);
|
||||
if (beforeText) {
|
||||
components.push(beforeText);
|
||||
}
|
||||
components.push(
|
||||
<img
|
||||
src={url}
|
||||
alt={shortcode}
|
||||
class="shortcode-emoji emoji"
|
||||
width="12"
|
||||
height="12"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>,
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
const regex = new RegExp(
|
||||
`:(${emojis.map((e) => e.shortcode).join('|')}):`,
|
||||
'g',
|
||||
);
|
||||
const elements = text.split(regex).map((word) => {
|
||||
const emoji = emojis.find((e) => e.shortcode === word);
|
||||
if (emoji) {
|
||||
const { url, staticUrl } = emoji;
|
||||
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
|
||||
}
|
||||
return word;
|
||||
});
|
||||
|
||||
const afterText = text.substring(lastIndex);
|
||||
if (afterText) {
|
||||
components.push(afterText);
|
||||
}
|
||||
|
||||
return components;
|
||||
return elements;
|
||||
}
|
||||
|
||||
export default EmojiText;
|
||||
export default memo(
|
||||
EmojiText,
|
||||
(oldProps, newProps) =>
|
||||
oldProps.text === newProps.text &&
|
||||
oldProps.emojis?.length === newProps.emojis?.length,
|
||||
);
|
||||
|
|
|
@ -2,26 +2,39 @@ import { useState } from 'preact/hooks';
|
|||
|
||||
import { api } from '../utils/api';
|
||||
|
||||
import Icon from './icon';
|
||||
import Loader from './loader';
|
||||
|
||||
function FollowRequestButtons({ accountID, onChange }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [requestState, setRequestState] = useState(null); // accept, reject
|
||||
const [relationship, setRelationship] = useState(null);
|
||||
|
||||
const hasRelationship = relationship !== null;
|
||||
|
||||
return (
|
||||
<p class="follow-request-buttons">
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading'}
|
||||
disabled={uiState === 'loading' || hasRelationship}
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
setRequestState('accept');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.followRequests.authorize(accountID);
|
||||
const rel = await masto.v1.followRequests
|
||||
.$select(accountID)
|
||||
.authorize();
|
||||
if (!rel?.followedBy) {
|
||||
throw new Error('Follow request not accepted');
|
||||
}
|
||||
setRelationship(rel);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('default');
|
||||
}
|
||||
setUIState('default');
|
||||
})();
|
||||
}}
|
||||
>
|
||||
|
@ -29,13 +42,20 @@ function FollowRequestButtons({ accountID, onChange }) {
|
|||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading'}
|
||||
disabled={uiState === 'loading' || hasRelationship}
|
||||
class="light danger"
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
setRequestState('reject');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.followRequests.reject(accountID);
|
||||
const rel = await masto.v1.followRequests
|
||||
.$select(accountID)
|
||||
.reject();
|
||||
if (rel?.followedBy) {
|
||||
throw new Error('Follow request not rejected');
|
||||
}
|
||||
setRelationship(rel);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
@ -46,7 +66,17 @@ function FollowRequestButtons({ accountID, onChange }) {
|
|||
>
|
||||
Reject
|
||||
</button>
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<span class="follow-request-states">
|
||||
{hasRelationship && requestState ? (
|
||||
requestState === 'accept' ? (
|
||||
<Icon icon="check-circle" alt="Accepted" class="follow-accepted" />
|
||||
) : (
|
||||
<Icon icon="x-circle" alt="Rejected" class="follow-rejected" />
|
||||
)
|
||||
) : (
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
111
src/components/generic-accounts.css
Normal file
111
src/components/generic-accounts.css
Normal file
|
@ -0,0 +1,111 @@
|
|||
#generic-accounts-container {
|
||||
.post-preview {
|
||||
--max-height: 120px;
|
||||
max-height: var(--max-height);
|
||||
overflow: hidden;
|
||||
margin-block: 8px;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
.status {
|
||||
font-size: calc(var(--text-size) * 0.9);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black calc(var(--max-height) / 2),
|
||||
transparent calc(var(--max-height) - 8px)
|
||||
);
|
||||
filter: saturate(0.5);
|
||||
}
|
||||
|
||||
&:is(a) {
|
||||
pointer-events: auto;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--outline-hover-color);
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accounts-list {
|
||||
--list-gap: 16px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
column-gap: 1.5em;
|
||||
row-gap: var(--list-gap);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-basis: 16em;
|
||||
/* align-items: center; */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 8px;
|
||||
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
border-top: var(--hairline-width) solid var(--divider-color);
|
||||
position: absolute;
|
||||
bottom: calc(-1 * var(--list-gap) / 2);
|
||||
inset-inline-start: 40px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
&:has(.reactions-block):before {
|
||||
/* avatar + reactions + gap */
|
||||
inset-inline-start: calc(40px + 16px + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
.account-block-acct {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-insignificant-color);
|
||||
/* display: block; */
|
||||
}
|
||||
}
|
||||
|
||||
.reactions-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* align-self: center; */
|
||||
|
||||
.favourite-icon {
|
||||
color: var(--favourite-color);
|
||||
}
|
||||
|
||||
.reblog-icon {
|
||||
color: var(--reblog-color);
|
||||
}
|
||||
|
||||
> .icon:only-child {
|
||||
margin-top: 8px; /* half of icon dimension */
|
||||
}
|
||||
}
|
||||
|
||||
.account-relationships {
|
||||
flex-grow: 1;
|
||||
|
||||
.tag {
|
||||
animation: appear 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.account-block {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
230
src/components/generic-accounts.jsx
Normal file
230
src/components/generic-accounts.jsx
Normal file
|
@ -0,0 +1,230 @@
|
|||
import './generic-accounts.css';
|
||||
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import { fetchRelationships } from '../utils/relationships';
|
||||
import states from '../utils/states';
|
||||
import useLocationChange from '../utils/useLocationChange';
|
||||
|
||||
import AccountBlock from './account-block';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Loader from './loader';
|
||||
import Status from './status';
|
||||
|
||||
export default function GenericAccounts({
|
||||
instance,
|
||||
excludeRelationshipAttrs = [],
|
||||
postID,
|
||||
onClose = () => {},
|
||||
blankCopy = 'Nothing to show',
|
||||
}) {
|
||||
const { masto, instance: currentInstance } = api();
|
||||
const isCurrentInstance = instance ? instance === currentInstance : true;
|
||||
const snapStates = useSnapshot(states);
|
||||
``;
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
useLocationChange(onClose);
|
||||
|
||||
if (!snapStates.showGenericAccounts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
heading,
|
||||
fetchAccounts,
|
||||
accounts: staticAccounts,
|
||||
showReactions,
|
||||
} = snapStates.showGenericAccounts;
|
||||
|
||||
const [relationshipsMap, setRelationshipsMap] = useState({});
|
||||
|
||||
const loadRelationships = async (accounts) => {
|
||||
if (!accounts?.length) return;
|
||||
if (!isCurrentInstance) return;
|
||||
const relationships = await fetchRelationships(accounts, relationshipsMap);
|
||||
if (relationships) {
|
||||
setRelationshipsMap({
|
||||
...relationshipsMap,
|
||||
...relationships,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadAccounts = (firstLoad) => {
|
||||
if (!fetchAccounts) return;
|
||||
if (firstLoad) setAccounts([]);
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const { done, value } = await fetchAccounts(firstLoad);
|
||||
if (Array.isArray(value)) {
|
||||
if (firstLoad) {
|
||||
const accounts = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const account = value[i];
|
||||
const theAccount = accounts.find(
|
||||
(a, j) => a.id === account.id && i !== j,
|
||||
);
|
||||
if (!theAccount) {
|
||||
accounts.push({
|
||||
_types: [],
|
||||
...account,
|
||||
});
|
||||
} else {
|
||||
theAccount._types.push(...account._types);
|
||||
}
|
||||
}
|
||||
setAccounts(accounts);
|
||||
} else {
|
||||
// setAccounts((prev) => [...prev, ...value]);
|
||||
// Merge accounts by id and _types
|
||||
setAccounts((prev) => {
|
||||
const newAccounts = prev;
|
||||
for (const account of value) {
|
||||
const theAccount = newAccounts.find((a) => a.id === account.id);
|
||||
if (!theAccount) {
|
||||
newAccounts.push(account);
|
||||
} else {
|
||||
theAccount._types.push(...account._types);
|
||||
}
|
||||
}
|
||||
return newAccounts;
|
||||
});
|
||||
}
|
||||
setShowMore(!done);
|
||||
|
||||
loadRelationships(value);
|
||||
} else {
|
||||
setShowMore(false);
|
||||
}
|
||||
setUIState('default');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const firstLoad = useRef(true);
|
||||
useEffect(() => {
|
||||
if (staticAccounts?.length > 0) {
|
||||
setAccounts(staticAccounts);
|
||||
loadRelationships(staticAccounts);
|
||||
} else {
|
||||
loadAccounts(true);
|
||||
firstLoad.current = false;
|
||||
}
|
||||
}, [staticAccounts, fetchAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstLoad.current) return;
|
||||
// reloadGenericAccounts contains value like {id: 'mute', counter: 1}
|
||||
// We only need to reload if the id matches
|
||||
if (snapStates.reloadGenericAccounts?.id === id) {
|
||||
loadAccounts(true);
|
||||
}
|
||||
}, [snapStates.reloadGenericAccounts.counter]);
|
||||
|
||||
const post = states.statuses[postID];
|
||||
|
||||
return (
|
||||
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<h2>{heading || 'Accounts'}</h2>
|
||||
</header>
|
||||
<main>
|
||||
{post && (
|
||||
<Link
|
||||
to={`/${instance || currentInstance}/s/${post.id}`}
|
||||
class="post-preview"
|
||||
>
|
||||
<Status status={post} size="s" readOnly />
|
||||
</Link>
|
||||
)}
|
||||
{accounts.length > 0 ? (
|
||||
<>
|
||||
<ul class="accounts-list">
|
||||
{accounts.map((account) => {
|
||||
const relationship = relationshipsMap[account.id];
|
||||
const key = `${account.id}-${account._types?.length || ''}`;
|
||||
return (
|
||||
<li key={key}>
|
||||
{showReactions && account._types?.length > 0 && (
|
||||
<div class="reactions-block">
|
||||
{account._types.map((type) => (
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
reblog: 'rocket',
|
||||
favourite: 'heart',
|
||||
}[type]
|
||||
}
|
||||
class={`${type}-icon`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div class="account-relationships">
|
||||
<AccountBlock
|
||||
account={account}
|
||||
showStats
|
||||
relationship={relationship}
|
||||
excludeRelationshipAttrs={excludeRelationshipAttrs}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{uiState === 'default' ? (
|
||||
showMore ? (
|
||||
<InView
|
||||
onChange={(inView) => {
|
||||
if (inView) {
|
||||
loadAccounts();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="plain block"
|
||||
onClick={() => loadAccounts()}
|
||||
>
|
||||
Show more…
|
||||
</button>
|
||||
</InView>
|
||||
) : (
|
||||
<p class="ui-state insignificant">The end.</p>
|
||||
)
|
||||
) : (
|
||||
uiState === 'loading' && (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : uiState === 'loading' ? (
|
||||
<p class="ui-state">
|
||||
<Loader abrupt />
|
||||
</p>
|
||||
) : uiState === 'error' ? (
|
||||
<p class="ui-state">Error loading accounts</p>
|
||||
) : (
|
||||
<p class="ui-state insignificant">{blankCopy}</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
import { useEffect, useState } from 'preact/hooks';
|
||||
import moize from 'moize';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { ICONS } from './ICONS';
|
||||
|
||||
const SIZES = {
|
||||
s: 12,
|
||||
|
@ -8,81 +11,30 @@ const SIZES = {
|
|||
xxl: 32,
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
x: 'mingcute:close-line',
|
||||
heart: 'mingcute:heart-line',
|
||||
bookmark: 'mingcute:bookmark-line',
|
||||
'check-circle': 'mingcute:check-circle-line',
|
||||
transfer: 'mingcute:transfer-4-line',
|
||||
rocket: 'mingcute:rocket-line',
|
||||
'arrow-left': 'mingcute:arrow-left-line',
|
||||
'arrow-right': 'mingcute:arrow-right-line',
|
||||
'arrow-up': 'mingcute:arrow-up-line',
|
||||
'arrow-down': 'mingcute:arrow-down-line',
|
||||
earth: 'mingcute:earth-line',
|
||||
lock: 'mingcute:lock-line',
|
||||
unlock: 'mingcute:unlock-line',
|
||||
'eye-close': 'mingcute:eye-close-line',
|
||||
'eye-open': 'mingcute:eye-2-line',
|
||||
message: 'mingcute:mail-line',
|
||||
comment: 'mingcute:chat-3-line',
|
||||
home: 'mingcute:home-3-line',
|
||||
notification: 'mingcute:notification-line',
|
||||
follow: 'mingcute:user-follow-line',
|
||||
'follow-add': 'mingcute:user-add-line',
|
||||
poll: ['mingcute:chart-bar-line', '90deg'],
|
||||
pencil: 'mingcute:pencil-line',
|
||||
quill: 'mingcute:quill-pen-line',
|
||||
at: 'mingcute:at-line',
|
||||
attachment: 'mingcute:attachment-line',
|
||||
upload: 'mingcute:upload-3-line',
|
||||
gear: 'mingcute:settings-3-line',
|
||||
more: 'mingcute:more-3-line',
|
||||
external: 'mingcute:external-link-line',
|
||||
popout: 'mingcute:external-link-line',
|
||||
popin: ['mingcute:external-link-line', '180deg'],
|
||||
plus: 'mingcute:add-circle-line',
|
||||
'chevron-left': 'mingcute:left-line',
|
||||
'chevron-right': 'mingcute:right-line',
|
||||
reply: ['mingcute:share-forward-line', '180deg', 'horizontal'],
|
||||
thread: 'mingcute:route-line',
|
||||
group: 'mingcute:group-line',
|
||||
bot: 'mingcute:android-2-line',
|
||||
menu: 'mingcute:rows-4-line',
|
||||
list: 'mingcute:list-check-line',
|
||||
search: 'mingcute:search-2-line',
|
||||
hashtag: 'mingcute:hashtag-line',
|
||||
info: 'mingcute:information-line',
|
||||
shortcut: 'mingcute:lightning-line',
|
||||
user: 'mingcute:user-4-line',
|
||||
following: 'mingcute:walk-line',
|
||||
pin: 'mingcute:pin-line',
|
||||
bus: 'mingcute:bus-2-line',
|
||||
link: 'mingcute:link-2-line',
|
||||
history: 'mingcute:history-line',
|
||||
share: 'mingcute:share-2-line',
|
||||
sparkles: 'mingcute:sparkles-line',
|
||||
exit: 'mingcute:exit-line',
|
||||
translate: 'mingcute:translate-line',
|
||||
play: 'mingcute:play-fill',
|
||||
trash: 'mingcute:delete-2-line',
|
||||
mute: 'mingcute:volume-mute-line',
|
||||
unmute: 'mingcute:volume-line',
|
||||
block: 'mingcute:forbid-circle-line',
|
||||
unblock: ['mingcute:forbid-circle-line', '180deg'],
|
||||
flag: 'mingcute:flag-4-line',
|
||||
time: 'mingcute:time-line',
|
||||
refresh: 'mingcute:refresh-2-line',
|
||||
emoji2: 'mingcute:emoji-2-line',
|
||||
filter: 'mingcute:filter-2-line',
|
||||
chart: 'mingcute:chart-line-line',
|
||||
react: 'mingcute:react-line',
|
||||
layout4: 'mingcute:layout-4-line',
|
||||
layout5: 'mingcute:layout-5-line',
|
||||
announce: 'mingcute:announcement-line',
|
||||
};
|
||||
const ICONDATA = {};
|
||||
|
||||
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
|
||||
// Memoize the dangerouslySetInnerHTML of the SVGs
|
||||
const SVGICon = moize(
|
||||
function ({ width, height, body, rotate, flip }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
style={{
|
||||
transform: `${rotate ? `rotate(${rotate})` : ''} ${
|
||||
flip ? `scaleX(-1)` : ''
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
{
|
||||
isShallowEqual: true,
|
||||
maxSize: Object.keys(ICONS).length,
|
||||
matchesArg: (cacheKeyArg, keyArg) =>
|
||||
cacheKeyArg.icon === keyArg.icon && cacheKeyArg.body === keyArg.body,
|
||||
},
|
||||
);
|
||||
|
||||
function Icon({
|
||||
icon,
|
||||
|
@ -95,48 +47,67 @@ function Icon({
|
|||
if (!icon) return null;
|
||||
|
||||
const iconSize = SIZES[size];
|
||||
let iconName = ICONS[icon];
|
||||
let rotate, flip;
|
||||
if (Array.isArray(iconName)) {
|
||||
[iconName, rotate, flip] = iconName;
|
||||
let iconBlock = ICONS[icon];
|
||||
if (!iconBlock) {
|
||||
console.warn(`Icon ${icon} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [iconData, setIconData] = useState(null);
|
||||
useEffect(async () => {
|
||||
const name = iconName.replace('mingcute:', '');
|
||||
const icon = await modules[
|
||||
`/node_modules/@iconify-icons/mingcute/${name}.js`
|
||||
]();
|
||||
setIconData(icon.default);
|
||||
}, [iconName]);
|
||||
let rotate,
|
||||
flip,
|
||||
rtl = false;
|
||||
if (Array.isArray(iconBlock)) {
|
||||
[iconBlock, rotate, flip] = iconBlock;
|
||||
} else if (typeof iconBlock === 'object') {
|
||||
({ rotate, flip, rtl } = iconBlock);
|
||||
iconBlock = iconBlock.module;
|
||||
}
|
||||
|
||||
const [iconData, setIconData] = useState(ICONDATA[icon]);
|
||||
const currentIcon = useRef(icon);
|
||||
useEffect(() => {
|
||||
if (iconData && currentIcon.current === icon) return;
|
||||
(async () => {
|
||||
const iconB = await iconBlock();
|
||||
setIconData(iconB.default);
|
||||
ICONDATA[icon] = iconB.default;
|
||||
})();
|
||||
currentIcon.current = icon;
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`icon ${className}`}
|
||||
<span
|
||||
class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`}
|
||||
title={title || alt}
|
||||
style={{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 0,
|
||||
...style,
|
||||
}}
|
||||
data-icon={icon}
|
||||
>
|
||||
{iconData && (
|
||||
<svg
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
viewBox={`0 0 ${iconData.width} ${iconData.height}`}
|
||||
dangerouslySetInnerHTML={{ __html: iconData.body }}
|
||||
style={{
|
||||
transform: `${rotate ? `rotate(${rotate})` : ''} ${
|
||||
flip ? `scaleX(-1)` : ''
|
||||
}`,
|
||||
}}
|
||||
// <svg
|
||||
// width={iconSize}
|
||||
// height={iconSize}
|
||||
// viewBox={`0 0 ${iconData.width} ${iconData.height}`}
|
||||
// dangerouslySetInnerHTML={{ __html: iconData.body }}
|
||||
// style={{
|
||||
// transform: `${rotate ? `rotate(${rotate})` : ''} ${
|
||||
// flip ? `scaleX(-1)` : ''
|
||||
// }`,
|
||||
// }}
|
||||
// />
|
||||
<SVGICon
|
||||
icon={icon}
|
||||
width={iconData.width}
|
||||
height={iconData.height}
|
||||
body={iconData.body}
|
||||
rotate={rotate}
|
||||
flip={flip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
29
src/components/intersection-view.jsx
Normal file
29
src/components/intersection-view.jsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const IntersectionView = ({ children, root = null, fallback = null }) => {
|
||||
const ref = useRef();
|
||||
const [show, setShow] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
setShow(true);
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: `${screen.height}px`,
|
||||
},
|
||||
);
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
return () => {
|
||||
if (ref.current) observer.unobserve(ref.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return show ? children : <div ref={ref}>{fallback}</div>;
|
||||
};
|
||||
|
||||
export default IntersectionView;
|
36
src/components/intl-segmenter-suspense.jsx
Normal file
36
src/components/intl-segmenter-suspense.jsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
|
||||
import { Suspense } from 'preact/compat';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Loader from './loader';
|
||||
|
||||
const supportsIntlSegmenter = !shouldPolyfill();
|
||||
|
||||
// Preload IntlSegmenter
|
||||
setTimeout(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!supportsIntlSegmenter) {
|
||||
import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
export default function IntlSegmenterSuspense({ children }) {
|
||||
if (supportsIntlSegmenter) {
|
||||
return <Suspense fallback={<Loader />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await import('@formatjs/intl-segmenter/polyfill-force');
|
||||
setPolyfillLoaded(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return polyfillLoaded ? (
|
||||
<Suspense fallback={<Loader />}>{children}</Suspense>
|
||||
) : (
|
||||
<Loader />
|
||||
);
|
||||
}
|
44
src/components/keyboard-shortcuts-help.css
Normal file
44
src/components/keyboard-shortcuts-help.css
Normal file
|
@ -0,0 +1,44 @@
|
|||
#keyboard-shortcuts-help-container {
|
||||
table {
|
||||
tr > * {
|
||||
border-top: 1px solid var(--outline-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
th {
|
||||
font-weight: normal;
|
||||
text-align: start;
|
||||
padding: 0.25em 0;
|
||||
line-height: 1;
|
||||
width: 60%;
|
||||
}
|
||||
td {
|
||||
padding: 0.25em 1em;
|
||||
}
|
||||
}
|
||||
|
||||
kbd {
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.3em;
|
||||
margin: 1px 0;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--outline-color);
|
||||
background-color: var(--bg-faded-color);
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
var(--bg-blur-color),
|
||||
transparent
|
||||
);
|
||||
text-shadow: 0 1px var(--bg-color);
|
||||
box-shadow: 0 1px var(--drop-shadow-color),
|
||||
0 1px 1px var(--drop-shadow-color), 0 1px 8px var(--drop-shadow-color),
|
||||
inset 0 1px var(--bg-blur-color);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 4px var(--drop-shadow-color),
|
||||
inset 0 1px var(--bg-blur-color);
|
||||
transform: translateY(1px);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
}
|
||||
}
|
191
src/components/keyboard-shortcuts-help.jsx
Normal file
191
src/components/keyboard-shortcuts-help.jsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
import './keyboard-shortcuts-help.css';
|
||||
|
||||
import { memo } from 'preact/compat';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Modal from './modal';
|
||||
|
||||
export default memo(function KeyboardShortcutsHelp() {
|
||||
const snapStates = useSnapshot(states);
|
||||
|
||||
function onClose() {
|
||||
states.showKeyboardShortcutsHelp = false;
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
'?, shift+?, shift+slash',
|
||||
(e) => {
|
||||
console.log('help');
|
||||
states.showKeyboardShortcutsHelp = true;
|
||||
},
|
||||
{
|
||||
ignoreEventWhen: (e) => {
|
||||
const hasModal = !!document.querySelector('#modal-container > *');
|
||||
return hasModal;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
!!snapStates.showKeyboardShortcutsHelp && (
|
||||
<Modal onClose={onClose}>
|
||||
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
{[
|
||||
{
|
||||
action: 'Keyboard shortcuts help',
|
||||
keys: <kbd>?</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Next post',
|
||||
keys: <kbd>j</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Previous post',
|
||||
keys: <kbd>k</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Skip carousel to next post',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>j</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Skip carousel to previous post',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>k</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Load new posts',
|
||||
keys: <kbd>.</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Open post details',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Enter</kbd> or <kbd>o</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: (
|
||||
<>
|
||||
Expand content warning or
|
||||
<br />
|
||||
toggle expanded/collapsed thread
|
||||
</>
|
||||
),
|
||||
keys: <kbd>x</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Close post or dialogs',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Esc</kbd> or <kbd>Backspace</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Focus column in multi-column mode',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>1</kbd> to <kbd>9</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Compose new post',
|
||||
keys: <kbd>c</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Compose new post (new window)',
|
||||
className: 'insignificant',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>c</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Send post',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Ctrl</kbd> + <kbd>Enter</kbd> or <kbd>⌘</kbd> +{' '}
|
||||
<kbd>Enter</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Search',
|
||||
keys: <kbd>/</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Reply',
|
||||
keys: <kbd>r</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Reply (new window)',
|
||||
className: 'insignificant',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>r</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Like (favourite)',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>l</kbd> or <kbd>f</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Boost',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>b</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
action: 'Bookmark',
|
||||
keys: <kbd>d</kbd>,
|
||||
},
|
||||
{
|
||||
action: 'Toggle Cloak mode',
|
||||
keys: (
|
||||
<>
|
||||
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
|
||||
</>
|
||||
),
|
||||
},
|
||||
].map(({ action, className, keys }) => (
|
||||
<tr key={action}>
|
||||
<th class={className}>{action}</th>
|
||||
<td>{keys}</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
});
|
59
src/components/lazy-shazam.jsx
Normal file
59
src/components/lazy-shazam.jsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
Rendered but hidden. Only show when visible
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
// The sticky header, usually at the top
|
||||
const TOP = 48;
|
||||
|
||||
const shazamIDs = {};
|
||||
|
||||
export default function LazyShazam({ id, children }) {
|
||||
const containerRef = useRef();
|
||||
const hasID = !!shazamIDs[id];
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [visibleStart, setVisibleStart] = useState(hasID || false);
|
||||
|
||||
const { ref } = useInView({
|
||||
root: null,
|
||||
rootMargin: `-${TOP}px 0px 0px 0px`,
|
||||
trackVisibility: true,
|
||||
delay: 1000,
|
||||
onChange: (inView) => {
|
||||
if (inView) {
|
||||
setVisible(true);
|
||||
if (id) shazamIDs[id] = true;
|
||||
}
|
||||
},
|
||||
triggerOnce: true,
|
||||
skip: visibleStart || visible,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.bottom > TOP) {
|
||||
if (rect.top < window.innerHeight) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisibleStart(true);
|
||||
}
|
||||
if (id) shazamIDs[id] = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (visibleStart) return children;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="shazam-container no-animation"
|
||||
hidden={!visible}
|
||||
>
|
||||
<div ref={ref} class="shazam-container-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -19,7 +19,19 @@ const Link = forwardRef((props, ref) => {
|
|||
let hash = (location.hash || '').replace(/^#/, '').trim();
|
||||
if (hash === '') hash = '/';
|
||||
const { to, ...restProps } = props;
|
||||
const isActive = decodeURIComponent(hash) === to;
|
||||
|
||||
// Handle encodeURIComponent of searchParams values
|
||||
if (!!hash && hash !== '/' && hash.includes('?')) {
|
||||
const parsedHash = URL.parse(hash, location.origin); // Fake base URL
|
||||
if (parsedHash?.searchParams?.size) {
|
||||
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
hash = parsedHash.pathname + '?' + searchParamsStr;
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = hash === to || decodeURIComponent(hash) === to;
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
|
@ -27,6 +39,10 @@ const Link = forwardRef((props, ref) => {
|
|||
{...restProps}
|
||||
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (e.currentTarget?.parentNode?.closest('a')) {
|
||||
// If this <a> is nested inside another <a>
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (routerLocation) states.prevLocation = routerLocation;
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
|
|
226
src/components/links-bar.css
Normal file
226
src/components/links-bar.css
Normal file
|
@ -0,0 +1,226 @@
|
|||
.links-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 16px 16px 20px 16px;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
background-color: var(--bg-faded-color);
|
||||
mask-image: linear-gradient(
|
||||
var(--to-forward),
|
||||
transparent,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
transparent
|
||||
);
|
||||
text-shadow: 0 1px var(--bg-blur-color);
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
#trending-page &:not(#columns &) {
|
||||
@media (min-width: 40em) {
|
||||
width: 95vw;
|
||||
max-width: calc(320px * 3.3);
|
||||
transform: translateX(calc(-50% + var(--main-width) / 2));
|
||||
&:dir(rtl) {
|
||||
transform: translateX(calc(50% - var(--main-width) / 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > header {
|
||||
width: 1.2em;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
h3 {
|
||||
font-size: 90%;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-insignificant-color);
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 0;
|
||||
transform-origin: top left;
|
||||
transform: rotate(-90deg) translateX(-100%);
|
||||
&:dir(rtl) {
|
||||
transform-origin: top right;
|
||||
transform: rotate(90deg) translateX(100%);
|
||||
}
|
||||
user-select: none;
|
||||
background-image: linear-gradient(
|
||||
var(--to-backward),
|
||||
var(--text-color),
|
||||
var(--link-color)
|
||||
);
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
min-width: 240px;
|
||||
flex-grow: 1;
|
||||
max-width: 320px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--accent-alpha-color);
|
||||
border: 4px solid transparent;
|
||||
box-shadow: 0 4px 8px -2px var(--drop-shadow-color);
|
||||
transition: all 0.15s ease-out;
|
||||
display: flex;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-color, var(--link-text-color)) -50%,
|
||||
transparent
|
||||
);
|
||||
background-clip: border-box;
|
||||
background-origin: border-box;
|
||||
min-height: 160px;
|
||||
height: 320px;
|
||||
max-height: 50vh;
|
||||
|
||||
&:not(:active):is(:hover, :focus-visible) {
|
||||
border-color: var(--accent-color, var(--link-light-color));
|
||||
box-shadow: 0 4px 8px var(--drop-shadow-color),
|
||||
0 8px 16px var(--drop-shadow-color);
|
||||
transform-origin: center bottom;
|
||||
transform: scale(1.02);
|
||||
|
||||
img {
|
||||
animation: position-object 5s ease-in-out 1s 5;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transition: none;
|
||||
transform: scale(1.015);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
figure {
|
||||
transition: 1s ease-out;
|
||||
transition-property: opacity, mix-blend-mode;
|
||||
}
|
||||
|
||||
&.inactive:not(:active, :hover) {
|
||||
figure {
|
||||
transition-duration: 0.3s;
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--accent-color, var(--link-light-color));
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
+ button[disabled] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
article {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
background-color: var(--bg-color);
|
||||
background-repeat: no-repeat;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--accent-alpha-color) 70%,
|
||||
var(--bg-color) 100%
|
||||
);
|
||||
transition: background-position-y 0.15s ease-out;
|
||||
|
||||
figure {
|
||||
flex-grow: 1;
|
||||
margin: 0 0 -16px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
vertical-align: top;
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
hsl(0, 0%, 0%) 0%,
|
||||
hsla(0, 0%, 0%, 0.987) 14%,
|
||||
hsla(0, 0%, 0%, 0.951) 26.2%,
|
||||
hsla(0, 0%, 0%, 0.896) 36.8%,
|
||||
hsla(0, 0%, 0%, 0.825) 45.9%,
|
||||
hsla(0, 0%, 0%, 0.741) 53.7%,
|
||||
hsla(0, 0%, 0%, 0.648) 60.4%,
|
||||
hsla(0, 0%, 0%, 0.55) 66.2%,
|
||||
hsla(0, 0%, 0%, 0.45) 71.2%,
|
||||
hsla(0, 0%, 0%, 0.352) 75.6%,
|
||||
hsla(0, 0%, 0%, 0.259) 79.6%,
|
||||
hsla(0, 0%, 0%, 0.175) 83.4%,
|
||||
hsla(0, 0%, 0%, 0.104) 87.2%,
|
||||
hsla(0, 0%, 0%, 0.049) 91.1%,
|
||||
hsla(0, 0%, 0%, 0.013) 95.3%,
|
||||
hsla(0, 0%, 0%, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) article {
|
||||
background-position-y: -40px;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
padding: 0 8px 8px;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
color: var(--text-insignificant-color);
|
||||
font-size: 90%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover .domain {
|
||||
color: var(--link-text-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: normal;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-wrap: balance;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-insignificant-color);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,21 +1,30 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
|
||||
import supports from '../utils/supports';
|
||||
|
||||
import Icon from './icon';
|
||||
import MenuConfirm from './menu-confirm';
|
||||
|
||||
function ListAddEdit({ list, onClose }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUiState] = useState('default');
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const editMode = !!list;
|
||||
const nameFieldRef = useRef();
|
||||
const repliesPolicyFieldRef = useRef();
|
||||
const exclusiveFieldRef = useRef();
|
||||
useEffect(() => {
|
||||
if (editMode) {
|
||||
nameFieldRef.current.value = list.title;
|
||||
repliesPolicyFieldRef.current.value = list.repliesPolicy;
|
||||
if (exclusiveFieldRef.current) {
|
||||
exclusiveFieldRef.current.checked = list.exclusive;
|
||||
}
|
||||
}
|
||||
}, [editMode]);
|
||||
const supportsExclusive = supports('@mastodon/list-exclusive');
|
||||
|
||||
return (
|
||||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
|
@ -35,37 +44,49 @@ function ListAddEdit({ list, onClose }) {
|
|||
const formData = new FormData(e.target);
|
||||
const title = formData.get('title');
|
||||
const repliesPolicy = formData.get('replies_policy');
|
||||
const exclusive = formData.get('exclusive') === 'on';
|
||||
console.log({
|
||||
title,
|
||||
repliesPolicy,
|
||||
exclusive,
|
||||
});
|
||||
setUiState('loading');
|
||||
setUIState('loading');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let listResult;
|
||||
|
||||
if (editMode) {
|
||||
listResult = await masto.v1.lists.update(list.id, {
|
||||
listResult = await masto.v1.lists.$select(list.id).update({
|
||||
title,
|
||||
replies_policy: repliesPolicy,
|
||||
exclusive,
|
||||
});
|
||||
} else {
|
||||
listResult = await masto.v1.lists.create({
|
||||
title,
|
||||
replies_policy: repliesPolicy,
|
||||
exclusive,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(listResult);
|
||||
setUiState('default');
|
||||
setUIState('default');
|
||||
onClose?.({
|
||||
state: 'success',
|
||||
list: listResult,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (editMode) {
|
||||
updateListStore(listResult);
|
||||
} else {
|
||||
addListStore(listResult);
|
||||
}
|
||||
}, 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUiState('error');
|
||||
setUIState('error');
|
||||
alert(
|
||||
editMode ? 'Unable to edit list.' : 'Unable to create list.',
|
||||
);
|
||||
|
@ -83,6 +104,7 @@ function ListAddEdit({ list, onClose }) {
|
|||
name="title"
|
||||
required
|
||||
disabled={uiState === 'loading'}
|
||||
dir="auto"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
@ -98,37 +120,60 @@ function ListAddEdit({ list, onClose }) {
|
|||
<option value="none">Don't show replies</option>
|
||||
</select>
|
||||
</div>
|
||||
{supportsExclusive && (
|
||||
<div class="list-form-row">
|
||||
<label class="label-block">
|
||||
<input
|
||||
ref={exclusiveFieldRef}
|
||||
type="checkbox"
|
||||
name="exclusive"
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
Hide posts on this list from Home/Following
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div class="list-form-footer">
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
{editMode ? 'Save' : 'Create'}
|
||||
</button>
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
class="light danger"
|
||||
<MenuConfirm
|
||||
disabled={uiState === 'loading'}
|
||||
align="end"
|
||||
menuItemClassName="danger"
|
||||
confirmLabel="Delete this list?"
|
||||
onClick={() => {
|
||||
const yes = confirm('Delete this list?');
|
||||
if (!yes) return;
|
||||
setUiState('loading');
|
||||
// const yes = confirm('Delete this list?');
|
||||
// if (!yes) return;
|
||||
setUIState('loading');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.lists.remove(list.id);
|
||||
setUiState('default');
|
||||
await masto.v1.lists.$select(list.id).remove();
|
||||
setUIState('default');
|
||||
onClose?.({
|
||||
state: 'deleted',
|
||||
});
|
||||
setTimeout(() => {
|
||||
deleteListStore(list.id);
|
||||
}, 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUiState('error');
|
||||
setUIState('error');
|
||||
alert('Unable to delete list.');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="light danger"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
</MenuConfirm>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import './loader.css';
|
||||
|
||||
function Loader({ abrupt, hidden }) {
|
||||
function Loader({ abrupt, hidden, ...props }) {
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
{...props}
|
||||
class={`loader-container ${abrupt ? 'abrupt' : ''} ${
|
||||
hidden ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div class="loader" />
|
||||
</div>
|
||||
<span class="loader" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
87
src/components/media-alt-modal.jsx
Normal file
87
src/components/media-alt-modal.jsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||
import localeMatch from '../utils/locale-match';
|
||||
import { speak, supportsTTS } from '../utils/speech';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Menu2 from './menu2';
|
||||
import TranslationBlock from './translation-block';
|
||||
|
||||
export default function MediaAltModal({ alt, lang, onClose }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [forceTranslate, setForceTranslate] = useState(false);
|
||||
const targetLanguage = getTranslateTargetLanguage(true);
|
||||
const contentTranslationHideLanguages =
|
||||
snapStates.settings.contentTranslationHideLanguages || [];
|
||||
const differentLanguage =
|
||||
!!lang &&
|
||||
lang !== targetLanguage &&
|
||||
!localeMatch([lang], [targetLanguage]) &&
|
||||
!contentTranslationHideLanguages.find(
|
||||
(l) => lang === l || localeMatch([lang], [l]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="sheet" tabindex="-1">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header class="header-grid">
|
||||
<h2>Media description</h2>
|
||||
<div class="header-side">
|
||||
<Menu2
|
||||
align="end"
|
||||
menuButton={
|
||||
<button type="button" class="plain4">
|
||||
<Icon icon="more" alt="More" size="xl" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
disabled={forceTranslate}
|
||||
onClick={() => {
|
||||
setForceTranslate(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuItem>
|
||||
{supportsTTS && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
speak(alt, lang);
|
||||
}}
|
||||
>
|
||||
<Icon icon="speak" />
|
||||
<span>Speak</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu2>
|
||||
</div>
|
||||
</header>
|
||||
<main lang={lang} dir="auto">
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{alt}
|
||||
</p>
|
||||
{(differentLanguage || forceTranslate) && (
|
||||
<TranslationBlock
|
||||
forceTranslate={forceTranslate}
|
||||
sourceLanguage={lang}
|
||||
text={alt}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,22 +1,36 @@
|
|||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
|
||||
import isRTL from '../utils/is-rtl';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Media from './media';
|
||||
import MenuLink from './menu-link';
|
||||
import Modal from './modal';
|
||||
import TranslationBlock from './translation-block';
|
||||
import Menu2 from './menu2';
|
||||
|
||||
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
||||
|
||||
function MediaModal({
|
||||
mediaAttachments,
|
||||
statusID,
|
||||
instance,
|
||||
lang,
|
||||
index = 0,
|
||||
onClose = () => {},
|
||||
}) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const carouselRef = useRef(null);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(index);
|
||||
|
@ -41,9 +55,10 @@ function MediaModal({
|
|||
const differentStatusID = prevStatusID.current !== statusID;
|
||||
if (differentStatusID) prevStatusID.current = statusID;
|
||||
carouselRef.current.scrollTo({
|
||||
left: scrollLeft,
|
||||
left: scrollLeft * (isRTL() ? -1 : 1),
|
||||
behavior: differentStatusID ? 'auto' : 'smooth',
|
||||
});
|
||||
carouselRef.current.focus();
|
||||
}, [index, statusID]);
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
|
@ -62,14 +77,22 @@ function MediaModal({
|
|||
};
|
||||
}, []);
|
||||
|
||||
useHotkeys('esc', onClose, [onClose]);
|
||||
|
||||
const [showMediaAlt, setShowMediaAlt] = useState(false);
|
||||
useHotkeys(
|
||||
'esc',
|
||||
onClose,
|
||||
{
|
||||
ignoreEventWhen: (e) => {
|
||||
const hasModal = !!document.querySelector('#modal-container > *');
|
||||
return hasModal;
|
||||
},
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = () => {
|
||||
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||
const index = Math.round(scrollLeft / clientWidth);
|
||||
const index = Math.round(Math.abs(scrollLeft) / clientWidth);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
if (carouselRef.current) {
|
||||
|
@ -91,11 +114,55 @@ function MediaModal({
|
|||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const mediaAccentColors = useMemo(() => {
|
||||
return mediaAttachments?.map((media) => {
|
||||
const { blurhash } = media;
|
||||
if (blurhash) {
|
||||
const averageColor = getBlurHashAverageColor(blurhash);
|
||||
const labAverageColor = rgb2oklab(averageColor);
|
||||
return oklab2rgb([0.6, labAverageColor[1], labAverageColor[2]]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [mediaAttachments]);
|
||||
const mediaAccentGradient = useMemo(() => {
|
||||
const gap = 5;
|
||||
const range = 100 / mediaAccentColors.length;
|
||||
return (
|
||||
mediaAccentColors
|
||||
?.map((color, i) => {
|
||||
const start = i * range + gap;
|
||||
const end = (i + 1) * range - gap;
|
||||
if (color) {
|
||||
return `
|
||||
rgba(${color?.join(',')}, 0.4) ${start}%,
|
||||
rgba(${color?.join(',')}, 0.4) ${end}%
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
transparent ${start}%,
|
||||
transparent ${end}%
|
||||
`;
|
||||
})
|
||||
?.join(', ') || 'transparent'
|
||||
);
|
||||
}, [mediaAccentColors]);
|
||||
|
||||
let toastRef = useRef(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
toastRef.current?.hideToast?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div class="media-modal-container">
|
||||
<div
|
||||
class={`media-modal-container media-modal-count-${mediaAttachments?.length}`}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
tabIndex="-1"
|
||||
tabIndex="0"
|
||||
data-swipe-threshold="44"
|
||||
class="carousel"
|
||||
onClick={(e) => {
|
||||
|
@ -107,26 +174,41 @@ function MediaModal({
|
|||
onClose();
|
||||
}
|
||||
}}
|
||||
style={
|
||||
mediaAttachments.length > 1
|
||||
? {
|
||||
backgroundAttachment: 'local',
|
||||
backgroundImage: `linear-gradient(
|
||||
to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{mediaAttachments?.map((media, i) => {
|
||||
const { blurhash } = media;
|
||||
const rgbAverageColor = blurhash
|
||||
? getBlurHashAverageColor(blurhash)
|
||||
: null;
|
||||
const accentColor =
|
||||
mediaAttachments.length === 1 ? mediaAccentColors[i] : null;
|
||||
return (
|
||||
<div
|
||||
class="carousel-item"
|
||||
style={{
|
||||
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
||||
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
||||
',',
|
||||
)}, .5)`,
|
||||
}}
|
||||
style={
|
||||
accentColor
|
||||
? {
|
||||
'--accent-color': `rgb(${accentColor?.join(',')})`,
|
||||
'--accent-alpha-color': `rgba(${accentColor?.join(
|
||||
',',
|
||||
)}, 0.4)`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
tabindex="0"
|
||||
key={media.id}
|
||||
ref={i === currentIndex ? carouselFocusItem : null}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
// console.log(e);
|
||||
// if (e.target !== e.currentTarget) {
|
||||
// setShowControls(!showControls);
|
||||
// }
|
||||
if (!e.target.classList.contains('media')) {
|
||||
setShowControls(!showControls);
|
||||
}
|
||||
}}
|
||||
|
@ -134,17 +216,22 @@ function MediaModal({
|
|||
{!!media.description && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain2 media-alt"
|
||||
class="media-alt"
|
||||
hidden={!showControls}
|
||||
onClick={() => {
|
||||
setShowMediaAlt(media.description);
|
||||
states.showMediaAlt = {
|
||||
alt: media.description,
|
||||
lang,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="info" />
|
||||
<span class="media-alt-desc">{media.description}</span>
|
||||
<span class="alt-badge">ALT</span>
|
||||
<span class="media-alt-desc" lang={lang} dir="auto">
|
||||
{media.description}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<Media media={media} showOriginal />
|
||||
<Media media={media} showOriginal lang={lang} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -153,7 +240,7 @@ function MediaModal({
|
|||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
class="carousel-button"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
|
@ -166,19 +253,19 @@ function MediaModal({
|
|||
key={media.id}
|
||||
type="button"
|
||||
disabled={i === currentIndex}
|
||||
class={`plain3 carousel-dot ${
|
||||
i === currentIndex ? 'active' : ''
|
||||
}`}
|
||||
class={`carousel-dot ${i === currentIndex ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * i,
|
||||
left:
|
||||
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
carouselRef.current.focus();
|
||||
}}
|
||||
>
|
||||
•
|
||||
<Icon icon="round" size="s" />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
|
@ -186,15 +273,14 @@ function MediaModal({
|
|||
<span />
|
||||
)}
|
||||
<span>
|
||||
<Menu
|
||||
<Menu2
|
||||
overflow="auto"
|
||||
align="end"
|
||||
position="anchor"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
gap={4}
|
||||
menuClassName="glass-menu"
|
||||
menuButton={
|
||||
<button type="button" class="carousel-button plain3">
|
||||
<button type="button" class="carousel-button">
|
||||
<Icon icon="more" alt="More" />
|
||||
</button>
|
||||
}
|
||||
|
@ -204,21 +290,62 @@ function MediaModal({
|
|||
mediaAttachments[currentIndex]?.remoteUrl ||
|
||||
mediaAttachments[currentIndex]?.url
|
||||
}
|
||||
class="carousel-button plain3"
|
||||
class="carousel-button"
|
||||
target="_blank"
|
||||
title="Open original media in new window"
|
||||
>
|
||||
<Icon icon="popout" />
|
||||
<span>Open original media</span>
|
||||
</MenuLink>
|
||||
</Menu>{' '}
|
||||
{import.meta.env.DEV && // Only dev for now
|
||||
!!states.settings.mediaAltGenerator &&
|
||||
!!IMG_ALT_API_URL &&
|
||||
!!mediaAttachments[currentIndex]?.url &&
|
||||
!mediaAttachments[currentIndex]?.description &&
|
||||
mediaAttachments[currentIndex]?.type === 'image' && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
toastRef.current = showToast({
|
||||
text: 'Attempting to describe image. Please wait...',
|
||||
duration: -1,
|
||||
});
|
||||
(async function () {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${IMG_ALT_API_URL}?image=${encodeURIComponent(
|
||||
mediaAttachments[currentIndex]?.url,
|
||||
)}`,
|
||||
).then((r) => r.json());
|
||||
states.showMediaAlt = {
|
||||
alt: response.description,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Failed to describe image');
|
||||
} finally {
|
||||
setUIState('default');
|
||||
toastRef.current?.hideToast?.();
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="sparkles2" />
|
||||
<span>Describe image…</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</Menu2>{' '}
|
||||
<Link
|
||||
to={`${instance ? `/${instance}` : ''}/s/${statusID}${
|
||||
window.matchMedia('(min-width: calc(40em + 350px))').matches
|
||||
? `?media=${currentIndex + 1}`
|
||||
: ''
|
||||
}`}
|
||||
class="button carousel-button media-post-link plain3"
|
||||
class="button carousel-button media-post-link"
|
||||
// onClick={() => {
|
||||
// // if small screen (not media query min-width 40em + 350px), run onClose
|
||||
// if (
|
||||
|
@ -228,7 +355,7 @@ function MediaModal({
|
|||
// }
|
||||
// }}
|
||||
>
|
||||
<span class="button-label">See post </span>»
|
||||
<span class="button-label">View post </span>»
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -236,13 +363,17 @@ function MediaModal({
|
|||
<div class="carousel-controls" hidden={!showControls}>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
class="carousel-button"
|
||||
hidden={currentIndex === 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||
left:
|
||||
carouselRef.current.clientWidth *
|
||||
(currentIndex - 1) *
|
||||
(isRTL() ? -1 : 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
|
@ -251,13 +382,17 @@ function MediaModal({
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="carousel-button plain3"
|
||||
class="carousel-button"
|
||||
hidden={currentIndex === mediaAttachments.length - 1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
carouselRef.current.focus();
|
||||
carouselRef.current.scrollTo({
|
||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||
left:
|
||||
carouselRef.current.clientWidth *
|
||||
(currentIndex + 1) *
|
||||
(isRTL() ? -1 : 1),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
|
@ -266,69 +401,6 @@ function MediaModal({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!showMediaAlt && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowMediaAlt(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MediaAltModal
|
||||
alt={showMediaAlt}
|
||||
onClose={() => setShowMediaAlt(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaAltModal({ alt, onClose }) {
|
||||
const [forceTranslate, setForceTranslate] = useState(false);
|
||||
return (
|
||||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header class="header-grid">
|
||||
<h2>Media description</h2>
|
||||
<div class="header-side">
|
||||
<Menu
|
||||
align="end"
|
||||
menuButton={
|
||||
<button type="button" class="plain4">
|
||||
<Icon icon="more" alt="More" size="xl" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
disabled={forceTranslate}
|
||||
onClick={() => {
|
||||
setForceTranslate(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{alt}
|
||||
</p>
|
||||
{forceTranslate && (
|
||||
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
122
src/components/media-post.css
Normal file
122
src/components/media-post.css
Normal file
|
@ -0,0 +1,122 @@
|
|||
.media-post {
|
||||
--item-radius: 16px;
|
||||
position: relative;
|
||||
animation: appear-smooth 1s ease-out;
|
||||
|
||||
&:is(.filtered, .has-spoiler:not(.show-media)) :is(img, video) {
|
||||
/* filter: blur(32px);
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated; */
|
||||
opacity: 0;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
&.filtered[data-filtered-text]:before {
|
||||
content: attr(data-filtered-text);
|
||||
}
|
||||
&.has-spoiler[data-spoiler-text]:before {
|
||||
content: attr(data-spoiler-text);
|
||||
}
|
||||
|
||||
&.filtered[data-filtered-text]:before,
|
||||
&.has-spoiler[data-spoiler-text]:before {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--bg-blur-color);
|
||||
margin: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: calc(var(--item-radius) / 2);
|
||||
font-size: 90%;
|
||||
border: var(--hairline-width) dashed var(--bg-color);
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
/* mix-blend-mode: luminosity; */
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
display: box;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-spoiler.show-media[data-spoiler-text]:before {
|
||||
mix-blend-mode: normal;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.media {
|
||||
border-radius: var(--item-radius);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 1 !important;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
&:not(.media-audio) {
|
||||
background-color: var(--average-color, var(--media-bg-color));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
--drop-shadow: var(--drop-shadow-color);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 8px 16px -4px var(--drop-shadow),
|
||||
0 4px 8px var(--drop-shadow);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--drop-shadow: var(--link-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(:has(button:active)) {
|
||||
box-shadow: none;
|
||||
filter: brightness(0.8);
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
video,
|
||||
img,
|
||||
audio {
|
||||
border-radius: 16px;
|
||||
/* object-fit: scale-down; */
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:not(.filtered, .has-spoiler) &:is(:hover, :focus) img {
|
||||
/* Less delay here to make it feel more responsive */
|
||||
animation: position-object 5s ease-in-out 0.1s 5;
|
||||
animation-duration: var(--anim-duration, 5s);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-spoiler .media:not(.media-audio) {
|
||||
background-image: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
var(--average-color, var(--bg-faded-color)),
|
||||
var(--bg-color) 20em
|
||||
);
|
||||
}
|
||||
}
|
154
src/components/media-post.jsx
Normal file
154
src/components/media-post.jsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import './media-post.css';
|
||||
|
||||
import { memo } from 'preact/compat';
|
||||
import { useContext, useMemo } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import FilterContext from '../utils/filter-context';
|
||||
import { isFiltered } from '../utils/filters';
|
||||
import states, { statusKey } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
|
||||
import Media from './media';
|
||||
|
||||
function MediaPost({
|
||||
class: className,
|
||||
statusID,
|
||||
status,
|
||||
instance,
|
||||
parent,
|
||||
// allowFilters,
|
||||
onMediaClick,
|
||||
}) {
|
||||
let sKey = statusKey(statusID, instance);
|
||||
const snapStates = useSnapshot(states);
|
||||
if (!status) {
|
||||
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
|
||||
sKey = statusKey(status?.id, instance);
|
||||
}
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
account: {
|
||||
acct,
|
||||
avatar,
|
||||
avatarStatic,
|
||||
id: accountId,
|
||||
url: accountURL,
|
||||
displayName,
|
||||
username,
|
||||
emojis: accountEmojis,
|
||||
bot,
|
||||
group,
|
||||
},
|
||||
id,
|
||||
repliesCount,
|
||||
reblogged,
|
||||
reblogsCount,
|
||||
favourited,
|
||||
favouritesCount,
|
||||
bookmarked,
|
||||
poll,
|
||||
muted,
|
||||
sensitive,
|
||||
spoilerText,
|
||||
visibility, // public, unlisted, private, direct
|
||||
language,
|
||||
editedAt,
|
||||
filtered,
|
||||
card,
|
||||
createdAt,
|
||||
inReplyToId,
|
||||
inReplyToAccountId,
|
||||
content,
|
||||
mentions,
|
||||
mediaAttachments,
|
||||
reblog,
|
||||
uri,
|
||||
url,
|
||||
emojis,
|
||||
// Non-API props
|
||||
_deleted,
|
||||
_pinned,
|
||||
// _filtered,
|
||||
} = status;
|
||||
|
||||
if (!mediaAttachments?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugHover = (e) => {
|
||||
if (e.shiftKey) {
|
||||
console.log({
|
||||
...status,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const currentAccount = useMemo(() => {
|
||||
return getCurrentAccountID();
|
||||
}, []);
|
||||
const isSelf = useMemo(() => {
|
||||
return currentAccount && currentAccount === accountId;
|
||||
}, [accountId, currentAccount]);
|
||||
|
||||
const filterContext = useContext(FilterContext);
|
||||
const filterInfo = !isSelf && isFiltered(filtered, filterContext);
|
||||
|
||||
if (filterInfo?.action === 'hide') {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.debug('RENDER Media post', id, status?.account.displayName);
|
||||
|
||||
const hasSpoiler = sensitive;
|
||||
const readingExpandMedia = useMemo(() => {
|
||||
// default | show_all | hide_all
|
||||
const prefs = store.account.get('preferences') || {};
|
||||
return prefs['reading:expand:media'] || 'default';
|
||||
}, []);
|
||||
const showSpoilerMedia = readingExpandMedia === 'show_all';
|
||||
|
||||
const Parent = parent || 'div';
|
||||
|
||||
return mediaAttachments.map((media, i) => {
|
||||
const mediaKey = `${sKey}-${media.id}`;
|
||||
const filterTitleStr = filterInfo?.titlesStr;
|
||||
return (
|
||||
<Parent
|
||||
data-state-post-id={sKey}
|
||||
onMouseEnter={debugHover}
|
||||
key={mediaKey}
|
||||
data-spoiler-text={
|
||||
spoilerText || (sensitive ? 'Sensitive media' : undefined)
|
||||
}
|
||||
data-filtered-text={
|
||||
filterInfo
|
||||
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}`
|
||||
: undefined
|
||||
}
|
||||
class={`
|
||||
media-post
|
||||
${filterInfo ? 'filtered' : ''}
|
||||
${hasSpoiler ? 'has-spoiler' : ''}
|
||||
${showSpoilerMedia ? 'show-media' : ''}
|
||||
`}
|
||||
>
|
||||
<Media
|
||||
class={className}
|
||||
media={media}
|
||||
lang={language}
|
||||
to={`/${instance}/s/${id}?media-only=${i + 1}`}
|
||||
onClick={
|
||||
onMediaClick ? (e) => onMediaClick(e, i, media, status) : undefined
|
||||
}
|
||||
/>
|
||||
</Parent>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default memo(MediaPost);
|
|
@ -1,4 +1,6 @@
|
|||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { Fragment } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
|
@ -8,9 +10,12 @@ import {
|
|||
} from 'preact/hooks';
|
||||
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
||||
|
||||
import formatDuration from '../utils/format-duration';
|
||||
import mem from '../utils/mem';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import { formatDuration } from './status';
|
||||
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||
|
||||
|
@ -24,8 +29,53 @@ video = Video clip
|
|||
audio = Audio track
|
||||
*/
|
||||
|
||||
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||
const {
|
||||
const dataAltLabel = 'ALT';
|
||||
const AltBadge = (props) => {
|
||||
const { alt, lang, index, ...rest } = props;
|
||||
if (!alt || !alt.trim()) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="alt-badge clickable"
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
states.showMediaAlt = {
|
||||
alt,
|
||||
lang,
|
||||
};
|
||||
}}
|
||||
title="Media description"
|
||||
>
|
||||
{dataAltLabel}
|
||||
{!!index && <sup>{index}</sup>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const MEDIA_CAPTION_LIMIT = 140;
|
||||
const MEDIA_CAPTION_LIMIT_LONGER = 280;
|
||||
export const isMediaCaptionLong = mem((caption) =>
|
||||
caption?.length
|
||||
? caption.length > MEDIA_CAPTION_LIMIT ||
|
||||
/[\n\r].*[\n\r]/.test(caption.trim())
|
||||
: false,
|
||||
);
|
||||
|
||||
function Media({
|
||||
class: className = '',
|
||||
media,
|
||||
to,
|
||||
lang,
|
||||
showOriginal,
|
||||
autoAnimate,
|
||||
showCaption,
|
||||
allowLongerCaption,
|
||||
altIndex,
|
||||
onClick = () => {},
|
||||
}) {
|
||||
let {
|
||||
blurhash,
|
||||
description,
|
||||
meta,
|
||||
|
@ -35,21 +85,33 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
url,
|
||||
type,
|
||||
} = media;
|
||||
if (/no\-preview\./i.test(previewUrl)) {
|
||||
previewUrl = null;
|
||||
}
|
||||
const { original = {}, small, focus } = meta || {};
|
||||
|
||||
const width = showOriginal ? original?.width : small?.width;
|
||||
const height = showOriginal ? original?.height : small?.height;
|
||||
const width = showOriginal
|
||||
? original?.width
|
||||
: small?.width || original?.width;
|
||||
const height = showOriginal
|
||||
? original?.height
|
||||
: small?.height || original?.height;
|
||||
const mediaURL = showOriginal ? url : previewUrl || url;
|
||||
const remoteMediaURL = showOriginal
|
||||
? remoteUrl
|
||||
: previewRemoteUrl || remoteUrl;
|
||||
const orientation = width >= height ? 'landscape' : 'portrait';
|
||||
const hasDimensions = width && height;
|
||||
const orientation = hasDimensions
|
||||
? width > height
|
||||
? 'landscape'
|
||||
: 'portrait'
|
||||
: null;
|
||||
|
||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
||||
const videoRef = useRef();
|
||||
|
||||
let focalBackgroundPosition;
|
||||
let focalPosition;
|
||||
if (focus) {
|
||||
// Convert focal point to CSS background position
|
||||
// Formula from jquery-focuspoint
|
||||
|
@ -58,7 +120,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
// x = 1, y = -1 => 100% 100%
|
||||
const x = ((focus.x + 1) / 2) * 100;
|
||||
const y = ((1 - focus.y) / 2) * 100;
|
||||
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
|
||||
focalPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
const mediaRef = useRef();
|
||||
|
@ -84,6 +146,8 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
enabled: pinchZoomEnabled,
|
||||
draggableUnZoomed: false,
|
||||
inertiaFriction: 0.9,
|
||||
tapZoomFactor: 2,
|
||||
doubleTapToggleZoom: true,
|
||||
containerProps: {
|
||||
className: 'media-zoom',
|
||||
style: {
|
||||
|
@ -103,7 +167,18 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
[to],
|
||||
);
|
||||
|
||||
const isImage = type === 'image' || (type === 'unknown' && previewUrl);
|
||||
const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null;
|
||||
const isVideoMaybe =
|
||||
type === 'unknown' &&
|
||||
remoteMediaURLObj &&
|
||||
/\.(mp4|m4r|m4v|mov|webm)$/i.test(remoteMediaURLObj.pathname);
|
||||
const isAudioMaybe =
|
||||
type === 'unknown' &&
|
||||
remoteMediaURLObj &&
|
||||
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(remoteMediaURLObj.pathname);
|
||||
const isImage =
|
||||
type === 'image' ||
|
||||
(type === 'unknown' && previewUrl && !isVideoMaybe && !isAudioMaybe);
|
||||
|
||||
const parentRef = useRef();
|
||||
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
|
||||
|
@ -116,6 +191,66 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
if (smaller) setImageSmallerThanParent(smaller);
|
||||
}, [width, height]);
|
||||
|
||||
const maxAspectHeight =
|
||||
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
|
||||
const maxHeight = orientation === 'portrait' ? 0 : 160;
|
||||
const averageColorStyle = {
|
||||
'--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
};
|
||||
const mediaStyles =
|
||||
width && height
|
||||
? {
|
||||
'--width': `${width}px`,
|
||||
'--height': `${height}px`,
|
||||
// Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px
|
||||
'--aspectWidth': `${
|
||||
(width / height) * Math.max(maxHeight, maxAspectHeight)
|
||||
}px`,
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
...averageColorStyle,
|
||||
}
|
||||
: {
|
||||
...averageColorStyle,
|
||||
};
|
||||
|
||||
const longDesc = isMediaCaptionLong(description);
|
||||
let showInlineDesc =
|
||||
!!showCaption && !showOriginal && !!description && !longDesc;
|
||||
if (
|
||||
allowLongerCaption &&
|
||||
!showInlineDesc &&
|
||||
description?.length <= MEDIA_CAPTION_LIMIT_LONGER
|
||||
) {
|
||||
showInlineDesc = true;
|
||||
}
|
||||
const Figure = !showInlineDesc
|
||||
? Fragment
|
||||
: (props) => {
|
||||
const { children, ...restProps } = props;
|
||||
return (
|
||||
<figure {...restProps}>
|
||||
{children}
|
||||
<figcaption
|
||||
class="media-caption"
|
||||
lang={lang}
|
||||
dir="auto"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
states.showMediaAlt = {
|
||||
alt: description,
|
||||
lang,
|
||||
};
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
const [hasNaturalAspectRatio, setHasNaturalAspectRatio] = useState(undefined);
|
||||
|
||||
if (isImage) {
|
||||
// Note: type: unknown might not have width/height
|
||||
quickPinchZoomProps.containerProps.style.display = 'inherit';
|
||||
|
@ -134,71 +269,139 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
}, [mediaURL]);
|
||||
|
||||
return (
|
||||
<Parent
|
||||
ref={parentRef}
|
||||
class={`media media-image`}
|
||||
onClick={onClick}
|
||||
style={
|
||||
showOriginal && {
|
||||
backgroundImage: `url(${previewUrl})`,
|
||||
backgroundSize: imageSmallerThanParent
|
||||
? `${width}px ${height}px`
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<QuickPinchZoom {...quickPinchZoomProps}>
|
||||
<img
|
||||
ref={mediaRef}
|
||||
src={mediaURL}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onLoad={(e) => {
|
||||
e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.closest('.media-zoom').style.display = '';
|
||||
setPinchZoomEnabled(true);
|
||||
}}
|
||||
onError={(e) => {
|
||||
const { src } = e.target;
|
||||
if (src === mediaURL) {
|
||||
e.target.src = remoteMediaURL;
|
||||
<Figure>
|
||||
<Parent
|
||||
ref={parentRef}
|
||||
class={`media media-image ${className}`}
|
||||
onClick={onClick}
|
||||
data-orientation={orientation}
|
||||
data-has-alt={!showInlineDesc || undefined}
|
||||
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined}
|
||||
style={
|
||||
showOriginal
|
||||
? {
|
||||
backgroundImage: `url(${previewUrl})`,
|
||||
backgroundSize: imageSmallerThanParent
|
||||
? `${width}px ${height}px`
|
||||
: undefined,
|
||||
...averageColorStyle,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</QuickPinchZoom>
|
||||
) : (
|
||||
<img
|
||||
src={mediaURL}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
style={{
|
||||
backgroundColor:
|
||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
backgroundPosition: focalBackgroundPosition || 'center',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.dataset.loaded = true;
|
||||
}}
|
||||
onError={(e) => {
|
||||
const { src } = e.target;
|
||||
if (src === mediaURL) {
|
||||
e.target.src = remoteMediaURL;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Parent>
|
||||
: mediaStyles
|
||||
}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<QuickPinchZoom {...quickPinchZoomProps}>
|
||||
<img
|
||||
ref={mediaRef}
|
||||
src={mediaURL}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onLoad={(e) => {
|
||||
e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.closest('.media-zoom').style.display = '';
|
||||
setPinchZoomEnabled(true);
|
||||
}}
|
||||
onError={(e) => {
|
||||
const { src } = e.target;
|
||||
if (
|
||||
src === mediaURL &&
|
||||
remoteMediaURL &&
|
||||
mediaURL !== remoteMediaURL
|
||||
) {
|
||||
e.target.src = remoteMediaURL;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</QuickPinchZoom>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={mediaURL}
|
||||
alt={showInlineDesc ? '' : description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
style={{
|
||||
// backgroundColor:
|
||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
// backgroundPosition: focalBackgroundPosition || 'center',
|
||||
// Duration based on width or height in pixels
|
||||
objectPosition: focalPosition || 'center',
|
||||
// 100px per second (rough estimate)
|
||||
// Clamp between 5s and 120s
|
||||
'--anim-duration': `${Math.min(
|
||||
Math.max(Math.max(width, height) / 100, 5),
|
||||
120,
|
||||
)}s`,
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
// e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.dataset.loaded = true;
|
||||
const $media = e.target.closest('.media');
|
||||
if (!hasDimensions && $media) {
|
||||
const { naturalWidth, naturalHeight } = e.target;
|
||||
$media.dataset.orientation =
|
||||
naturalWidth > naturalHeight ? 'landscape' : 'portrait';
|
||||
$media.style.setProperty('--width', `${naturalWidth}px`);
|
||||
$media.style.setProperty('--height', `${naturalHeight}px`);
|
||||
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
|
||||
}
|
||||
|
||||
// Check natural aspect ratio vs display aspect ratio
|
||||
if ($media) {
|
||||
const {
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
} = e.target;
|
||||
if (
|
||||
clientWidth &&
|
||||
clientHeight &&
|
||||
naturalWidth &&
|
||||
naturalHeight
|
||||
) {
|
||||
const minDimension = 88;
|
||||
if (
|
||||
naturalWidth < minDimension ||
|
||||
naturalHeight < minDimension
|
||||
) {
|
||||
$media.dataset.hasSmallDimension = true;
|
||||
} else {
|
||||
const displayNaturalHeight =
|
||||
(naturalHeight * clientWidth) / naturalWidth;
|
||||
const almostSimilarHeight =
|
||||
Math.abs(displayNaturalHeight - clientHeight) < 5;
|
||||
|
||||
if (almostSimilarHeight) {
|
||||
setHasNaturalAspectRatio(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
const { src } = e.target;
|
||||
if (src === mediaURL && mediaURL !== remoteMediaURL) {
|
||||
e.target.src = remoteMediaURL;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!showInlineDesc && (
|
||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Parent>
|
||||
</Figure>
|
||||
);
|
||||
} else if (type === 'gifv' || type === 'video') {
|
||||
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
|
||||
const hasDuration = original.duration > 0;
|
||||
const shortDuration = original.duration < 31;
|
||||
const isGIF = type === 'gifv' && shortDuration;
|
||||
// If GIF is too long, treat it as a video
|
||||
|
@ -206,135 +409,282 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
const formattedDuration = formatDuration(original.duration);
|
||||
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
|
||||
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
||||
const showProgress = original.duration > 5;
|
||||
|
||||
const videoHTML = `
|
||||
<video
|
||||
src="${url}"
|
||||
poster="${previewUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
data-orientation="${orientation}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
muted="${isGIF}"
|
||||
${isGIF ? '' : 'controls'}
|
||||
playsinline
|
||||
loop="${loopable}"
|
||||
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
|
||||
></video>
|
||||
// This string is only for autoplay + muted to work on Mobile Safari
|
||||
const gifHTML = `
|
||||
<video
|
||||
src="${url}"
|
||||
poster="${previewUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
data-orientation="${orientation}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
${loopable ? 'loop' : ''}
|
||||
ondblclick="this.paused ? this.play() : this.pause()"
|
||||
${
|
||||
showProgress
|
||||
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
|
||||
: ''
|
||||
}
|
||||
></video>
|
||||
`;
|
||||
|
||||
const videoHTML = `
|
||||
<video
|
||||
src="${url}"
|
||||
poster="${previewUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
data-orientation="${orientation}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
playsinline
|
||||
${loopable ? 'loop' : ''}
|
||||
controls
|
||||
></video>
|
||||
`;
|
||||
|
||||
return (
|
||||
<Parent
|
||||
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||
autoGIFAnimate ? 'media-contain' : ''
|
||||
}`}
|
||||
data-formatted-duration={formattedDuration}
|
||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||
// style={{
|
||||
// backgroundColor:
|
||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
// }}
|
||||
onClick={(e) => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
<Figure>
|
||||
<Parent
|
||||
class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${
|
||||
autoGIFAnimate ? 'media-contain' : ''
|
||||
} ${hoverAnimate ? 'media-hover-animate' : ''}`}
|
||||
data-orientation={orientation}
|
||||
data-formatted-duration={
|
||||
!showOriginal ? formattedDuration : undefined
|
||||
}
|
||||
onClick(e);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.play();
|
||||
} catch (e) {}
|
||||
data-label={
|
||||
isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showOriginal || autoGIFAnimate ? (
|
||||
isGIF && showOriginal ? (
|
||||
<QuickPinchZoom {...quickPinchZoomProps} enabled>
|
||||
data-has-alt={!showInlineDesc || undefined}
|
||||
// style={{
|
||||
// backgroundColor:
|
||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
// }}
|
||||
style={!showOriginal && mediaStyles}
|
||||
onClick={(e) => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
onClick(e);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.play();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.play();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
videoRef.current.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showOriginal || autoGIFAnimate ? (
|
||||
isGIF && showOriginal ? (
|
||||
<QuickPinchZoom {...quickPinchZoomProps} enabled>
|
||||
<div
|
||||
ref={mediaRef}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: gifHTML,
|
||||
}}
|
||||
/>
|
||||
</QuickPinchZoom>
|
||||
) : isGIF ? (
|
||||
<div
|
||||
ref={mediaRef}
|
||||
class="video-container"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: videoHTML,
|
||||
__html: gifHTML,
|
||||
}}
|
||||
/>
|
||||
</QuickPinchZoom>
|
||||
) : (
|
||||
<div
|
||||
class="video-container"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: videoHTML,
|
||||
}}
|
||||
) : (
|
||||
<div
|
||||
class="video-container"
|
||||
dangerouslySetInnerHTML={{ __html: videoHTML }}
|
||||
/>
|
||||
)
|
||||
) : isGIF ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
poster={previewUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
preload="auto"
|
||||
// controls
|
||||
playsinline
|
||||
loop
|
||||
muted
|
||||
onTimeUpdate={
|
||||
showProgress
|
||||
? (e) => {
|
||||
const { target } = e;
|
||||
const container = target?.closest('.media-gif');
|
||||
if (container) {
|
||||
const percentage =
|
||||
(target.currentTime / target.duration) * 100;
|
||||
container.style.setProperty(
|
||||
'--progress',
|
||||
`${percentage}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : isGIF ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
poster={previewUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
preload="auto"
|
||||
// controls
|
||||
playsinline
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
) : (
|
||||
<>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={showInlineDesc ? '' : description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={(e) => {
|
||||
if (!hasDimensions) {
|
||||
const $media = e.target.closest('.media');
|
||||
if ($media) {
|
||||
const { naturalHeight, naturalWidth } = e.target;
|
||||
$media.dataset.orientation =
|
||||
naturalWidth > naturalHeight
|
||||
? 'landscape'
|
||||
: 'portrait';
|
||||
$media.style.setProperty(
|
||||
'--width',
|
||||
`${naturalWidth}px`,
|
||||
);
|
||||
$media.style.setProperty(
|
||||
'--height',
|
||||
`${naturalHeight}px`,
|
||||
);
|
||||
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={url + '#t=0.1'} // Make Safari show 1st-frame preview
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
preload="metadata"
|
||||
muted
|
||||
disablePictureInPicture
|
||||
onLoadedMetadata={(e) => {
|
||||
if (!hasDuration) {
|
||||
const { duration } = e.target;
|
||||
if (duration) {
|
||||
const formattedDuration = formatDuration(duration);
|
||||
const container = e.target.closest('.media-video');
|
||||
if (container) {
|
||||
container.dataset.formattedDuration =
|
||||
formattedDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xl" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!showOriginal && !showInlineDesc && (
|
||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
||||
)}
|
||||
</Parent>
|
||||
</Figure>
|
||||
);
|
||||
} else if (type === 'audio' || isAudioMaybe) {
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
return (
|
||||
<Figure>
|
||||
<Parent
|
||||
class={`media media-audio ${className}`}
|
||||
data-formatted-duration={
|
||||
!showOriginal ? formattedDuration : undefined
|
||||
}
|
||||
data-has-alt={!showInlineDesc || undefined}
|
||||
onClick={onClick}
|
||||
style={!showOriginal && mediaStyles}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<audio src={remoteUrl || url} preload="none" controls autoPlay />
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
alt={showInlineDesc ? '' : description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
try {
|
||||
// Remove self if broken
|
||||
e.target?.remove?.();
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xxl" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Parent>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
return (
|
||||
<Parent
|
||||
class="media media-audio"
|
||||
data-formatted-duration={formattedDuration}
|
||||
onClick={onClick}
|
||||
>
|
||||
{showOriginal ? (
|
||||
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
{!showOriginal && (
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xxl" />
|
||||
</div>
|
||||
)}
|
||||
</Parent>
|
||||
) : null}
|
||||
{!showOriginal && (
|
||||
<>
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xl" />
|
||||
</div>
|
||||
{!showInlineDesc && (
|
||||
<AltBadge alt={description} lang={lang} index={altIndex} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Parent>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Media;
|
||||
function getURLObj(url) {
|
||||
// Fake base URL if url doesn't have https:// prefix
|
||||
return URL.parse(url, location.origin);
|
||||
}
|
||||
|
||||
export default memo(Media, (oldProps, newProps) => {
|
||||
const oldMedia = oldProps.media || {};
|
||||
const newMedia = newProps.media || {};
|
||||
|
||||
return (
|
||||
oldMedia?.id === newMedia?.id &&
|
||||
oldMedia.url === newMedia.url &&
|
||||
oldProps.to === newProps.to &&
|
||||
oldProps.class === newProps.class
|
||||
);
|
||||
});
|
||||
|
|
48
src/components/menu-confirm.jsx
Normal file
48
src/components/menu-confirm.jsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { cloneElement } from 'preact';
|
||||
|
||||
import Menu2 from './menu2';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function MenuConfirm({
|
||||
subMenu = false,
|
||||
confirm = true,
|
||||
confirmLabel,
|
||||
menuItemClassName,
|
||||
menuFooter,
|
||||
menuExtras,
|
||||
...props
|
||||
}) {
|
||||
const { children, onClick, ...restProps } = props;
|
||||
if (!confirm) {
|
||||
if (subMenu) return <MenuItem {...props} />;
|
||||
if (onClick) {
|
||||
return cloneElement(children, {
|
||||
onClick,
|
||||
});
|
||||
}
|
||||
return children;
|
||||
}
|
||||
const Parent = subMenu ? SubMenu2 : Menu2;
|
||||
return (
|
||||
<Parent
|
||||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
shift={8}
|
||||
menuClassName="menu-emphasized"
|
||||
{...restProps}
|
||||
menuButton={subMenu ? undefined : children}
|
||||
label={subMenu ? children : undefined}
|
||||
>
|
||||
<MenuItem className={menuItemClassName} onClick={onClick}>
|
||||
{confirmLabel}
|
||||
</MenuItem>
|
||||
{menuExtras}
|
||||
{menuFooter}
|
||||
</Parent>
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuConfirm;
|
|
@ -3,11 +3,12 @@ import { FocusableItem } from '@szhsin/react-menu';
|
|||
import Link from './link';
|
||||
|
||||
function MenuLink(props) {
|
||||
const { className, disabled, ...restProps } = props;
|
||||
return (
|
||||
<FocusableItem>
|
||||
<FocusableItem className={className} disabled={disabled}>
|
||||
{({ ref, closeMenu }) => (
|
||||
<Link
|
||||
{...props}
|
||||
{...restProps}
|
||||
ref={ref}
|
||||
onClick={({ detail }) =>
|
||||
closeMenu(detail === 0 ? 'Enter' : undefined)
|
||||
|
|
|
@ -1,19 +1,33 @@
|
|||
import { Menu } from '@szhsin/react-menu';
|
||||
import { useWindowSize } from '@uidotdev/usehooks';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import isRTL from '../utils/is-rtl';
|
||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||
import useWindowSize from '../utils/useWindowSize';
|
||||
|
||||
// It's like Menu but with sensible defaults, bug fixes and improvements.
|
||||
function Menu2(props) {
|
||||
const { containerProps } = props;
|
||||
const { containerProps, instanceRef: _instanceRef, align } = props;
|
||||
const size = useWindowSize();
|
||||
const instanceRef = useRef();
|
||||
const instanceRef = _instanceRef?.current ? _instanceRef : useRef();
|
||||
|
||||
// Values: start, end, center
|
||||
// Note: don't mess with 'center'
|
||||
const rtlAlign = isRTL()
|
||||
? align === 'end'
|
||||
? 'start'
|
||||
: align === 'start'
|
||||
? 'end'
|
||||
: align
|
||||
: align;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
boundingBoxPadding={safeBoundingBoxPadding()}
|
||||
repositionFlag={`${size.width}x${size.height}`}
|
||||
unmountOnClose
|
||||
{...props}
|
||||
align={rtlAlign}
|
||||
instanceRef={instanceRef}
|
||||
containerProps={{
|
||||
onClick: (e) => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#modal-container > div {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
|
@ -9,10 +9,66 @@
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--backdrop-color);
|
||||
backdrop-filter: blur(24px);
|
||||
animation: appear 0.5s var(--timing-function) both;
|
||||
transition: all 0.5s var(--timing-function);
|
||||
|
||||
&.solid {
|
||||
background-color: var(--backdrop-solid-color);
|
||||
}
|
||||
|
||||
--compose-button-dimension: 56px;
|
||||
--compose-button-dimension-half: calc(var(--compose-button-dimension) / 2);
|
||||
--compose-button-dimension-margin: 16px;
|
||||
|
||||
&.min {
|
||||
/* Minimized */
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
transform: scale(0);
|
||||
--end: max(
|
||||
var(--compose-button-dimension-margin),
|
||||
env(safe-area-inset-right)
|
||||
);
|
||||
:dir(rtl) & {
|
||||
--end: max(
|
||||
var(--compose-button-dimension-margin),
|
||||
env(safe-area-inset-left)
|
||||
);
|
||||
}
|
||||
--bottom: max(
|
||||
var(--compose-button-dimension-margin),
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
--origin-end: calc(
|
||||
100% - var(--compose-button-dimension-half) - var(--end)
|
||||
);
|
||||
:dir(rtl) & {
|
||||
--origin-end: calc(var(--compose-button-dimension-half) + var(--end));
|
||||
}
|
||||
--origin-bottom: calc(
|
||||
100% - var(--compose-button-dimension-half) - var(--bottom)
|
||||
);
|
||||
transform-origin: var(--origin-end) var(--origin-bottom);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
transition: transform 0.3s var(--timing-function);
|
||||
transform-origin: 80% 80%;
|
||||
}
|
||||
|
||||
&:has(~ div) .sheet {
|
||||
transform: scale(0.975);
|
||||
}
|
||||
}
|
||||
|
||||
#modal-container > .light {
|
||||
backdrop-filter: saturate(0.75);
|
||||
@media (max-width: calc(40em - 1px)) {
|
||||
#app[data-shortcuts-view-mode='tab-menu-bar'] ~ #modal-container > div.min {
|
||||
border: 2px solid red;
|
||||
|
||||
--bottom: calc(
|
||||
var(--compose-button-dimension-margin) + env(safe-area-inset-bottom) +
|
||||
52px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,13 @@ import './modal.css';
|
|||
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import useCloseWatcher from '../utils/useCloseWatcher';
|
||||
|
||||
const $modalContainer = document.getElementById('modal-container');
|
||||
|
||||
function Modal({ children, onClick, class: className }) {
|
||||
function Modal({ children, onClose, onClick, class: className, minimized }) {
|
||||
if (!children) return null;
|
||||
|
||||
const modalRef = useRef();
|
||||
|
@ -19,8 +22,84 @@ function Modal({ children, onClick, class: className }) {
|
|||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const supportsCloseWatcher = window.CloseWatcher;
|
||||
const escRef = useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
onClose?.();
|
||||
}, 0);
|
||||
},
|
||||
{
|
||||
enabled: !supportsCloseWatcher && !!onClose,
|
||||
// Using keyup and setTimeout above
|
||||
// This will run "later" to prevent clash with esc handlers from other components
|
||||
keydown: false,
|
||||
keyup: true,
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
useCloseWatcher(onClose, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const $deckContainers = document.querySelectorAll('.deck-container');
|
||||
if (minimized) {
|
||||
// Similar to focusDeck in focus-deck.jsx
|
||||
// Focus last deck
|
||||
const page = $deckContainers[$deckContainers.length - 1]; // last one
|
||||
if (page && page.tabIndex === -1) {
|
||||
page.focus();
|
||||
}
|
||||
} else {
|
||||
if (children) {
|
||||
$deckContainers.forEach(($deckContainer) => {
|
||||
$deckContainer.setAttribute('inert', '');
|
||||
});
|
||||
} else {
|
||||
$deckContainers.forEach(($deckContainer) => {
|
||||
$deckContainer.removeAttribute('inert');
|
||||
});
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
$deckContainers.forEach(($deckContainer) => {
|
||||
$deckContainer.removeAttribute('inert');
|
||||
});
|
||||
};
|
||||
}, [children, minimized]);
|
||||
|
||||
const Modal = (
|
||||
<div ref={modalRef} className={className} onClick={onClick}>
|
||||
<div
|
||||
ref={(node) => {
|
||||
modalRef.current = node;
|
||||
escRef.current = node?.querySelector?.('[tabindex="-1"]') || node;
|
||||
}}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
onClick?.(e);
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose?.(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={minimized ? 0 : '-1'}
|
||||
inert={minimized}
|
||||
onFocus={(e) => {
|
||||
try {
|
||||
if (e.target === e.currentTarget) {
|
||||
const focusElement =
|
||||
modalRef.current?.querySelector('[tabindex="-1"]');
|
||||
const isFocusable =
|
||||
!!focusElement &&
|
||||
getComputedStyle(focusElement)?.pointerEvents !== 'none';
|
||||
if (focusElement && isFocusable) {
|
||||
focusElement.focus();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
246
src/components/modals.jsx
Normal file
246
src/components/modals.jsx
Normal file
|
@ -0,0 +1,246 @@
|
|||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { subscribe, useSnapshot } from 'valtio';
|
||||
|
||||
import Accounts from '../pages/accounts';
|
||||
import Settings from '../pages/settings';
|
||||
import focusDeck from '../utils/focus-deck';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
|
||||
import AccountSheet from './account-sheet';
|
||||
import ComposeSuspense, { preload } from './compose-suspense';
|
||||
import Drafts from './drafts';
|
||||
import EmbedModal from './embed-modal';
|
||||
import GenericAccounts from './generic-accounts';
|
||||
import MediaAltModal from './media-alt-modal';
|
||||
import MediaModal from './media-modal';
|
||||
import Modal from './modal';
|
||||
import ReportModal from './report-modal';
|
||||
import ShortcutsSettings from './shortcuts-settings';
|
||||
|
||||
subscribe(states, (changes) => {
|
||||
for (const [action, path, value, prevValue] of changes) {
|
||||
// When closing modal, focus on deck
|
||||
if (/^show/i.test(path) && !value) {
|
||||
focusDeck();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default function Modals() {
|
||||
const snapStates = useSnapshot(states);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(preload, 1000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!snapStates.showCompose && (
|
||||
<Modal
|
||||
class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`}
|
||||
minimized={!!snapStates.composerState.minimized}
|
||||
>
|
||||
<ComposeSuspense
|
||||
replyToStatus={
|
||||
typeof snapStates.showCompose !== 'boolean'
|
||||
? snapStates.showCompose.replyToStatus
|
||||
: window.__COMPOSE__?.replyToStatus || null
|
||||
}
|
||||
editStatus={
|
||||
states.showCompose?.editStatus ||
|
||||
window.__COMPOSE__?.editStatus ||
|
||||
null
|
||||
}
|
||||
draftStatus={
|
||||
states.showCompose?.draftStatus ||
|
||||
window.__COMPOSE__?.draftStatus ||
|
||||
null
|
||||
}
|
||||
onClose={(results) => {
|
||||
const { newStatus, instance, type } = results || {};
|
||||
states.showCompose = false;
|
||||
window.__COMPOSE__ = null;
|
||||
if (newStatus) {
|
||||
states.reloadStatusPage++;
|
||||
showToast({
|
||||
text: {
|
||||
post: 'Post published. Check it out.',
|
||||
reply: 'Reply posted. Check it out.',
|
||||
edit: 'Post updated. Check it out.',
|
||||
}[type || 'post'],
|
||||
delay: 1000,
|
||||
duration: 10_000, // 10 seconds
|
||||
onClick: (toast) => {
|
||||
toast.hideToast();
|
||||
states.prevLocation = location;
|
||||
navigate(
|
||||
instance
|
||||
? `/${instance}/s/${newStatus.id}`
|
||||
: `/s/${newStatus.id}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showSettings && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showSettings = false;
|
||||
}}
|
||||
>
|
||||
<Settings
|
||||
onClose={() => {
|
||||
states.showSettings = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showAccounts && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showAccounts = false;
|
||||
}}
|
||||
>
|
||||
<Accounts
|
||||
onClose={() => {
|
||||
states.showAccounts = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showAccount && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showAccount = false;
|
||||
}}
|
||||
>
|
||||
<AccountSheet
|
||||
account={snapStates.showAccount?.account || snapStates.showAccount}
|
||||
instance={snapStates.showAccount?.instance}
|
||||
onClose={({ destination } = {}) => {
|
||||
states.showAccount = false;
|
||||
// states.showGenericAccounts = false;
|
||||
// if (destination) {
|
||||
// states.showAccounts = false;
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showDrafts && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showDrafts = false;
|
||||
}}
|
||||
>
|
||||
<Drafts onClose={() => (states.showDrafts = false)} />
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showMediaModal && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (
|
||||
e.target === e.currentTarget ||
|
||||
e.target.classList.contains('media')
|
||||
) {
|
||||
states.showMediaModal = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MediaModal
|
||||
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
|
||||
instance={snapStates.showMediaModal.instance}
|
||||
index={snapStates.showMediaModal.index}
|
||||
statusID={snapStates.showMediaModal.statusID}
|
||||
onClose={() => {
|
||||
states.showMediaModal = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showShortcutsSettings && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showShortcutsSettings = false;
|
||||
}}
|
||||
>
|
||||
<ShortcutsSettings
|
||||
onClose={() => (states.showShortcutsSettings = false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showGenericAccounts && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showGenericAccounts = false;
|
||||
}}
|
||||
>
|
||||
<GenericAccounts
|
||||
instance={snapStates.showGenericAccounts.instance}
|
||||
excludeRelationshipAttrs={
|
||||
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
||||
}
|
||||
postID={snapStates.showGenericAccounts.postID}
|
||||
onClose={() => (states.showGenericAccounts = false)}
|
||||
blankCopy={snapStates.showGenericAccounts.blankCopy}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showMediaAlt && (
|
||||
<Modal
|
||||
onClose={(e) => {
|
||||
states.showMediaAlt = false;
|
||||
}}
|
||||
>
|
||||
<MediaAltModal
|
||||
alt={snapStates.showMediaAlt.alt || snapStates.showMediaAlt}
|
||||
lang={snapStates.showMediaAlt?.lang}
|
||||
onClose={() => {
|
||||
states.showMediaAlt = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showEmbedModal && (
|
||||
<Modal
|
||||
class="solid"
|
||||
onClose={() => {
|
||||
states.showEmbedModal = false;
|
||||
}}
|
||||
>
|
||||
<EmbedModal
|
||||
html={snapStates.showEmbedModal.html}
|
||||
url={snapStates.showEmbedModal.url}
|
||||
width={snapStates.showEmbedModal.width}
|
||||
height={snapStates.showEmbedModal.height}
|
||||
onClose={() => {
|
||||
states.showEmbedModal = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showReportModal && (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
states.showReportModal = false;
|
||||
}}
|
||||
>
|
||||
<ReportModal
|
||||
account={snapStates.showReportModal.account}
|
||||
post={snapStates.showReportModal.post}
|
||||
onClose={() => {
|
||||
states.showReportModal = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -2,6 +2,17 @@
|
|||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: inline;
|
||||
unicode-bidi: isolate;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
i {
|
||||
font-variant-numeric: slashed-zero;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
}
|
||||
.name-text.show-acct {
|
||||
display: inline-block;
|
||||
|
@ -15,6 +26,9 @@ a.name-text.short:is(:hover, :focus) i {
|
|||
font-style: normal;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.name-text i.instance {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.name-text .avatar {
|
||||
vertical-align: middle;
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import './name-text.css';
|
||||
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import EmojiText from './emoji-text';
|
||||
|
||||
const nameCollator = new Intl.Collator('en', {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
function NameText({
|
||||
account,
|
||||
instance,
|
||||
|
@ -14,35 +21,60 @@ function NameText({
|
|||
external,
|
||||
onClick,
|
||||
}) {
|
||||
const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
|
||||
account;
|
||||
let { username } = account;
|
||||
const {
|
||||
acct,
|
||||
avatar,
|
||||
avatarStatic,
|
||||
id,
|
||||
url,
|
||||
displayName,
|
||||
emojis,
|
||||
bot,
|
||||
username,
|
||||
} = account;
|
||||
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||
|
||||
if (!instance) instance = api().instance;
|
||||
|
||||
const trimmedUsername = username.toLowerCase().trim();
|
||||
const trimmedDisplayName = (displayName || '').toLowerCase().trim();
|
||||
const shortenedDisplayName = trimmedDisplayName
|
||||
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
|
||||
.replace(/\s+/g, '') // E.g. "My name" === "myname"
|
||||
.replace(/[^a-z0-9]/gi, ''); // Remove non-alphanumeric characters
|
||||
.replace(/\s+/g, ''); // E.g. "My name" === "myname"
|
||||
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
|
||||
/[^a-z0-9@\.]/gi,
|
||||
'',
|
||||
); // Remove non-alphanumeric characters
|
||||
|
||||
if (
|
||||
!short &&
|
||||
(trimmedUsername === trimmedDisplayName ||
|
||||
trimmedUsername === shortenedDisplayName)
|
||||
) {
|
||||
username = null;
|
||||
}
|
||||
const hideUsername =
|
||||
(!short &&
|
||||
(trimmedUsername === trimmedDisplayName ||
|
||||
trimmedUsername === shortenedDisplayName ||
|
||||
trimmedUsername === shortenedAlphaNumericDisplayName ||
|
||||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) ||
|
||||
shortenedAlphaNumericDisplayName === acct.toLowerCase();
|
||||
|
||||
return (
|
||||
<a
|
||||
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`@${acct}`}
|
||||
title={
|
||||
displayName
|
||||
? `${displayName} (${acct2 ? '' : '@'}${acct})`
|
||||
: `${acct2 ? '' : '@'}${acct}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (external) return;
|
||||
if (e.shiftKey) return; // Save link? 🤷♂️
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onClick) return onClick(e);
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.which === 2) {
|
||||
const internalURL = `#/${instance}/a/${id}`;
|
||||
window.open(internalURL, '_blank');
|
||||
return;
|
||||
}
|
||||
states.showAccount = {
|
||||
account,
|
||||
instance,
|
||||
|
@ -56,29 +88,39 @@ function NameText({
|
|||
)}
|
||||
{displayName && !short ? (
|
||||
<>
|
||||
<b>
|
||||
<b dir="auto">
|
||||
<EmojiText text={displayName} emojis={emojis} />
|
||||
</b>
|
||||
{!showAcct && username && (
|
||||
{!showAcct && !hideUsername ? (
|
||||
<>
|
||||
{' '}
|
||||
<i>@{username}</i>
|
||||
<i class="bidi-isolate">@{username}</i>
|
||||
</>
|
||||
)}
|
||||
) : ' '}
|
||||
<i class="instance">{acct2}</i>
|
||||
</>
|
||||
) : short ? (
|
||||
<i>@{username}</i>
|
||||
<i>{username}</i>
|
||||
) : (
|
||||
<b>@{username}</b>
|
||||
<b>{username}</b>
|
||||
)}
|
||||
{showAcct && (
|
||||
<>
|
||||
<br />
|
||||
<i>@{acct}</i>
|
||||
<i class="bidi-isolate">
|
||||
{acct2 ? '' : '@'}
|
||||
{acct1}
|
||||
{!!acct2 && <span class="ib">{acct2}</span>}
|
||||
</i>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default NameText;
|
||||
export default memo(NameText, (oldProps, newProps) => {
|
||||
// Only care about account.id, the other props usually don't change
|
||||
const { account } = oldProps;
|
||||
const { account: newAccount } = newProps;
|
||||
return account?.acct === newAccount?.acct;
|
||||
});
|
||||
|
|
|
@ -1,12 +1,29 @@
|
|||
.nav-menu section:last-child {
|
||||
background-color: var(--bg-faded-color);
|
||||
margin-bottom: -8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 23em) {
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'left right';
|
||||
padding: 0;
|
||||
width: 22em;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
.nav-menu .top-menu {
|
||||
grid-area: top;
|
||||
padding-top: 8px;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
.nav-menu section {
|
||||
padding: 8px 0;
|
||||
width: 50%;
|
||||
/* width: 50%; */
|
||||
}
|
||||
@keyframes phanpying {
|
||||
0% {
|
||||
|
@ -17,13 +34,16 @@
|
|||
}
|
||||
}
|
||||
.nav-menu section:last-child {
|
||||
background-color: var(--bg-faded-color);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--to-forward),
|
||||
var(--divider-color) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(to bottom left, var(--bg-blur-color), transparent),
|
||||
linear-gradient(
|
||||
to bottom var(--backward),
|
||||
var(--bg-blur-color),
|
||||
transparent
|
||||
),
|
||||
url(../assets/phanpy-bg.svg);
|
||||
background-repeat: no-repeat;
|
||||
/* background-size: auto, auto, 200%; */
|
||||
|
@ -33,6 +53,17 @@
|
|||
position: sticky;
|
||||
top: 0;
|
||||
animation: phanpying 0.2s ease-in-out both;
|
||||
border-start-end-radius: inherit;
|
||||
border-end-end-radius: inherit;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.divider-grow {
|
||||
flex-grow: 1;
|
||||
height: auto;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.nav-menu section:last-child > .szh-menu__divider:first-child {
|
||||
display: none;
|
||||
|
@ -47,3 +78,21 @@
|
|||
width: 28em;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkle-icon {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
color: var(--red-color);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.2);
|
||||
color: var(--orange-color);
|
||||
}
|
||||
}
|
||||
.sparkle-icon {
|
||||
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.nav-submenu {
|
||||
max-width: 14em;
|
||||
}
|
||||
|
|
|
@ -1,33 +1,35 @@
|
|||
import './nav-menu.css';
|
||||
|
||||
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { ControlledMenu, FocusableItem, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import { getLists } from '../utils/lists';
|
||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||
import states from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
import supports from '../utils/supports';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import MenuLink from './menu-link';
|
||||
import SubMenu2 from './submenu2';
|
||||
import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
|
||||
|
||||
function NavMenu(props) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { instance, authenticated } = api();
|
||||
const { masto, instance, authenticated } = api();
|
||||
|
||||
const [currentAccount, setCurrentAccount] = useState();
|
||||
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const [currentAccount, moreThanOneAccount] = useMemo(() => {
|
||||
const accounts = store.local.getJSON('accounts') || [];
|
||||
const acc = accounts.find(
|
||||
(account) => account.info.id === store.session.get('currentAccount'),
|
||||
);
|
||||
if (acc) setCurrentAccount(acc);
|
||||
setMoreThanOneAccount(accounts.length > 1);
|
||||
const acc =
|
||||
accounts.find((account) => account.info.id === getCurrentAccountID()) ||
|
||||
accounts[0];
|
||||
return [acc, accounts.length > 1];
|
||||
}, []);
|
||||
|
||||
// Home = Following
|
||||
|
@ -35,8 +37,9 @@ function NavMenu(props) {
|
|||
// User may choose pin or not to pin Following
|
||||
// If user doesn't pin Following, we show it in the menu
|
||||
const showFollowing =
|
||||
(snapStates.settings.shortcutsColumnsMode ||
|
||||
snapStates.settings.shortcutsViewMode === 'multi-column') &&
|
||||
(snapStates.settings.shortcutsViewMode === 'multi-column' ||
|
||||
(!snapStates.settings.shortcutsViewMode &&
|
||||
snapStates.settings.shortcutsColumnsMode)) &&
|
||||
!snapStates.shortcuts.find((pin) => pin.type === 'following');
|
||||
|
||||
const bindLongPress = useLongPress(
|
||||
|
@ -60,6 +63,38 @@ function NavMenu(props) {
|
|||
0,
|
||||
]);
|
||||
|
||||
const mutesIterator = useRef();
|
||||
async function fetchMutes(firstLoad) {
|
||||
if (firstLoad || !mutesIterator.current) {
|
||||
mutesIterator.current = masto.v1.mutes.list({
|
||||
limit: 80,
|
||||
});
|
||||
}
|
||||
const results = await mutesIterator.current.next();
|
||||
return results;
|
||||
}
|
||||
|
||||
const blocksIterator = useRef();
|
||||
async function fetchBlocks(firstLoad) {
|
||||
if (firstLoad || !blocksIterator.current) {
|
||||
blocksIterator.current = masto.v1.blocks.list({
|
||||
limit: 80,
|
||||
});
|
||||
}
|
||||
const results = await blocksIterator.current.next();
|
||||
return results;
|
||||
}
|
||||
|
||||
const supportsLists = supports('@mastodon/lists');
|
||||
const [lists, setLists] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!supportsLists) return;
|
||||
if (menuState === 'open') {
|
||||
getLists().then(setLists);
|
||||
}
|
||||
}, [menuState === 'open']);
|
||||
|
||||
const buttonClickTS = useRef();
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
|
@ -67,9 +102,10 @@ function NavMenu(props) {
|
|||
type="button"
|
||||
class={`button plain nav-menu-button ${
|
||||
moreThanOneAccount ? 'with-avatar' : ''
|
||||
} ${open ? 'active' : ''}`}
|
||||
} ${menuState === 'open' ? 'active' : ''}`}
|
||||
style={{ position: 'relative' }}
|
||||
onClick={() => {
|
||||
buttonClickTS.current = Date.now();
|
||||
setMenuState((state) => (!state ? 'open' : undefined));
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
|
@ -101,7 +137,10 @@ function NavMenu(props) {
|
|||
zIndex: 10,
|
||||
},
|
||||
onClick: () => {
|
||||
setMenuState(undefined);
|
||||
if (Date.now() - buttonClickTS.current < 300) {
|
||||
return;
|
||||
}
|
||||
// setMenuState(undefined);
|
||||
},
|
||||
}}
|
||||
portal={{
|
||||
|
@ -115,41 +154,47 @@ function NavMenu(props) {
|
|||
boundingBoxPadding={boundingBoxPadding}
|
||||
unmountOnClose
|
||||
>
|
||||
{!!snapStates.appVersion?.commitHash &&
|
||||
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
|
||||
<div class="top-menu">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const yes = confirm('Reload page now to update?');
|
||||
if (yes) {
|
||||
(async () => {
|
||||
try {
|
||||
location.reload();
|
||||
} catch (e) {}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="sparkles" class="sparkle-icon" size="l" />{' '}
|
||||
<span>New update available…</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
{!!snapStates.appVersion?.commitHash &&
|
||||
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
const yes = confirm('Reload page now to update?');
|
||||
if (yes) {
|
||||
(async () => {
|
||||
try {
|
||||
location.reload();
|
||||
} catch (e) {}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="sparkles" size="l" />{' '}
|
||||
<span>New update available…</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
</>
|
||||
)}
|
||||
<MenuLink to="/">
|
||||
<Icon icon="home" size="l" /> <span>Home</span>
|
||||
</MenuLink>
|
||||
{authenticated && (
|
||||
{authenticated ? (
|
||||
<>
|
||||
{showFollowing && (
|
||||
<MenuLink to="/following">
|
||||
<Icon icon="following" size="l" /> <span>Following</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/mentions">
|
||||
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||
<MenuLink to="/catchup">
|
||||
<Icon icon="history2" size="l" />
|
||||
<span>Catch-up</span>
|
||||
</MenuLink>
|
||||
{supports('@mastodon/mentions') && (
|
||||
<MenuLink to="/mentions">
|
||||
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuLink to="/notifications">
|
||||
<Icon icon="notification" size="l" /> <span>Notifications</span>
|
||||
{snapStates.notificationsShowNew && (
|
||||
|
@ -159,44 +204,107 @@ function NavMenu(props) {
|
|||
</sup>
|
||||
)}
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" /> <span>Lists</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/ft">
|
||||
<Icon icon="hashtag" size="l" /> <span>Followed Hashtags</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/b">
|
||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/f">
|
||||
<Icon icon="heart" size="l" /> <span>Favourites</span>
|
||||
</MenuLink>
|
||||
</>
|
||||
)}
|
||||
<MenuDivider />
|
||||
<MenuLink to={`/search`}>
|
||||
<Icon icon="search" size="l" /> <span>Search</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/p/l`}>
|
||||
<Icon icon="group" size="l" /> <span>Local</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/p`}>
|
||||
<Icon icon="earth" size="l" /> <span>Federated</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/trending`}>
|
||||
<Icon icon="chart" size="l" /> <span>Trending</span>
|
||||
</MenuLink>
|
||||
</section>
|
||||
<section>
|
||||
{authenticated ? (
|
||||
<>
|
||||
<MenuDivider />
|
||||
{currentAccount?.info?.id && (
|
||||
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
|
||||
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
{currentAccount && accountsIsDtth(currentAccount) &&
|
||||
<FocusableItem title="Takes you to DTTHDon settings">
|
||||
<a href={gtsDtthSettings} target='_blank'><Icon icon="user-setting" size="l" /> <span>User Settings…</span></a>
|
||||
</FocusableItem>}
|
||||
{lists?.length > 0 ? (
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
label={
|
||||
<>
|
||||
<Icon icon="list" size="l" />
|
||||
<span class="menu-grow">Lists</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
</MenuLink>
|
||||
{lists?.length > 0 && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
{lists.map((list) => (
|
||||
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SubMenu2>
|
||||
) : (
|
||||
supportsLists && (
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" />
|
||||
<span>Lists</span>
|
||||
</MenuLink>
|
||||
)
|
||||
)}
|
||||
<MenuLink to="/b">
|
||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||
</MenuLink>
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
label={
|
||||
<>
|
||||
<Icon icon="more" size="l" />
|
||||
<span class="menu-grow">More…</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/f">
|
||||
<Icon icon="heart" size="l" /> <span>Likes</span>
|
||||
</MenuLink>
|
||||
<MenuLink to="/fh">
|
||||
<Icon icon="hashtag" size="l" />{' '}
|
||||
<span>Followed Hashtags</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
{supports('@mastodon/filters') && (
|
||||
<MenuLink to="/ft">
|
||||
<Icon icon="filters" size="l" />
|
||||
Filters
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'mute',
|
||||
heading: 'Muted users',
|
||||
fetchAccounts: fetchMutes,
|
||||
excludeRelationshipAttrs: ['muting'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="mute" size="l" /> Muted users…
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'block',
|
||||
heading: 'Blocked users',
|
||||
fetchAccounts: fetchBlocks,
|
||||
excludeRelationshipAttrs: ['blocking'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
</MenuItem>{' '}
|
||||
</SubMenu2>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showAccounts = true;
|
||||
|
@ -204,13 +312,48 @@ function NavMenu(props) {
|
|||
>
|
||||
<Icon icon="group" size="l" /> <span>Accounts…</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuLink to="/login">
|
||||
<Icon icon="user" size="l" /> <span>Log in</span>
|
||||
</MenuLink>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<MenuDivider />
|
||||
<MenuLink to={`/search`}>
|
||||
<Icon icon="search" size="l" /> <span>Search</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/trending`}>
|
||||
<Icon icon="chart" size="l" /> <span>Trending</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/p/l`}>
|
||||
<Icon icon="building" size="l" /> <span>Local</span>
|
||||
</MenuLink>
|
||||
<MenuLink to={`/${instance}/p`}>
|
||||
<Icon icon="earth" size="l" /> <span>Federated</span>
|
||||
</MenuLink>
|
||||
{authenticated ? (
|
||||
<>
|
||||
<MenuDivider className="divider-grow" />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showKeyboardShortcutsHelp = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="keyboard" size="l" />{' '}
|
||||
<span>Keyboard shortcuts</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showShortcutsSettings = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="shortcut" size="l" />{' '}
|
||||
<span>Shortcuts Settings…</span>
|
||||
<span>Shortcuts / Columns…</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
@ -223,9 +366,13 @@ function NavMenu(props) {
|
|||
) : (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuLink to="/login">
|
||||
<Icon icon="user" size="l" /> <span>Log in</span>
|
||||
</MenuLink>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showSettings = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="gear" size="l" /> <span>Settings…</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
@ -234,4 +381,4 @@ function NavMenu(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export default NavMenu;
|
||||
export default memo(NavMenu);
|
||||
|
|
199
src/components/notification-service.jsx
Normal file
199
src/components/notification-service.jsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
import { memo } from 'preact/compat';
|
||||
import { useLayoutEffect, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import states from '../utils/states';
|
||||
import {
|
||||
getAccountByAccessToken,
|
||||
getCurrentAccount,
|
||||
} from '../utils/store-utils';
|
||||
import usePageVisibility from '../utils/usePageVisibility';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import Modal from './modal';
|
||||
import Notification from './notification';
|
||||
|
||||
{
|
||||
if ('serviceWorker' in navigator) {
|
||||
console.log('👂👂👂 Listen to message');
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
console.log('💥💥💥 Message event', event);
|
||||
const { type, id, accessToken } = event?.data || {};
|
||||
if (type === 'notification') {
|
||||
states.routeNotification = {
|
||||
id,
|
||||
accessToken,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(function NotificationService() {
|
||||
if (!('serviceWorker' in navigator)) return null;
|
||||
|
||||
const snapStates = useSnapshot(states);
|
||||
const { routeNotification } = snapStates;
|
||||
|
||||
console.log('🛎️ Notification service', routeNotification);
|
||||
|
||||
const { id, accessToken } = routeNotification || {};
|
||||
const [showNotificationSheet, setShowNotificationSheet] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
const { instance: currentInstance } = api();
|
||||
const { masto, instance } = api({
|
||||
accessToken,
|
||||
});
|
||||
console.log('API', { accessToken, currentInstance, instance });
|
||||
const sameInstance = currentInstance === instance;
|
||||
const account = accessToken
|
||||
? getAccountByAccessToken(accessToken)
|
||||
: getCurrentAccount();
|
||||
(async () => {
|
||||
const notification = await masto.v1.notifications.$select(id).fetch();
|
||||
if (notification && account) {
|
||||
console.log('🛎️ Notification', { id, notification, account });
|
||||
const accountInstance = account.instanceURL;
|
||||
const { type, status, account: notificationAccount } = notification;
|
||||
const hasModal = !!document.querySelector('#modal-container > *');
|
||||
const isFollow = type === 'follow' && !!notificationAccount?.id;
|
||||
const hasAccount = !!notificationAccount?.id;
|
||||
const hasStatus = !!status?.id;
|
||||
if (isFollow && sameInstance) {
|
||||
// Show account sheet, can handle different instances
|
||||
states.showAccount = {
|
||||
account: notificationAccount,
|
||||
instance: accountInstance,
|
||||
};
|
||||
} else if (hasModal || !sameInstance || (hasAccount && hasStatus)) {
|
||||
// Show sheet of notification, if
|
||||
// - there is a modal open
|
||||
// - the notification is from another instance
|
||||
// - the notification has both account and status, gives choice for users to go to account or status
|
||||
setShowNotificationSheet({
|
||||
id,
|
||||
account,
|
||||
notification,
|
||||
sameInstance,
|
||||
});
|
||||
} else {
|
||||
if (hasStatus) {
|
||||
// Go to status page
|
||||
location.hash = `/${currentInstance}/s/${status.id}`;
|
||||
} else if (isFollow) {
|
||||
// Go to profile page
|
||||
location.hash = `/${currentInstance}/a/${notificationAccount.id}`;
|
||||
} else {
|
||||
// Go to notifications page
|
||||
location.hash = '/notifications';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('🛎️ Notification not found', id);
|
||||
}
|
||||
})();
|
||||
}, [id, accessToken]);
|
||||
|
||||
// useLayoutEffect(() => {
|
||||
// // Listen to message from service worker
|
||||
// const handleMessage = (event) => {
|
||||
// console.log('💥💥💥 Message event', event);
|
||||
// const { type, id, accessToken } = event?.data || {};
|
||||
// if (type === 'notification') {
|
||||
// states.routeNotification = {
|
||||
// id,
|
||||
// accessToken,
|
||||
// };
|
||||
// }
|
||||
// };
|
||||
// console.log('👂👂👂 Listen to message');
|
||||
// navigator.serviceWorker.addEventListener('message', handleMessage);
|
||||
// return () => {
|
||||
// console.log('👂👂👂 Remove listen to message');
|
||||
// navigator.serviceWorker.removeEventListener('message', handleMessage);
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (navigator?.clearAppBadge) {
|
||||
navigator.clearAppBadge();
|
||||
}
|
||||
}, []);
|
||||
usePageVisibility((visible) => {
|
||||
if (visible && navigator?.clearAppBadge) {
|
||||
console.log('🔰 Clear app badge');
|
||||
navigator.clearAppBadge();
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
setShowNotificationSheet(false);
|
||||
states.routeNotification = null;
|
||||
|
||||
// If url is #/notifications?id=123, go to #/notifications
|
||||
if (/\/notifications\?id=/i.test(location.hash)) {
|
||||
location.hash = '/notifications';
|
||||
}
|
||||
};
|
||||
|
||||
if (showNotificationSheet) {
|
||||
const { id, account, notification, sameInstance } = showNotificationSheet;
|
||||
return (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet" tabIndex="-1">
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<b>Notification</b>
|
||||
</header>
|
||||
<main>
|
||||
{!sameInstance && (
|
||||
<p>This notification is from your other account.</p>
|
||||
)}
|
||||
<div
|
||||
class="notification-peek"
|
||||
// style={{
|
||||
// pointerEvents: sameInstance ? '' : 'none',
|
||||
// }}
|
||||
onClick={(e) => {
|
||||
const { target } = e;
|
||||
// If button or links
|
||||
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Notification
|
||||
instance={account.instanceURL}
|
||||
notification={notification}
|
||||
isStatic
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'end',
|
||||
}}
|
||||
>
|
||||
<Link to="/notifications" class="button light" onClick={onClose}>
|
||||
<span>View all notifications</span> <Icon icon="arrow-right" />
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
|
@ -1,7 +1,14 @@
|
|||
import states from '../utils/states';
|
||||
import { Fragment } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states, { statusKey } from '../utils/states';
|
||||
import store from '../utils/store';
|
||||
import { getCurrentAccountID } from '../utils/store-utils';
|
||||
import useTruncated from '../utils/useTruncated';
|
||||
|
||||
import Avatar from './avatar';
|
||||
import CustomEmoji from './custom-emoji';
|
||||
import FollowRequestButtons from './follow-request-buttons';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
|
@ -18,6 +25,12 @@ const NOTIFICATION_ICONS = {
|
|||
favourite: 'heart',
|
||||
poll: 'poll',
|
||||
update: 'pencil',
|
||||
'admin.signup': 'account-edit',
|
||||
'admin.report': 'account-warning',
|
||||
severed_relationships: 'heart-break',
|
||||
moderation_warning: 'alert',
|
||||
emoji_reaction: 'emoji2',
|
||||
'pleroma:emoji_reaction': 'emoji2',
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -33,63 +46,234 @@ poll = A poll you have voted in or created has ended
|
|||
update = A status you interacted with has been edited
|
||||
admin.sign_up = Someone signed up (optionally sent to admins)
|
||||
admin.report = A new report has been filed
|
||||
severed_relationships = Severed relationships
|
||||
moderation_warning = Moderation warning
|
||||
*/
|
||||
|
||||
function emojiText(emoji, emoji_url) {
|
||||
let url;
|
||||
let staticUrl;
|
||||
if (typeof emoji_url === 'string') {
|
||||
url = emoji_url;
|
||||
} else {
|
||||
url = emoji_url?.url;
|
||||
staticUrl = emoji_url?.staticUrl;
|
||||
}
|
||||
return url ? (
|
||||
<>
|
||||
reacted to your post with{' '}
|
||||
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
|
||||
</>
|
||||
) : (
|
||||
`reacted to your post with ${emoji}.`
|
||||
);
|
||||
}
|
||||
const contentText = {
|
||||
mention: 'mentioned you in their post.',
|
||||
status: 'published a post.',
|
||||
reblog: 'boosted your post.',
|
||||
'reblog+account': (count) => `boosted ${count} of your posts.`,
|
||||
reblog_reply: 'boosted your reply.',
|
||||
follow: 'followed you.',
|
||||
follow_request: 'requested to follow you.',
|
||||
favourite: 'favourited your post.',
|
||||
favourite: 'liked your post.',
|
||||
'favourite+account': (count) => `liked ${count} of your posts.`,
|
||||
favourite_reply: 'liked your reply.',
|
||||
poll: 'A poll you have voted in or created has ended.',
|
||||
'poll-self': 'A poll you have created has ended.',
|
||||
'poll-voted': 'A poll you have voted in has ended.',
|
||||
update: 'A post you interacted with has been edited.',
|
||||
'favourite+reblog': 'boosted & favourited your post.',
|
||||
'favourite+reblog': 'boosted & liked your post.',
|
||||
'favourite+reblog+account': (count) =>
|
||||
`boosted & liked ${count} of your posts.`,
|
||||
'favourite+reblog_reply': 'boosted & liked your reply.',
|
||||
'admin.sign_up': 'signed up.',
|
||||
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
|
||||
severed_relationships: (name) => (
|
||||
<>
|
||||
Lost connections with <i>{name}</i>.
|
||||
</>
|
||||
),
|
||||
moderation_warning: <b>Moderation warning</b>,
|
||||
emoji_reaction: emojiText,
|
||||
'pleroma:emoji_reaction': emojiText,
|
||||
};
|
||||
|
||||
function Notification({ notification, instance, reload }) {
|
||||
const { id, status, account, _accounts } = notification;
|
||||
// account_suspension, domain_block, user_domain_block
|
||||
const SEVERED_RELATIONSHIPS_TEXT = {
|
||||
account_suspension: ({ from, targetName }) => (
|
||||
<>
|
||||
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
|
||||
you can no longer receive updates from them or interact with them.
|
||||
</>
|
||||
),
|
||||
domain_block: ({ from, targetName, followersCount, followingCount }) => (
|
||||
<>
|
||||
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
|
||||
followers: {followersCount}, followings: {followingCount}.
|
||||
</>
|
||||
),
|
||||
user_domain_block: ({ targetName, followersCount, followingCount }) => (
|
||||
<>
|
||||
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
|
||||
followings: {followingCount}.
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
const MODERATION_WARNING_TEXT = {
|
||||
none: 'Your account has received a moderation warning.',
|
||||
disable: 'Your account has been disabled.',
|
||||
mark_statuses_as_sensitive:
|
||||
'Some of your posts have been marked as sensitive.',
|
||||
delete_statuses: 'Some of your posts have been deleted.',
|
||||
sensitive: 'Your posts will be marked as sensitive from now on.',
|
||||
silence: 'Your account has been limited.',
|
||||
suspend: 'Your account has been suspended.',
|
||||
};
|
||||
|
||||
const AVATARS_LIMIT = 30;
|
||||
|
||||
function Notification({
|
||||
notification,
|
||||
instance,
|
||||
isStatic,
|
||||
disableContextMenu,
|
||||
}) {
|
||||
const {
|
||||
id,
|
||||
status,
|
||||
account,
|
||||
report,
|
||||
event,
|
||||
moderation_warning,
|
||||
// Client-side grouped notification
|
||||
_ids,
|
||||
_accounts,
|
||||
_statuses,
|
||||
// Server-side grouped notification
|
||||
sampleAccounts,
|
||||
notificationsCount,
|
||||
} = notification;
|
||||
let { type } = notification;
|
||||
|
||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||
const actualStatusID = status?.reblog?.id || status?.id;
|
||||
const actualStatus = status?.reblog || status;
|
||||
const actualStatusID = actualStatus?.id;
|
||||
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
const currentAccount = getCurrentAccountID();
|
||||
const isSelf = currentAccount === account?.id;
|
||||
const isVoted = status?.poll?.voted;
|
||||
const isReplyToOthers =
|
||||
!!status?.inReplyToAccountId &&
|
||||
status?.inReplyToAccountId !== currentAccount &&
|
||||
status?.account?.id === currentAccount;
|
||||
|
||||
let favsCount = 0;
|
||||
let reblogsCount = 0;
|
||||
if (type === 'favourite+reblog') {
|
||||
for (const account of _accounts) {
|
||||
if (account._types?.includes('favourite')) {
|
||||
favsCount++;
|
||||
}
|
||||
if (account._types?.includes('reblog')) {
|
||||
reblogsCount++;
|
||||
if (_accounts) {
|
||||
for (const account of _accounts) {
|
||||
if (account._types?.includes('favourite')) {
|
||||
favsCount++;
|
||||
}
|
||||
if (account._types?.includes('reblog')) {
|
||||
reblogsCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!reblogsCount && favsCount) type = 'favourite';
|
||||
if (!favsCount && reblogsCount) type = 'reblog';
|
||||
}
|
||||
|
||||
const text =
|
||||
type === 'poll'
|
||||
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
|
||||
: contentText[type];
|
||||
let text;
|
||||
if (type === 'poll') {
|
||||
text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll'];
|
||||
} else if (
|
||||
type === 'reblog' ||
|
||||
type === 'favourite' ||
|
||||
type === 'favourite+reblog'
|
||||
) {
|
||||
if (_statuses?.length > 1) {
|
||||
text = contentText[`${type}+account`];
|
||||
} else if (isReplyToOthers) {
|
||||
text = contentText[`${type}_reply`];
|
||||
} else {
|
||||
text = contentText[type];
|
||||
}
|
||||
} else if (contentText[type]) {
|
||||
text = contentText[type];
|
||||
} else {
|
||||
// Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances
|
||||
// This surfaces the error to the user, hoping that users will report it
|
||||
text = `[Unknown notification type: ${type}]`;
|
||||
}
|
||||
|
||||
if (typeof text === 'function') {
|
||||
const count = _statuses?.length || _accounts?.length;
|
||||
if (type === 'admin.report') {
|
||||
const targetAccount = report?.targetAccount;
|
||||
if (targetAccount) {
|
||||
text = text(<NameText account={targetAccount} showAvatar />);
|
||||
}
|
||||
} else if (type === 'severed_relationships') {
|
||||
const targetName = event?.targetName;
|
||||
if (targetName) {
|
||||
text = text(targetName);
|
||||
}
|
||||
} else if (
|
||||
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
|
||||
notification.emoji
|
||||
) {
|
||||
const emojiURL =
|
||||
notification.emoji_url || // This is string
|
||||
status?.emojis?.find?.(
|
||||
(emoji) =>
|
||||
emoji?.shortcode ===
|
||||
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
|
||||
); // Emoji object instead of string
|
||||
text = text(notification.emoji, emojiURL);
|
||||
} else if (count) {
|
||||
text = text(count);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'mention' && !status) {
|
||||
// Could be deleted
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedCreatedAt =
|
||||
notification.createdAt && new Date(notification.createdAt).toLocaleString();
|
||||
|
||||
const genericAccountsHeading =
|
||||
{
|
||||
'favourite+reblog': 'Boosted/Liked by…',
|
||||
favourite: 'Liked by…',
|
||||
reblog: 'Boosted by…',
|
||||
follow: 'Followed by…',
|
||||
}[type] || 'Accounts';
|
||||
const handleOpenGenericAccounts = () => {
|
||||
states.showGenericAccounts = {
|
||||
heading: genericAccountsHeading,
|
||||
accounts: _accounts,
|
||||
showReactions: type === 'favourite+reblog',
|
||||
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
|
||||
postID: statusKey(actualStatusID, instance),
|
||||
};
|
||||
};
|
||||
|
||||
console.debug('RENDER Notification', notification.id);
|
||||
|
||||
return (
|
||||
<div class={`notification notification-${type}`} tabIndex="0">
|
||||
<div
|
||||
class={`notification notification-${type}`}
|
||||
data-notification-id={_ids || id}
|
||||
tabIndex="0"
|
||||
>
|
||||
<div
|
||||
class={`notification-type notification-${type}`}
|
||||
title={new Date(notification.createdAt).toLocaleString()}
|
||||
title={formattedCreatedAt}
|
||||
>
|
||||
{type === 'favourite+reblog' ? (
|
||||
<>
|
||||
|
@ -108,16 +292,32 @@ function Notification({ notification, instance, reload }) {
|
|||
{type !== 'mention' && (
|
||||
<>
|
||||
<p>
|
||||
{!/poll|update/i.test(type) && (
|
||||
{!/poll|update|severed_relationships/i.test(type) && (
|
||||
<>
|
||||
{_accounts?.length > 1 ? (
|
||||
<>
|
||||
<b>{_accounts.length} people</b>{' '}
|
||||
<b tabIndex="0" onClick={handleOpenGenericAccounts}>
|
||||
<span title={_accounts.length}>
|
||||
{shortenNumber(_accounts.length)}
|
||||
</span>{' '}
|
||||
people
|
||||
</b>{' '}
|
||||
</>
|
||||
) : notificationsCount > 1 ? (
|
||||
<>
|
||||
<b>
|
||||
<span title={notificationsCount}>
|
||||
{shortenNumber(notificationsCount)}
|
||||
</span>{' '}
|
||||
people
|
||||
</b>{' '}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NameText account={account} showAvatar />{' '}
|
||||
</>
|
||||
account && (
|
||||
<>
|
||||
<NameText account={account} showAvatar />{' '}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
@ -134,20 +334,47 @@ function Notification({ notification, instance, reload }) {
|
|||
)}
|
||||
</p>
|
||||
{type === 'follow_request' && (
|
||||
<FollowRequestButtons
|
||||
accountID={account.id}
|
||||
onChange={() => {
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
<FollowRequestButtons accountID={account.id} />
|
||||
)}
|
||||
{type === 'severed_relationships' && (
|
||||
<div>
|
||||
{SEVERED_RELATIONSHIPS_TEXT[event.type]({
|
||||
from: instance,
|
||||
...event,
|
||||
})}
|
||||
<br />
|
||||
<a
|
||||
href={`https://${instance}/severed_relationships`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
{type === 'moderation_warning' && !!moderation_warning && (
|
||||
<div>
|
||||
{MODERATION_WARNING_TEXT[moderation_warning.action]}
|
||||
<br />
|
||||
<a
|
||||
href={`/disputes/strikes/${moderation_warning.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more <Icon icon="external" size="s" />
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{_accounts?.length > 1 && (
|
||||
<p class="avatars-stack">
|
||||
{_accounts.map((account, i) => (
|
||||
<>
|
||||
{_accounts.slice(0, AVATARS_LIMIT).map((account) => (
|
||||
<Fragment key={account.id}>
|
||||
<a
|
||||
key={account.id}
|
||||
href={account.url}
|
||||
rel="noopener noreferrer"
|
||||
class="account-avatar-stack"
|
||||
|
@ -161,13 +388,9 @@ function Notification({ notification, instance, reload }) {
|
|||
size={
|
||||
_accounts.length <= 10
|
||||
? 'xxl'
|
||||
: _accounts.length < 100
|
||||
: _accounts.length < 20
|
||||
? 'xl'
|
||||
: _accounts.length < 1000
|
||||
? 'l'
|
||||
: _accounts.length < 2000
|
||||
? 'm'
|
||||
: 's' // My god, this person is popular!
|
||||
: 'l'
|
||||
}
|
||||
key={account.id}
|
||||
alt={`${account.displayName} @${account.acct}`}
|
||||
|
@ -185,25 +408,142 @@ function Notification({ notification, instance, reload }) {
|
|||
</div>
|
||||
)}
|
||||
</a>{' '}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
class="small plain"
|
||||
onClick={handleOpenGenericAccounts}
|
||||
>
|
||||
{_accounts.length > AVATARS_LIMIT &&
|
||||
`+${_accounts.length - AVATARS_LIMIT}`}
|
||||
<Icon icon="chevron-down" />
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{status && (
|
||||
<Link
|
||||
{!_accounts?.length && sampleAccounts?.length > 1 && (
|
||||
<p class="avatars-stack">
|
||||
{sampleAccounts.map((account) => (
|
||||
<Fragment key={account.id}>
|
||||
<a
|
||||
key={account.id}
|
||||
href={account.url}
|
||||
rel="noopener noreferrer"
|
||||
class="account-avatar-stack"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
states.showAccount = account;
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
url={account.avatarStatic}
|
||||
size="xxl"
|
||||
key={account.id}
|
||||
alt={`${account.displayName} @${account.acct}`}
|
||||
squircle={account?.bot}
|
||||
/>
|
||||
{/* {type === 'favourite+reblog' && (
|
||||
<div class="account-sub-icons">
|
||||
{account._types.map((type) => (
|
||||
<Icon
|
||||
icon={NOTIFICATION_ICONS[type]}
|
||||
size="s"
|
||||
class={`${type}-icon`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)} */}
|
||||
</a>{' '}
|
||||
</Fragment>
|
||||
))}
|
||||
{notificationsCount > sampleAccounts.length && (
|
||||
<Link
|
||||
to={
|
||||
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
|
||||
}
|
||||
class="button small plain centered"
|
||||
>
|
||||
+{notificationsCount - sampleAccounts.length}
|
||||
<Icon icon="chevron-right" />
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{_statuses?.length > 1 && (
|
||||
<ul class="notification-group-statuses">
|
||||
{_statuses.map((status) => (
|
||||
<li key={status.id}>
|
||||
<TruncatedLink
|
||||
class={`status-link status-type-${type}`}
|
||||
to={
|
||||
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
|
||||
}
|
||||
>
|
||||
<Status
|
||||
status={status}
|
||||
size="s"
|
||||
previewMode
|
||||
allowContextMenu
|
||||
/>
|
||||
</TruncatedLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status && (!_statuses?.length || _statuses?.length <= 1) && (
|
||||
<TruncatedLink
|
||||
class={`status-link status-type-${type}`}
|
||||
to={
|
||||
instance
|
||||
? `/${instance}/s/${actualStatusID}`
|
||||
: `/s/${actualStatusID}`
|
||||
}
|
||||
onContextMenu={
|
||||
!disableContextMenu
|
||||
? (e) => {
|
||||
const post = e.target.querySelector('.status');
|
||||
if (post) {
|
||||
// Fire a custom event to open the context menu
|
||||
if (e.metaKey) return;
|
||||
e.preventDefault();
|
||||
post.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Status statusID={actualStatusID} size="s" />
|
||||
</Link>
|
||||
{isStatic ? (
|
||||
<Status
|
||||
status={actualStatus}
|
||||
size="s"
|
||||
readOnly
|
||||
allowContextMenu
|
||||
/>
|
||||
) : (
|
||||
<Status
|
||||
statusID={actualStatusID}
|
||||
size="s"
|
||||
readOnly
|
||||
allowContextMenu
|
||||
/>
|
||||
)}
|
||||
</TruncatedLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notification;
|
||||
function TruncatedLink(props) {
|
||||
const ref = useTruncated();
|
||||
return <Link {...props} data-read-more="Read more →" ref={ref} />;
|
||||
}
|
||||
|
||||
export default memo(Notification, (oldProps, newProps) => {
|
||||
return oldProps.notification?.id === newProps.notification?.id;
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
|
||||
|
@ -62,29 +62,13 @@ export default function Poll({
|
|||
const [showResults, setShowResults] = useState(false);
|
||||
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
|
||||
|
||||
const pollRef = useRef();
|
||||
useEffect(() => {
|
||||
const handleSwipe = () => {
|
||||
console.log('swiped left');
|
||||
setShowResults(!showResults);
|
||||
};
|
||||
pollRef.current?.addEventListener?.('swiped-left', handleSwipe);
|
||||
return () => {
|
||||
pollRef.current?.removeEventListener?.('swiped-left', handleSwipe);
|
||||
};
|
||||
}, [showResults]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pollRef}
|
||||
lang={lang}
|
||||
dir="auto"
|
||||
class={`poll ${readOnly ? 'read-only' : ''} ${
|
||||
uiState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
onDblClick={() => {
|
||||
setShowResults(!showResults);
|
||||
}}
|
||||
>
|
||||
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
|
||||
<>
|
||||
|
@ -138,11 +122,12 @@ export default function Poll({
|
|||
<button
|
||||
class="poll-vote-button plain2"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowResults(false);
|
||||
}}
|
||||
>
|
||||
<Icon icon="arrow-left" /> Hide results
|
||||
<Icon icon="arrow-left" size="s" /> Hide results
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
@ -196,44 +181,59 @@ export default function Poll({
|
|||
)}
|
||||
</form>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<p class="poll-meta">
|
||||
{!expired && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class="textual"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setUIState('loading');
|
||||
<p class="poll-meta">
|
||||
{!expired && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setUIState('loading');
|
||||
|
||||
(async () => {
|
||||
await refresh();
|
||||
setUIState('default');
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>{' '}
|
||||
•{' '}
|
||||
</>
|
||||
)}
|
||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
||||
{votesCount === 1 ? '' : 's'}
|
||||
{!!votersCount && votersCount !== votesCount && (
|
||||
<>
|
||||
{' '}
|
||||
•{' '}
|
||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||
voter
|
||||
{votersCount === 1 ? '' : 's'}
|
||||
</>
|
||||
)}{' '}
|
||||
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
|
||||
</p>
|
||||
)}
|
||||
(async () => {
|
||||
await refresh();
|
||||
setUIState('default');
|
||||
})();
|
||||
}}
|
||||
title="Refresh"
|
||||
>
|
||||
<Icon icon="refresh" alt="Refresh" />
|
||||
</button>
|
||||
)}
|
||||
{!voted && !expired && !readOnly && optionsHaveVoteCounts && (
|
||||
<button
|
||||
type="button"
|
||||
class="plain small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowResults(!showResults);
|
||||
}}
|
||||
title={showResults ? 'Hide results' : 'Show results'}
|
||||
>
|
||||
<Icon
|
||||
icon={showResults ? 'eye-open' : 'eye-close'}
|
||||
alt={showResults ? 'Hide results' : 'Show results'}
|
||||
/>{' '}
|
||||
</button>
|
||||
)}
|
||||
{!expired && !readOnly && ' '}
|
||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
||||
{votesCount === 1 ? '' : 's'}
|
||||
{!!votersCount && votersCount !== votesCount && (
|
||||
<>
|
||||
{' '}
|
||||
• <span title={votersCount}>
|
||||
{shortenNumber(votersCount)}
|
||||
</span>{' '}
|
||||
voter
|
||||
{votersCount === 1 ? '' : 's'}
|
||||
</>
|
||||
)}{' '}
|
||||
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
|
||||
</p>{' '}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import dayjs from 'dayjs';
|
|||
import dayjsTwitter from 'dayjs-twitter';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||
|
||||
dayjs.extend(dayjsTwitter);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
@ -18,40 +18,54 @@ const dtf = new Intl.DateTimeFormat();
|
|||
|
||||
export default function RelativeTime({ datetime, format }) {
|
||||
if (!datetime) return null;
|
||||
const date = dayjs(datetime);
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
|
||||
const date = useMemo(() => dayjs(datetime), [datetime]);
|
||||
const [dateStr, dt, title] = useMemo(() => {
|
||||
if (!date.isValid()) return ['' + datetime, '', ''];
|
||||
let str;
|
||||
if (format === 'micro') {
|
||||
// If date <= 1 day ago or day is within this year
|
||||
const now = dayjs();
|
||||
const dayDiff = now.diff(date, 'day');
|
||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||
str = date.twitter();
|
||||
} else {
|
||||
str = dtf.format(date.toDate());
|
||||
}
|
||||
}
|
||||
if (!str) str = date.fromNow();
|
||||
return [str, date.toISOString(), date.format('LLLL')];
|
||||
}, [date, format, renderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer, raf;
|
||||
const update = () => {
|
||||
if (!date.isValid()) return;
|
||||
let timeout;
|
||||
let raf;
|
||||
function rafRerender() {
|
||||
raf = requestAnimationFrame(() => {
|
||||
let str;
|
||||
if (format === 'micro') {
|
||||
// If date <= 1 day ago or day is within this year
|
||||
const now = dayjs();
|
||||
const dayDiff = now.diff(date, 'day');
|
||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||
str = date.twitter();
|
||||
} else {
|
||||
str = dtf.format(date.toDate());
|
||||
}
|
||||
} else {
|
||||
str = date.fromNow();
|
||||
}
|
||||
setDateStr(str);
|
||||
|
||||
timer = setTimeout(update, 30_000);
|
||||
rerender();
|
||||
scheduleRerender();
|
||||
});
|
||||
};
|
||||
raf = requestAnimationFrame(update);
|
||||
}
|
||||
function scheduleRerender() {
|
||||
// If less than 1 minute, rerender every 10s
|
||||
// If less than 1 hour rerender every 1m
|
||||
// Else, don't need to rerender
|
||||
if (date.diff(dayjs(), 'minute', true) < 1) {
|
||||
timeout = setTimeout(rafRerender, 10_000);
|
||||
} else if (date.diff(dayjs(), 'hour', true) < 1) {
|
||||
timeout = setTimeout(rafRerender, 60_000);
|
||||
}
|
||||
}
|
||||
scheduleRerender();
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(timeout);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [date]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<time datetime={date.toISOString()} title={date.format('LLLL')}>
|
||||
<time datetime={dt} title={title}>
|
||||
{dateStr}
|
||||
</time>
|
||||
);
|
||||
|
|
200
src/components/report-modal.css
Normal file
200
src/components/report-modal.css
Normal file
|
@ -0,0 +1,200 @@
|
|||
.report-modal-container {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 40em;
|
||||
background-color: var(--bg-color);
|
||||
box-shadow: 0 16px 32px -8px var(--drop-shadow-color);
|
||||
overflow-y: auto;
|
||||
animation: slide-up-smooth 0.3s ease-in-out;
|
||||
position: relative;
|
||||
|
||||
@media (min-width: 40em) {
|
||||
max-height: calc(100% - 32px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.top-controls {
|
||||
position: sticky;
|
||||
top: var(--sai-top, 0);
|
||||
z-index: 1;
|
||||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 16px;
|
||||
padding: calc(var(--sai-top, 0) + 16px) calc(var(--sai-right, 0) + 16px)
|
||||
16px calc(var(--sai-left, 0) + 16px);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
pointer-events: auto;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 calc(var(--sai-right, 0) + 16px)
|
||||
calc(var(--sai-bottom, 0) + 16px) calc(var(--sai-left, 0) + 16px);
|
||||
/* display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px; */
|
||||
}
|
||||
|
||||
form {
|
||||
/* display: flex; */
|
||||
/* flex-direction: column; */
|
||||
/* gap: 16px; */
|
||||
text-wrap: pretty;
|
||||
|
||||
input {
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.report-preview {
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
border: 2px dashed var(--red-color);
|
||||
box-shadow: inset 0 0 16px -4px var(--red-bg-color);
|
||||
overflow: auto;
|
||||
max-height: 33vh;
|
||||
|
||||
.status {
|
||||
font-size: 90%;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.account-block {
|
||||
margin: 16px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.rubber-stamp {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
inset-inline-end: 32px;
|
||||
margin-top: -48px;
|
||||
animation: rubber-stamp 0.3s ease-in both;
|
||||
position: absolute;
|
||||
font-weight: bold;
|
||||
color: var(--red-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.5px;
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
padding: 0.1em;
|
||||
border: 0.15em solid var(--red-color);
|
||||
border-radius: 0.3em;
|
||||
background-color: var(--bg-blur-color);
|
||||
text-align: center;
|
||||
/* Noise pattern - https://css-tricks.com/making-static-noise-from-a-weird-css-gradient-bug/ */
|
||||
mask-image: repeating-conic-gradient(
|
||||
#000 0 0.01%,
|
||||
rgba(0, 0, 0, 0.45) 0 0.02%
|
||||
);
|
||||
|
||||
small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block: 0.5em;
|
||||
}
|
||||
|
||||
section {
|
||||
label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:has(:checked) {
|
||||
.insignificant {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
> label:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.report-categories {
|
||||
label {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.report-rules {
|
||||
margin-inline-start: 1.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.report-comment {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin-top: 2em;
|
||||
flex-wrap: wrap;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex-grow: 1;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2em;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
border-radius: 8px !important;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rubber-stamp {
|
||||
0% {
|
||||
transform: rotate(-20deg) scale(5);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(-20deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
298
src/components/report-modal.jsx
Normal file
298
src/components/report-modal.jsx
Normal file
|
@ -0,0 +1,298 @@
|
|||
import './report-modal.css';
|
||||
|
||||
import { Fragment } from 'preact';
|
||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import showToast from '../utils/show-toast';
|
||||
import { getCurrentInstance } from '../utils/store-utils';
|
||||
|
||||
import AccountBlock from './account-block';
|
||||
import Icon from './icon';
|
||||
import Loader from './loader';
|
||||
import Status from './status';
|
||||
|
||||
// NOTE: `dislike` hidden for now, it's actually not used for reporting
|
||||
// Mastodon shows another screen for unfollowing, muting or blocking instead of reporting
|
||||
|
||||
const CATEGORIES = [, /*'dislike'*/ 'spam', 'legal', 'violation', 'other'];
|
||||
// `violation` will be set if there are `rule_ids[]`
|
||||
|
||||
const CATEGORIES_INFO = {
|
||||
// dislike: {
|
||||
// label: 'Dislike',
|
||||
// description: 'Not something you want to see',
|
||||
// },
|
||||
spam: {
|
||||
label: 'Spam',
|
||||
description: 'Malicious links, fake engagement, or repetitive replies',
|
||||
},
|
||||
legal: {
|
||||
label: 'Illegal',
|
||||
description: "Violates the law of your or the server's country",
|
||||
},
|
||||
violation: {
|
||||
label: 'Server rule violation',
|
||||
description: 'Breaks specific server rules',
|
||||
stampLabel: 'Violation',
|
||||
},
|
||||
other: {
|
||||
label: 'Other',
|
||||
description: "Issue doesn't fit other categories",
|
||||
excludeStamp: true,
|
||||
},
|
||||
};
|
||||
|
||||
function ReportModal({ account, post, onClose }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [username, domain] = account.acct.split('@');
|
||||
|
||||
const [rules, currentDomain] = useMemo(() => {
|
||||
const { rules, domain } = getCurrentInstance();
|
||||
return [rules || [], domain];
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
const rulesRef = useRef(null);
|
||||
const [hasRules, setHasRules] = useState(false);
|
||||
|
||||
return (
|
||||
<div class="report-modal-container">
|
||||
<div class="top-controls">
|
||||
<h1>{post ? 'Report Post' : `Report @${username}`}</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="plain4 small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Icon icon="x" size="xl" />
|
||||
</button>
|
||||
</div>
|
||||
<main>
|
||||
<div class="report-preview">
|
||||
{post ? (
|
||||
<Status status={post} size="s" previewMode />
|
||||
) : (
|
||||
<AccountBlock
|
||||
account={account}
|
||||
avatarSize="xxl"
|
||||
useAvatarStatic
|
||||
showStats
|
||||
showActivity
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!selectedCategory &&
|
||||
!CATEGORIES_INFO[selectedCategory].excludeStamp && (
|
||||
<span
|
||||
class="rubber-stamp"
|
||||
key={selectedCategory}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{CATEGORIES_INFO[selectedCategory].stampLabel ||
|
||||
CATEGORIES_INFO[selectedCategory].label}
|
||||
<small>Pending review</small>
|
||||
</span>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const entries = Object.fromEntries(formData.entries());
|
||||
console.log('ENTRIES', entries);
|
||||
|
||||
let { category, comment, forward } = entries;
|
||||
if (!comment) comment = undefined;
|
||||
if (forward === 'on') forward = true;
|
||||
const ruleIds =
|
||||
category === 'violation'
|
||||
? Object.entries(entries)
|
||||
.filter(([key]) => key.startsWith('rule_ids'))
|
||||
.map(([key, value]) => value)
|
||||
: undefined;
|
||||
|
||||
const params = {
|
||||
category,
|
||||
comment,
|
||||
forward,
|
||||
ruleIds,
|
||||
};
|
||||
console.log('PARAMS', params);
|
||||
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
await masto.v1.reports.create({
|
||||
accountId: account.id,
|
||||
statusIds: post?.id ? [post.id] : undefined,
|
||||
category,
|
||||
comment,
|
||||
ruleIds,
|
||||
forward,
|
||||
});
|
||||
setUIState('success');
|
||||
showToast(post ? 'Post reported' : 'Profile reported');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUIState('error');
|
||||
showToast(
|
||||
error?.message ||
|
||||
(post
|
||||
? 'Unable to report post'
|
||||
: 'Unable to report profile'),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
{post
|
||||
? `What's the issue with this post?`
|
||||
: `What's the issue with this profile?`}
|
||||
</p>
|
||||
<section class="report-categories">
|
||||
{CATEGORIES.map((category) =>
|
||||
category === 'violation' && !rules?.length ? null : (
|
||||
<Fragment key={category}>
|
||||
<label class="report-category">
|
||||
<input
|
||||
type="radio"
|
||||
name="category"
|
||||
value={category}
|
||||
required
|
||||
disabled={uiState === 'loading'}
|
||||
onChange={(e) => {
|
||||
setSelectedCategory(e.target.value);
|
||||
setShowRules(e.target.value === 'violation');
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{CATEGORIES_INFO[category].label}
|
||||
<small class="ib insignificant">
|
||||
{CATEGORIES_INFO[category].description}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
{category === 'violation' && !!rules?.length && (
|
||||
<div
|
||||
class="shazam-container no-animation"
|
||||
hidden={!showRules}
|
||||
>
|
||||
<div class="shazam-container-inner">
|
||||
<div class="report-rules" ref={rulesRef}>
|
||||
{rules.map((rule, i) => (
|
||||
<label class="report-rule" key={rule.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`rule_ids[${i}]`}
|
||||
value={rule.id}
|
||||
required={showRules && !hasRules}
|
||||
disabled={uiState === 'loading'}
|
||||
onChange={(e) => {
|
||||
const { checked } = e.target;
|
||||
if (checked) {
|
||||
setHasRules(true);
|
||||
} else {
|
||||
const checkedInputs =
|
||||
rulesRef.current.querySelectorAll(
|
||||
'input:checked',
|
||||
);
|
||||
if (!checkedInputs.length) {
|
||||
setHasRules(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{rule.text}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
<section class="report-comment">
|
||||
<p>
|
||||
<label for="report-comment">Additional info</label>
|
||||
</p>
|
||||
<textarea
|
||||
maxlength="1000"
|
||||
rows="1"
|
||||
name="comment"
|
||||
id="report-comment"
|
||||
disabled={uiState === 'loading'}
|
||||
/>
|
||||
</section>
|
||||
{!!domain && domain !== currentDomain && (
|
||||
<section>
|
||||
<p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
name="forward"
|
||||
disabled={uiState === 'loading'}
|
||||
/>{' '}
|
||||
<span>
|
||||
Forward to <i>{domain}</i>
|
||||
</span>
|
||||
</label>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
<footer>
|
||||
<button type="submit" disabled={uiState === 'loading'}>
|
||||
Send Report
|
||||
</button>{' '}
|
||||
<button
|
||||
type="submit"
|
||||
class="plain2"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
|
||||
showToast(`Muted ${username}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(`Unable to mute ${username}`);
|
||||
}
|
||||
// onSubmit will still run
|
||||
}}
|
||||
>
|
||||
Send Report <small class="ib">+ Mute profile</small>
|
||||
</button>{' '}
|
||||
<button
|
||||
type="submit"
|
||||
class="plain2"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await masto.v1.accounts.$select(account.id).block();
|
||||
showToast(`Blocked ${username}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(`Unable to block ${username}`);
|
||||
}
|
||||
// onSubmit will still run
|
||||
}}
|
||||
>
|
||||
Send Report <small class="ib">+ Block profile</small>
|
||||
</button>
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportModal;
|
54
src/components/search-command.css
Normal file
54
src/components/search-command.css
Normal file
|
@ -0,0 +1,54 @@
|
|||
#search-command-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1002;
|
||||
background-color: var(--backdrop-darker-color);
|
||||
background-image: radial-gradient(
|
||||
farthest-corner at top,
|
||||
var(--backdrop-color),
|
||||
transparent
|
||||
);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
#search-command-container[hidden] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#search-command-container form {
|
||||
width: calc(40em - 32px);
|
||||
max-width: 100%;
|
||||
transition: transform 0.1s ease-in-out;
|
||||
}
|
||||
#search-command-container[hidden] form {
|
||||
transform: translateY(-64px) scale(0.9);
|
||||
}
|
||||
#search-command-container input {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-faded-color);
|
||||
border: 2px solid var(--outline-color);
|
||||
box-shadow: 0 2px 16px var(--drop-shadow-color),
|
||||
0 32px 64px var(--drop-shadow-color);
|
||||
}
|
||||
#search-command-container input:focus {
|
||||
outline: 0;
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--link-color);
|
||||
}
|
||||
|
||||
@media (min-width: 40em) {
|
||||
#search-command-container {
|
||||
align-items: center;
|
||||
background-image: radial-gradient(
|
||||
closest-side,
|
||||
var(--backdrop-color),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
69
src/components/search-command.jsx
Normal file
69
src/components/search-command.jsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import './search-command.css';
|
||||
|
||||
import { memo } from 'preact/compat';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import SearchForm from './search-form';
|
||||
|
||||
export default memo(function SearchCommand({ onClose = () => {} }) {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const searchFormRef = useRef(null);
|
||||
|
||||
useHotkeys(
|
||||
['Slash', '/'],
|
||||
(e) => {
|
||||
setShowSearch(true);
|
||||
setTimeout(() => {
|
||||
searchFormRef.current?.focus?.();
|
||||
searchFormRef.current?.select?.();
|
||||
}, 0);
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
ignoreEventWhen: (e) => {
|
||||
const isSearchPage = /\/search/.test(location.hash);
|
||||
const hasModal = !!document.querySelector('#modal-container > *');
|
||||
return isSearchPage || hasModal;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const closeSearch = () => {
|
||||
setShowSearch(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
(e) => {
|
||||
searchFormRef.current?.blur?.();
|
||||
closeSearch();
|
||||
},
|
||||
{
|
||||
enabled: showSearch,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="search-command-container"
|
||||
hidden={!showSearch}
|
||||
onClick={(e) => {
|
||||
console.log(e);
|
||||
if (e.target === e.currentTarget) {
|
||||
closeSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchForm
|
||||
ref={searchFormRef}
|
||||
onSubmit={() => {
|
||||
closeSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
292
src/components/search-form.jsx
Normal file
292
src/components/search-form.jsx
Normal file
|
@ -0,0 +1,292 @@
|
|||
import { forwardRef } from 'preact/compat';
|
||||
import { useImperativeHandle, useRef, useState } from 'preact/hooks';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
|
||||
const SearchForm = forwardRef((props, ref) => {
|
||||
const { instance } = api();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
|
||||
const [query, setQuery] = useState(searchParams.get('q') || '');
|
||||
const type = searchParams.get('type');
|
||||
const formRef = useRef(null);
|
||||
|
||||
const searchFieldRef = useRef(null);
|
||||
useImperativeHandle(ref, () => ({
|
||||
setValue: (value) => {
|
||||
setQuery(value);
|
||||
},
|
||||
focus: () => {
|
||||
searchFieldRef.current.focus();
|
||||
},
|
||||
select: () => {
|
||||
searchFieldRef.current.select();
|
||||
},
|
||||
blur: () => {
|
||||
searchFieldRef.current.blur();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
class="search-popover-container"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isSearchPage = /\/search/.test(location.hash);
|
||||
if (isSearchPage) {
|
||||
if (query) {
|
||||
const params = {
|
||||
q: query,
|
||||
};
|
||||
if (type) params.type = type; // Preserve type
|
||||
setSearchParams(params);
|
||||
} else {
|
||||
setSearchParams({});
|
||||
}
|
||||
} else {
|
||||
if (query) {
|
||||
location.hash = `/search?q=${encodeURIComponent(query)}${
|
||||
type ? `&type=${type}` : ''
|
||||
}`;
|
||||
} else {
|
||||
location.hash = `/search`;
|
||||
}
|
||||
}
|
||||
|
||||
props?.onSubmit?.(e);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={searchFieldRef}
|
||||
value={query}
|
||||
name="q"
|
||||
type="search"
|
||||
// autofocus
|
||||
placeholder="Search"
|
||||
dir="auto"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellCheck="false"
|
||||
onSearch={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSearchParams({});
|
||||
}
|
||||
}}
|
||||
onInput={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSearchMenuOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSearchMenuOpen(true);
|
||||
formRef.current
|
||||
?.querySelector('.search-popover-item')
|
||||
?.classList.add('focus');
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
setSearchMenuOpen(false);
|
||||
}, 100);
|
||||
formRef.current
|
||||
?.querySelector('.search-popover-item.focus')
|
||||
?.classList.remove('focus');
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const { key } = e;
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
setSearchMenuOpen(false);
|
||||
break;
|
||||
case 'Down':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (searchMenuOpen) {
|
||||
const focusItem = formRef.current.querySelector(
|
||||
'.search-popover-item.focus',
|
||||
);
|
||||
if (focusItem) {
|
||||
let nextItem = focusItem.nextElementSibling;
|
||||
while (nextItem && nextItem.hidden) {
|
||||
nextItem = nextItem.nextElementSibling;
|
||||
}
|
||||
if (nextItem) {
|
||||
nextItem.classList.add('focus');
|
||||
const siblings = Array.from(
|
||||
nextItem.parentElement.children,
|
||||
).filter((el) => el !== nextItem);
|
||||
siblings.forEach((el) => {
|
||||
el.classList.remove('focus');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const firstItem = formRef.current.querySelector(
|
||||
'.search-popover-item',
|
||||
);
|
||||
if (firstItem) {
|
||||
firstItem.classList.add('focus');
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Up':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (searchMenuOpen) {
|
||||
const focusItem = document.querySelector(
|
||||
'.search-popover-item.focus',
|
||||
);
|
||||
if (focusItem) {
|
||||
let prevItem = focusItem.previousElementSibling;
|
||||
while (prevItem && prevItem.hidden) {
|
||||
prevItem = prevItem.previousElementSibling;
|
||||
}
|
||||
if (prevItem) {
|
||||
prevItem.classList.add('focus');
|
||||
const siblings = Array.from(
|
||||
prevItem.parentElement.children,
|
||||
).filter((el) => el !== prevItem);
|
||||
siblings.forEach((el) => {
|
||||
el.classList.remove('focus');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const lastItem = document.querySelector(
|
||||
'.search-popover-item:last-child',
|
||||
);
|
||||
if (lastItem) {
|
||||
lastItem.classList.add('focus');
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchMenuOpen) {
|
||||
const focusItem = document.querySelector(
|
||||
'.search-popover-item.focus',
|
||||
);
|
||||
if (focusItem) {
|
||||
e.preventDefault();
|
||||
focusItem.click();
|
||||
}
|
||||
setSearchMenuOpen(false);
|
||||
props?.onSubmit?.(e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="search-popover" hidden={!searchMenuOpen || !query}>
|
||||
{/* {!!query && (
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(query)}`}
|
||||
class="search-popover-item focus"
|
||||
onClick={(e) => {
|
||||
props?.onSubmit?.(e);
|
||||
}}
|
||||
>
|
||||
<Icon icon="search" />
|
||||
<span>{query}</span>
|
||||
</Link>
|
||||
)} */}
|
||||
{!!query &&
|
||||
[
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
{query}{' '}
|
||||
<small class="insignificant">
|
||||
‒ accounts, hashtags & posts
|
||||
</small>
|
||||
</>
|
||||
),
|
||||
to: `/search?q=${encodeURIComponent(query)}`,
|
||||
top: !type && !/\s/.test(query),
|
||||
hidden: !!type,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Posts with <q>{query}</q>
|
||||
</>
|
||||
),
|
||||
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
|
||||
hidden: /^https?:/.test(query),
|
||||
top: /\s/.test(query),
|
||||
icon: 'document',
|
||||
queryType: 'statuses',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
|
||||
</>
|
||||
),
|
||||
to: `/${instance}/t/${query.replace(/^#/, '')}`,
|
||||
hidden:
|
||||
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
|
||||
top: /^#/.test(query),
|
||||
type: 'link',
|
||||
icon: 'hashtag',
|
||||
queryType: 'hashtags',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Look up <mark>{query}</mark>
|
||||
</>
|
||||
),
|
||||
to: `/${query}`,
|
||||
hidden: !/^https?:/.test(query),
|
||||
top: /^https?:/.test(query),
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Accounts with <q>{query}</q>
|
||||
</>
|
||||
),
|
||||
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
|
||||
icon: 'group',
|
||||
queryType: 'accounts',
|
||||
},
|
||||
]
|
||||
.sort((a, b) => {
|
||||
if (type) {
|
||||
if (a.queryType === type) return -1;
|
||||
if (b.queryType === type) return 1;
|
||||
}
|
||||
if (a.top && !b.top) return -1;
|
||||
if (!a.top && b.top) return 1;
|
||||
return 0;
|
||||
})
|
||||
.filter(({ hidden }) => !hidden)
|
||||
.map(({ label, to, icon, type }, i) => (
|
||||
<Link
|
||||
to={to}
|
||||
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
|
||||
// hidden={hidden}
|
||||
onClick={(e) => {
|
||||
console.log('onClick', e);
|
||||
props?.onSubmit?.(e);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={icon || (type === 'link' ? 'arrow-right' : 'search')}
|
||||
class="more-insignificant"
|
||||
/>
|
||||
<span>{label}</span>{' '}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
export default SearchForm;
|
|
@ -18,10 +18,11 @@
|
|||
counter-increment: index;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: right;
|
||||
margin-right: 8px;
|
||||
text-align: end;
|
||||
margin-inline-end: 8px;
|
||||
color: var(--text-insignificant-color);
|
||||
font-size: 90%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-list li .shortcut-text {
|
||||
flex-grow: 1;
|
||||
|
@ -35,7 +36,7 @@
|
|||
|
||||
#shortcuts-settings-container .shortcuts-view-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
@ -51,14 +52,15 @@
|
|||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
|
||||
border-top-left-radius: 16px;
|
||||
border-bottom-left-radius: 16px;
|
||||
border-start-start-radius: 16px;
|
||||
border-end-start-radius: 16px;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label:last-child {
|
||||
border-top-right-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
border-start-end-radius: 16px;
|
||||
border-end-end-radius: 16px;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label img {
|
||||
max-height: 64px;
|
||||
|
@ -84,8 +86,9 @@
|
|||
transform: scale(0.975);
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) {
|
||||
box-shadow: inset 0 0 0 3px var(--link-color);
|
||||
#shortcuts-settings-container .shortcuts-view-mode label.checked {
|
||||
box-shadow: inset 0 0 0 3px var(--link-color),
|
||||
inset 0 0 32px var(--link-faded-color);
|
||||
}
|
||||
#shortcuts-settings-container
|
||||
.shortcuts-view-mode
|
||||
|
@ -111,7 +114,7 @@
|
|||
}
|
||||
#shortcut-settings-form label > span:first-child {
|
||||
flex-basis: 5em;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
#shortcut-settings-form :is(input[type='text'], select) {
|
||||
flex-grow: 1;
|
||||
|
@ -121,7 +124,81 @@
|
|||
min-width: 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
#shortcut-settings-form .form-note {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
#shortcut-settings-form form footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Import/Export */
|
||||
|
||||
#import-export-container input[type='text'] {
|
||||
font-family: var(--monospace-font);
|
||||
}
|
||||
#import-export-container section {
|
||||
margin: 8px 0;
|
||||
background-color: var(--bg-faded-color);
|
||||
border-radius: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
#import-export-container section h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
#import-export-container section h3 * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
#import-export-container section p {
|
||||
margin: 8px 0;
|
||||
|
||||
&.field-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
#import-export-container section details > summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
#import-export-container .import-settings-list {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
counter-reset: index;
|
||||
}
|
||||
#import-export-container .import-settings-list li {
|
||||
background-color: var(--bg-blur-color);
|
||||
margin: 0 0 2px;
|
||||
padding: 8px 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
#import-export-container .import-settings-list li::before {
|
||||
content: counter(index);
|
||||
counter-increment: index;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: end;
|
||||
margin-inline-end: 8px;
|
||||
color: var(--text-insignificant-color);
|
||||
font-size: 90%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#import-export-container {
|
||||
footer {
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
|
||||
.icon {
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,8 +2,8 @@
|
|||
position: fixed;
|
||||
bottom: 16px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
left: 16px;
|
||||
left: max(16px, env(safe-area-inset-left));
|
||||
inset-inline-start: 16px;
|
||||
inset-inline-start: max(16px, env(safe-area-inset-left));
|
||||
padding: 16px;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
z-index: 101;
|
||||
|
@ -34,9 +34,9 @@
|
|||
|
||||
@media (min-width: calc(40em + 56px + 8px)) {
|
||||
#shortcuts-button {
|
||||
right: 16px;
|
||||
right: max(16px, env(safe-area-inset-right));
|
||||
left: auto;
|
||||
inset-inline-end: 16px;
|
||||
inset-inline-end: max(16px, env(safe-area-inset-right));
|
||||
inset-inline-start: auto;
|
||||
top: 16px;
|
||||
top: max(16px, env(safe-area-inset-top));
|
||||
bottom: auto;
|
||||
|
@ -59,8 +59,9 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(16px) saturate(3);
|
||||
background-color: var(--bg-color);
|
||||
/* background-color: var(--bg-blur-color);
|
||||
backdrop-filter: blur(16px) saturate(3); */
|
||||
border-top: var(--hairline-width) solid var(--outline-color);
|
||||
box-shadow: 0 -8px 16px -8px var(--drop-shadow-color);
|
||||
overflow: auto;
|
||||
|
@ -82,6 +83,8 @@
|
|||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 20vw;
|
||||
flex-basis: 20vw;
|
||||
}
|
||||
#shortcuts .tab-bar li a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
@ -95,7 +98,13 @@
|
|||
padding: 8px;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 var(--hairline-width) var(--bg-color);
|
||||
width: 20vw;
|
||||
width: 100%;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:is(:hover, :focus) {
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
#shortcuts .tab-bar li a:active {
|
||||
transform: scale(0.95);
|
||||
|
@ -144,7 +153,7 @@ shortcuts .tab-bar[hidden] {
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 40em) {
|
||||
@media (min-width: 40em) and (hover: hover) {
|
||||
#app[data-shortcuts-view-mode='tab-menu-bar'] .timeline-deck {
|
||||
margin-top: 44px;
|
||||
}
|
||||
|
@ -157,6 +166,7 @@ shortcuts .tab-bar[hidden] {
|
|||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
||||
env(safe-area-inset-left);
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-bottom: var(--hairline-width) solid var(--bg-faded-color);
|
||||
|
@ -171,6 +181,8 @@ shortcuts .tab-bar[hidden] {
|
|||
}
|
||||
#shortcuts .tab-bar li {
|
||||
flex-grow: 0;
|
||||
min-width: auto;
|
||||
flex-basis: auto;
|
||||
}
|
||||
#shortcuts .tab-bar li a {
|
||||
padding: 0 16px;
|
||||
|
|
|
@ -1,72 +1,83 @@
|
|||
import './shortcuts.css';
|
||||
|
||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { useMemo, useRef } from 'preact/hooks';
|
||||
import { MenuDivider } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
||||
import { api } from '../utils/api';
|
||||
import { getLists } from '../utils/lists';
|
||||
import states from '../utils/states';
|
||||
|
||||
import AsyncText from './AsyncText';
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import MenuLink from './menu-link';
|
||||
import Menu2 from './menu2';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function Shortcuts() {
|
||||
const { instance } = api();
|
||||
const snapStates = useSnapshot(states);
|
||||
const { shortcuts } = snapStates;
|
||||
const { shortcuts, settings } = snapStates;
|
||||
|
||||
if (!shortcuts.length) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
settings.shortcutsViewMode === 'multi-column' ||
|
||||
(!settings.shortcutsViewMode && settings.shortcutsColumnsMode)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const menuRef = useRef();
|
||||
|
||||
const formattedShortcuts = useMemo(
|
||||
() =>
|
||||
shortcuts
|
||||
.map((pin, i) => {
|
||||
const { type, ...data } = pin;
|
||||
if (!SHORTCUTS_META[type]) return null;
|
||||
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
||||
const hasLists = useRef(false);
|
||||
const formattedShortcuts = shortcuts
|
||||
.map((pin, i) => {
|
||||
const { type, ...data } = pin;
|
||||
if (!SHORTCUTS_META[type]) return null;
|
||||
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
||||
|
||||
if (typeof id === 'function') {
|
||||
id = id(data, i);
|
||||
}
|
||||
if (typeof path === 'function') {
|
||||
path = path(
|
||||
{
|
||||
...data,
|
||||
instance: data.instance || instance,
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (typeof title === 'function') {
|
||||
title = title(data, i);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(data, i);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(data, i);
|
||||
}
|
||||
if (typeof id === 'function') {
|
||||
id = id(data, i);
|
||||
}
|
||||
if (typeof path === 'function') {
|
||||
path = path(
|
||||
{
|
||||
...data,
|
||||
instance: data.instance || instance,
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (typeof title === 'function') {
|
||||
title = title(data, i);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(data, i);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(data, i);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
[shortcuts],
|
||||
);
|
||||
if (id === 'lists') {
|
||||
hasLists.current = true;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
path,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
||||
|
@ -80,15 +91,23 @@ function Shortcuts() {
|
|||
}
|
||||
});
|
||||
|
||||
const [lists, setLists] = useState([]);
|
||||
|
||||
return (
|
||||
<div id="shortcuts">
|
||||
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
||||
<nav class="tab-bar">
|
||||
<nav
|
||||
class="tab-bar"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
states.showShortcutsSettings = true;
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{formattedShortcuts.map(
|
||||
({ id, path, title, subtitle, icon }, i) => {
|
||||
return (
|
||||
<li key={i + title}>
|
||||
<li key={`${i}-${id}-${title}-${subtitle}-${path}`}>
|
||||
<Link
|
||||
class={subtitle ? 'has-subtitle' : ''}
|
||||
to={path}
|
||||
|
@ -126,19 +145,27 @@ function Shortcuts() {
|
|||
</ul>
|
||||
</nav>
|
||||
) : (
|
||||
<Menu
|
||||
<Menu2
|
||||
instanceRef={menuRef}
|
||||
overflow="auto"
|
||||
viewScroll="close"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
menuClassName="glass-menu shortcuts-menu"
|
||||
gap={8}
|
||||
position="anchor"
|
||||
onMenuChange={(e) => {
|
||||
if (e.open && hasLists.current) {
|
||||
getLists().then(setLists);
|
||||
}
|
||||
}}
|
||||
menuButton={
|
||||
<button
|
||||
type="button"
|
||||
id="shortcuts-button"
|
||||
class="plain"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
states.showShortcutsSettings = true;
|
||||
}}
|
||||
onTransitionStart={(e) => {
|
||||
// Close menu if the button disappears
|
||||
try {
|
||||
|
@ -153,9 +180,42 @@ function Shortcuts() {
|
|||
</button>
|
||||
}
|
||||
>
|
||||
{formattedShortcuts.map(({ path, title, subtitle, icon }, i) => {
|
||||
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
|
||||
if (id === 'lists') {
|
||||
return (
|
||||
<SubMenu2
|
||||
menuClassName="glass-menu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
label={
|
||||
<>
|
||||
<Icon icon={icon} size="l" />
|
||||
<span class="menu-grow">
|
||||
<AsyncText>{title}</AsyncText>
|
||||
</span>
|
||||
<Icon icon="chevron-right" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuLink to="/l">
|
||||
<span>All Lists</span>
|
||||
</MenuLink>
|
||||
<MenuDivider />
|
||||
{lists?.map((list) => (
|
||||
<MenuLink key={list.id} to={`/l/${list.id}`}>
|
||||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</SubMenu2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuLink to={path} key={i + title} class="glass-menu-item">
|
||||
<MenuLink
|
||||
to={path}
|
||||
key={`${i}-${id}-${title}-${subtitle}-${path}`}
|
||||
class="glass-menu-item"
|
||||
>
|
||||
<Icon icon={icon} size="l" />{' '}
|
||||
<span class="menu-grow">
|
||||
<span>
|
||||
|
@ -174,10 +234,10 @@ function Shortcuts() {
|
|||
</MenuLink>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Menu2>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Shortcuts;
|
||||
export default memo(Shortcuts);
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
25
src/components/submenu2.jsx
Normal file
25
src/components/submenu2.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { SubMenu } from '@szhsin/react-menu';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
export default function SubMenu2(props) {
|
||||
const menuRef = useRef();
|
||||
return (
|
||||
<SubMenu
|
||||
{...props}
|
||||
instanceRef={menuRef}
|
||||
// Test fix for bug; submenus not opening on Android
|
||||
itemProps={{
|
||||
onPointerMove: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -35,7 +35,7 @@
|
|||
border-bottom: 0;
|
||||
margin-bottom: -1px;
|
||||
background-image: linear-gradient(
|
||||
to top left,
|
||||
to top var(--backward),
|
||||
var(--bg-color) 50%,
|
||||
var(--bg-faded-blur-color)
|
||||
);
|
||||
|
@ -44,12 +44,13 @@
|
|||
.status-translation-block .translated-block {
|
||||
border: 1px solid var(--outline-color);
|
||||
line-height: 1.3;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
border-radius: 8px;
|
||||
border-start-start-radius: 0;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--bg-color);
|
||||
background-image: linear-gradient(
|
||||
to bottom right,
|
||||
to bottom var(--forward),
|
||||
var(--bg-color),
|
||||
var(--bg-faded-blur-color)
|
||||
);
|
||||
|
@ -83,6 +84,7 @@
|
|||
.status-translation-block .translated-block output {
|
||||
display: block;
|
||||
margin-top: 0.75em;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.status-translation-block
|
||||
.translated-block
|
||||
|
@ -105,3 +107,22 @@
|
|||
overflow: visible;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* MINI */
|
||||
|
||||
.status-translation-block-mini {
|
||||
display: flex;
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 0 0;
|
||||
font-size: 90%;
|
||||
border-top: var(--hairline-width) solid var(--outline-color);
|
||||
color: var(--text-insignificant-color);
|
||||
gap: 8px;
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
.status-translation-block-mini .icon {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.status:is(:hover, :active) .status-translation-block-mini {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
|
|
@ -1,19 +1,83 @@
|
|||
import './translation-block.css';
|
||||
|
||||
import pRetry from 'p-retry';
|
||||
import pThrottle from 'p-throttle';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import sourceLanguages from '../data/lingva-source-languages';
|
||||
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||
import localeCode2Text from '../utils/localeCode2Text';
|
||||
import pmem from '../utils/pmem';
|
||||
|
||||
import Icon from './icon';
|
||||
import LazyShazam from './lazy-shazam';
|
||||
import Loader from './loader';
|
||||
|
||||
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
||||
const LINGVA_INSTANCES = PHANPY_LINGVA_INSTANCES
|
||||
? PHANPY_LINGVA_INSTANCES.split(/\s+/)
|
||||
: [];
|
||||
|
||||
const throttle = pThrottle({
|
||||
limit: 1,
|
||||
interval: 2000,
|
||||
});
|
||||
|
||||
let currentLingvaInstance = 0;
|
||||
|
||||
function _lingvaTranslate(text, source, target) {
|
||||
console.log('TRANSLATE', text, source, target);
|
||||
const fetchCall = () => {
|
||||
let instance = LINGVA_INSTANCES[currentLingvaInstance];
|
||||
return fetch(
|
||||
`https://${instance}/api/v1/${source}/${target}/${encodeURIComponent(
|
||||
text,
|
||||
)}`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json();
|
||||
})
|
||||
.then((res) => {
|
||||
return {
|
||||
provider: 'lingva',
|
||||
content: res.translation,
|
||||
detectedSourceLanguage: res.info?.detectedSource,
|
||||
info: res.info,
|
||||
};
|
||||
});
|
||||
};
|
||||
return pRetry(fetchCall, {
|
||||
retries: 3,
|
||||
onFailedAttempt: (e) => {
|
||||
currentLingvaInstance =
|
||||
(currentLingvaInstance + 1) % LINGVA_INSTANCES.length;
|
||||
console.log(
|
||||
'Retrying translation with another instance',
|
||||
currentLingvaInstance,
|
||||
);
|
||||
},
|
||||
});
|
||||
// return masto.v1.statuses.$select(id).translate({
|
||||
// lang: DEFAULT_LANG,
|
||||
// });
|
||||
}
|
||||
const TRANSLATED_MAX_AGE = 1000 * 60 * 60; // 1 hour
|
||||
const lingvaTranslate = pmem(_lingvaTranslate, {
|
||||
maxAge: TRANSLATED_MAX_AGE,
|
||||
});
|
||||
const throttledLingvaTranslate = pmem(throttle(lingvaTranslate), {
|
||||
// I know, this is double-layered memoization
|
||||
maxAge: TRANSLATED_MAX_AGE,
|
||||
});
|
||||
|
||||
function TranslationBlock({
|
||||
forceTranslate,
|
||||
sourceLanguage,
|
||||
onTranslate,
|
||||
text = '',
|
||||
mini,
|
||||
autoDetected,
|
||||
}) {
|
||||
const targetLang = getTranslateTargetLanguage(true);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
@ -28,35 +92,15 @@ function TranslationBlock({
|
|||
const targetLangText = localeCode2Text(targetLang);
|
||||
const apiSourceLang = useRef('auto');
|
||||
|
||||
if (!onTranslate)
|
||||
onTranslate = (source, target) => {
|
||||
console.log('TRANSLATE', source, target, text);
|
||||
// Using another API instance instead of lingva.ml because of this bug (slashes don't work):
|
||||
// https://github.com/thedaviddelta/lingva-translate/issues/68
|
||||
return fetch(
|
||||
`https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent(
|
||||
text,
|
||||
)}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
return {
|
||||
provider: 'lingva',
|
||||
content: res.translation,
|
||||
detectedSourceLanguage: res.info?.detectedSource,
|
||||
info: res.info,
|
||||
};
|
||||
});
|
||||
// return masto.v1.statuses.translate(id, {
|
||||
// lang: DEFAULT_LANG,
|
||||
// });
|
||||
};
|
||||
if (!onTranslate) {
|
||||
onTranslate = mini ? throttledLingvaTranslate : lingvaTranslate;
|
||||
}
|
||||
|
||||
const translate = async () => {
|
||||
setUIState('loading');
|
||||
try {
|
||||
const { content, detectedSourceLanguage, provider, ...props } =
|
||||
await onTranslate(apiSourceLang.current, targetLang);
|
||||
const { content, detectedSourceLanguage, provider, error, ...props } =
|
||||
await onTranslate(text, apiSourceLang.current, targetLang);
|
||||
if (content) {
|
||||
if (detectedSourceLanguage) {
|
||||
const detectedLangText = localeCode2Text(detectedSourceLanguage);
|
||||
|
@ -70,13 +114,15 @@ function TranslationBlock({
|
|||
}
|
||||
setTranslatedContent(content);
|
||||
setUIState('default');
|
||||
detailsRef.current.open = true;
|
||||
detailsRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
if (!mini && content.trim() !== text.trim()) {
|
||||
detailsRef.current.open = true;
|
||||
detailsRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(result);
|
||||
if (error) console.error(error);
|
||||
setUIState('error');
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -91,6 +137,33 @@ function TranslationBlock({
|
|||
}
|
||||
}, [forceTranslate]);
|
||||
|
||||
if (mini) {
|
||||
if (
|
||||
!!translatedContent &&
|
||||
translatedContent.trim() !== text.trim() &&
|
||||
detectedLang !== targetLangText
|
||||
) {
|
||||
return (
|
||||
<LazyShazam>
|
||||
<div class="status-translation-block-mini">
|
||||
<Icon
|
||||
icon="translate"
|
||||
alt={`Auto-translated from ${sourceLangText}`}
|
||||
/>
|
||||
<output
|
||||
lang={targetLang}
|
||||
dir="auto"
|
||||
title={pronunciationContent || ''}
|
||||
>
|
||||
{translatedContent}
|
||||
</output>
|
||||
</div>
|
||||
</LazyShazam>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="status-translation-block"
|
||||
|
@ -115,7 +188,9 @@ function TranslationBlock({
|
|||
{uiState === 'loading'
|
||||
? 'Translating…'
|
||||
: sourceLanguage && sourceLangText && !detectedLang
|
||||
? `Translate from ${sourceLangText}`
|
||||
? autoDetected
|
||||
? `Translate from ${sourceLangText} (auto-detected)`
|
||||
: `Translate from ${sourceLangText}`
|
||||
: `Translate`}
|
||||
</span>
|
||||
</button>
|
||||
|
@ -167,4 +242,4 @@ function TranslationBlock({
|
|||
);
|
||||
}
|
||||
|
||||
export default TranslationBlock;
|
||||
export default LINGVA_INSTANCES?.length ? TranslationBlock : () => null;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import './index.css';
|
||||
|
||||
import './app.css';
|
||||
import './polyfills';
|
||||
|
||||
import { render } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Compose from './components/compose';
|
||||
import ComposeSuspense from './components/compose-suspense';
|
||||
import { initStates } from './utils/states';
|
||||
import useTitle from './utils/useTitle';
|
||||
|
||||
if (window.opener) {
|
||||
|
@ -27,6 +28,10 @@ function App() {
|
|||
: 'Compose',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initStates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (uiState === 'closed') {
|
||||
try {
|
||||
|
@ -57,7 +62,7 @@ function App() {
|
|||
console.debug('OPEN COMPOSE');
|
||||
|
||||
return (
|
||||
<Compose
|
||||
<ComposeSuspense
|
||||
editStatus={editStatus}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
{
|
||||
"@mastodon/edit-media-attributes": ">=4.1"
|
||||
"@mastodon/edit-media-attributes": ">=4.1",
|
||||
"@mastodon/list-exclusive": ">=4.2",
|
||||
"@mastodon/filtered-notifications": "~4.3 || >=4.3",
|
||||
"@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3",
|
||||
"@mastodon/trending-link-posts": "~4.3 || >=4.3",
|
||||
"@mastodon/grouped-notifications": "~4.3 || >=4.3"
|
||||
}
|
||||
|
|
|
@ -1,638 +1,384 @@
|
|||
[
|
||||
"daystorm.netz.org",
|
||||
"mastodon.social",
|
||||
"pawoo.net",
|
||||
"mstdn.social",
|
||||
"mstdn.jp",
|
||||
"mstdn.social",
|
||||
"mas.to",
|
||||
"mastodon.online",
|
||||
"mastodon.world",
|
||||
"infosec.exchange",
|
||||
"fosstodon.org",
|
||||
"hachyderm.io",
|
||||
"troet.cafe",
|
||||
"m.cmx.im",
|
||||
"fedibird.com",
|
||||
"techhub.social",
|
||||
"mastodonapp.uk",
|
||||
"universeodon.com",
|
||||
"mastodon.uno",
|
||||
"chaos.social",
|
||||
"mastodon.gamedev.place",
|
||||
"m.cmx.im",
|
||||
"techhub.social",
|
||||
"piaille.fr",
|
||||
"mastodon.gamedev.place",
|
||||
"mastodonapp.uk",
|
||||
"mastodon.nl",
|
||||
"mastodon.art",
|
||||
"mastodon.cloud",
|
||||
"mastodon.sdf.org",
|
||||
"kolektiva.social",
|
||||
"thu.closed.social",
|
||||
"mstdn.ca",
|
||||
"masto.ai",
|
||||
"c.im",
|
||||
"alive.bar",
|
||||
"sfba.social",
|
||||
"o3o.ca",
|
||||
"social.vivaldi.net",
|
||||
"universeodon.com",
|
||||
"mastodon.sdf.org",
|
||||
"c.im",
|
||||
"mstdn.ca",
|
||||
"kolektiva.social",
|
||||
"mastodon-japan.net",
|
||||
"norden.social",
|
||||
"social.tchncs.de",
|
||||
"noagendasocial.com",
|
||||
"det.social",
|
||||
"wxw.moe",
|
||||
"mstdn.party",
|
||||
"aus.social",
|
||||
"o3o.ca",
|
||||
"sfba.social",
|
||||
"nrw.social",
|
||||
"home.social",
|
||||
"mastodon.scot",
|
||||
"tech.lgbt",
|
||||
"newsie.social",
|
||||
"mastodon.scot",
|
||||
"mstdn.party",
|
||||
"occm.cc",
|
||||
"aus.social",
|
||||
"mathstodon.xyz",
|
||||
"toot.community",
|
||||
"ohai.social",
|
||||
"mastodon.top",
|
||||
"mastodon.ie",
|
||||
"mamot.fr",
|
||||
"sueden.social",
|
||||
"mindly.social",
|
||||
"mathstodon.xyz",
|
||||
"meow.social",
|
||||
"botsin.space",
|
||||
"mastodon.ie",
|
||||
"mastodon.top",
|
||||
"mastodontech.de",
|
||||
"loforo.com",
|
||||
"dice.camp",
|
||||
"ro-mastodon.puyo.jp",
|
||||
"twit.social",
|
||||
"planet.moe",
|
||||
"ioc.exchange",
|
||||
"mastodon.au",
|
||||
"mastodon.nu",
|
||||
"masto.es",
|
||||
"freemasonry.social",
|
||||
"ioc.exchange",
|
||||
"mindly.social",
|
||||
"hessen.social",
|
||||
"ruhr.social",
|
||||
"mastodon.green",
|
||||
"social.cologne",
|
||||
"mastodon.nz",
|
||||
"nerdculture.de",
|
||||
"muenchen.social",
|
||||
"mastouille.fr",
|
||||
"qoto.org",
|
||||
"social.anoxinon.de",
|
||||
"twingyeo.kr",
|
||||
"mastodon.xyz",
|
||||
"fediscience.org",
|
||||
"framapiaf.org",
|
||||
"akamdon.com",
|
||||
"indieweb.social",
|
||||
"social.linux.pizza",
|
||||
"wandering.shop",
|
||||
"me.dm",
|
||||
"sigmoid.social",
|
||||
"aethy.com",
|
||||
"eldritch.cafe",
|
||||
"zirk.us",
|
||||
"ruby.social",
|
||||
"mastodon-japan.net",
|
||||
"mstdn.science",
|
||||
"defcon.social",
|
||||
"noc.social",
|
||||
"ravenation.club",
|
||||
"social.librem.one",
|
||||
"mstdn.guru",
|
||||
"mastodont.cat",
|
||||
"g0v.social",
|
||||
"ecoevo.social",
|
||||
"tkz.one",
|
||||
"social.anoxinon.de",
|
||||
"mastodon.green",
|
||||
"mastouille.fr",
|
||||
"social.linux.pizza",
|
||||
"social.cologne",
|
||||
"indieweb.social",
|
||||
"livellosegreto.it",
|
||||
"masto.nu",
|
||||
"med-mastodon.com",
|
||||
"toot.wales",
|
||||
"ruby.social",
|
||||
"ieji.de",
|
||||
"bildung.social",
|
||||
"octodon.social",
|
||||
"urbanists.social",
|
||||
"pouet.chapril.org",
|
||||
"mastodon.com.tr",
|
||||
"social.dev-wiki.de",
|
||||
"mastodon.nz",
|
||||
"toot.io",
|
||||
"digitalcourage.social",
|
||||
"econtwitter.net",
|
||||
"climatejustice.social",
|
||||
"kinky.business",
|
||||
"mastodontti.fi",
|
||||
"mastodon.radio",
|
||||
"metalhead.club",
|
||||
"tkz.one",
|
||||
"mastodont.cat",
|
||||
"social.tchncs.de",
|
||||
"mastodon.com.tr",
|
||||
"noc.social",
|
||||
"sciences.social",
|
||||
"mastodon.bida.im",
|
||||
"toot.wales",
|
||||
"masto.nu",
|
||||
"phpc.social",
|
||||
"mastodon.fun",
|
||||
"berlin.social",
|
||||
"mstdn.plus",
|
||||
"mastodon.iriseden.eu",
|
||||
"101010.pl",
|
||||
"woof.group",
|
||||
"obo.sh",
|
||||
"geekdom.social",
|
||||
"androiddev.social",
|
||||
"rollenspiel.social",
|
||||
"social.lol",
|
||||
"genomic.social",
|
||||
"socel.net",
|
||||
"best-friends.chat",
|
||||
"mastodonczech.cz",
|
||||
"wien.rocks",
|
||||
"mastodon.me.uk",
|
||||
"scholar.social",
|
||||
"swiss.social",
|
||||
"dresden.network",
|
||||
"yiff.life",
|
||||
"social.dev-wiki.de",
|
||||
"cyberplace.social",
|
||||
"mastodontti.fi",
|
||||
"climatejustice.social",
|
||||
"urbanists.social",
|
||||
"mstdn.plus",
|
||||
"metalhead.club",
|
||||
"ravenation.club",
|
||||
"mastodon.ml",
|
||||
"fairy.id",
|
||||
"feuerwehr.social",
|
||||
"dresden.network",
|
||||
"stranger.social",
|
||||
"mastodon.iriseden.eu",
|
||||
"rollenspiel.social",
|
||||
"pol.social",
|
||||
"mstdn.business",
|
||||
"mstdn.games",
|
||||
"wien.rocks",
|
||||
"h4.io",
|
||||
"socel.net",
|
||||
"mastodon.eus",
|
||||
"wehavecookies.social",
|
||||
"glasgow.social",
|
||||
"masto.pt",
|
||||
"sself.co",
|
||||
"mastodon.me.uk",
|
||||
"uri.life",
|
||||
"hostux.social",
|
||||
"theblower.au",
|
||||
"openbiblio.social",
|
||||
"mstdn.games",
|
||||
"todon.eu",
|
||||
"typo.social",
|
||||
"floss.social",
|
||||
"tabletop.social",
|
||||
"mastodon.ml",
|
||||
"freiburg.social",
|
||||
"writing.exchange",
|
||||
"rubber.social",
|
||||
"mstdn.io",
|
||||
"paquita.masto.host",
|
||||
"hci.social",
|
||||
"snabelen.no",
|
||||
"astrodon.social",
|
||||
"mastodon-belgium.be",
|
||||
"queer.party",
|
||||
"journa.host",
|
||||
"hcommons.social",
|
||||
"mastodonners.nl",
|
||||
"toot.aquilenet.fr",
|
||||
"mastodon-uk.net",
|
||||
"masto.pt",
|
||||
"awscommunity.social",
|
||||
"mastodon.zaclys.com",
|
||||
"dju.social",
|
||||
"ursal.zone",
|
||||
"stranger.social",
|
||||
"tooot.im",
|
||||
"muenster.im",
|
||||
"social.coop",
|
||||
"tooting.ch",
|
||||
"abdl.link",
|
||||
"toad.social",
|
||||
"rheinneckar.social",
|
||||
"social.treehouse.systems",
|
||||
"shakedown.social",
|
||||
"peoplemaking.games",
|
||||
"lor.sh",
|
||||
"union.place",
|
||||
"witches.live",
|
||||
"vis.social",
|
||||
"equestria.social",
|
||||
"tilde.zone",
|
||||
"lewdieheaven.com",
|
||||
"wetdry.world",
|
||||
"nofan.xyz",
|
||||
"h4.io",
|
||||
"photog.social",
|
||||
"discuss.systems",
|
||||
"mastoturk.org",
|
||||
"bonn.social",
|
||||
"vmst.io",
|
||||
"spore.social",
|
||||
"pol.social",
|
||||
"flipboard.social",
|
||||
"imastodon.net",
|
||||
"cybre.space",
|
||||
"bsd.network",
|
||||
"mstdn.maud.io",
|
||||
"girlcock.club",
|
||||
"pettingzoo.co",
|
||||
"mast.lat",
|
||||
"cupoftea.social",
|
||||
"bark.lgbt",
|
||||
"moth.social",
|
||||
"toot.cat",
|
||||
"furry.engineer",
|
||||
"qdon.space",
|
||||
"otadon.com",
|
||||
"gruene.social",
|
||||
"historians.social",
|
||||
"mapstodon.space",
|
||||
"douchi.space",
|
||||
"vocalodon.net",
|
||||
"layer8.space",
|
||||
"todon.nl",
|
||||
"types.pl",
|
||||
"ludosphere.fr",
|
||||
"merveilles.town",
|
||||
"iosdev.space",
|
||||
"feuerwehr.social",
|
||||
"mast.dragon-fly.club",
|
||||
"kemonodon.club",
|
||||
"macaw.social",
|
||||
"oldbytes.space",
|
||||
"medibubble.org",
|
||||
"expressional.social",
|
||||
"disabled.social",
|
||||
"bolha.us",
|
||||
"freeradical.zone",
|
||||
"scicomm.xyz",
|
||||
"graphics.social",
|
||||
"mona.do",
|
||||
"toot.blue",
|
||||
"emacs.ch",
|
||||
"lile.cl",
|
||||
"social.sciences.re",
|
||||
"ai.wiki",
|
||||
"linuxrocks.online",
|
||||
"jorts.horse",
|
||||
"persiansmastodon.com",
|
||||
"mastodon.berlin",
|
||||
"liker.social",
|
||||
"literatur.social",
|
||||
"masto.bike",
|
||||
"retro.pizza",
|
||||
"climatejustice.rocks",
|
||||
"neurodifferent.me",
|
||||
"post.lurk.org",
|
||||
"mastodon.coffee",
|
||||
"mastodon.gal",
|
||||
"oslo.town",
|
||||
"neuromatch.social",
|
||||
"ika.queloud.net",
|
||||
"mstdn.beer",
|
||||
"graz.social",
|
||||
"libretooth.gr",
|
||||
"mastodonbooks.net",
|
||||
"xoxo.zone",
|
||||
"mastodon.design",
|
||||
"convo.casa",
|
||||
"bitbang.social",
|
||||
"freeatlantis.com",
|
||||
"masto.nobigtech.es",
|
||||
"eupolicy.social",
|
||||
"sociale.network",
|
||||
"famichiki.jp",
|
||||
"pkm.social",
|
||||
"4bear.com",
|
||||
"freak.university",
|
||||
"opalstack.social",
|
||||
"chitter.xyz",
|
||||
"sciencemastodon.com",
|
||||
"lgbtqia.space",
|
||||
"ffxiv-mastodon.com",
|
||||
"mental.social",
|
||||
"iztasocial.site",
|
||||
"artisan.chat",
|
||||
"vulpine.club",
|
||||
"musician.social",
|
||||
"freiburg.social",
|
||||
"snabelen.no",
|
||||
"mastodon.zaclys.com",
|
||||
"muenster.im",
|
||||
"mastodon-belgium.be",
|
||||
"geekdom.social",
|
||||
"hcommons.social",
|
||||
"tooot.im",
|
||||
"tooting.ch",
|
||||
"rheinneckar.social",
|
||||
"discuss.systems",
|
||||
"sunny.garden",
|
||||
"dizl.de",
|
||||
"glammr.us",
|
||||
"mastodo.fi",
|
||||
"kirche.social",
|
||||
"mastodon.energy",
|
||||
"kind.social",
|
||||
"shelter.moe",
|
||||
"computerfairi.es",
|
||||
"mastodon.la",
|
||||
"mastodon.org.uk",
|
||||
"awoo.space",
|
||||
"fulda.social",
|
||||
"witter.cz",
|
||||
"freemasonry.social",
|
||||
"jawns.club",
|
||||
"mao.mastodonhub.com",
|
||||
"trpg.cloud",
|
||||
"ramen-fsm.eu.org",
|
||||
"toot.cafe",
|
||||
"darmstadt.social",
|
||||
"mstdn.mx",
|
||||
"pokemon.mastportal.info",
|
||||
"toot.lv",
|
||||
"romancelandia.club",
|
||||
"better.boston",
|
||||
"pnw.zone",
|
||||
"mastodon.content.town",
|
||||
"rivals.space",
|
||||
"thecanadian.social",
|
||||
"cr8r.gg",
|
||||
"plural.cafe",
|
||||
"xarxa.cloud",
|
||||
"esperanto.masto.host",
|
||||
"federated.press",
|
||||
"nnia.space",
|
||||
"digipres.club",
|
||||
"h5q.net",
|
||||
"kinkyelephant.com",
|
||||
"pawb.fun",
|
||||
"data-folks.masto.host",
|
||||
"mastodon.uy",
|
||||
"worldkey.io",
|
||||
"mastorol.es",
|
||||
"zeroes.ca",
|
||||
"mastodon.arch-linux.cz",
|
||||
"mastodon.acm.org",
|
||||
"social.bau-ha.us",
|
||||
"bbq.snoot.com",
|
||||
"akademienl.social",
|
||||
"toot.bike",
|
||||
"vtdon.com",
|
||||
"uri.life",
|
||||
"machteburch.social",
|
||||
"mas.town",
|
||||
"vkl.world",
|
||||
"vt.social",
|
||||
"mastodon.cat",
|
||||
"podcastindex.social",
|
||||
"artsio.com",
|
||||
"dotnet.social",
|
||||
"oc.todon.fr",
|
||||
"functional.cafe",
|
||||
"halifaxsocial.ca",
|
||||
"babka.social",
|
||||
"ichiji.social",
|
||||
"ura-mstdn.com",
|
||||
"eightpoint.app",
|
||||
"liberdon.com",
|
||||
"toot.portes-imaginaire.org",
|
||||
"mograph.social",
|
||||
"kirakiratter.com",
|
||||
"mstdn.tokyocameraclub.com",
|
||||
"gearheads.social",
|
||||
"est.social",
|
||||
"mastodon.mim-libre.fr",
|
||||
"swiss-talk.net",
|
||||
"donphan.social",
|
||||
"masto.nyc",
|
||||
"mapstodon.space",
|
||||
"toad.social",
|
||||
"lor.sh",
|
||||
"peoplemaking.games",
|
||||
"union.place",
|
||||
"bark.lgbt",
|
||||
"bonn.social",
|
||||
"tilde.zone",
|
||||
"vmst.io",
|
||||
"mastodon.berlin",
|
||||
"emacs.ch",
|
||||
"blorbo.social",
|
||||
"qubit-social.xyz",
|
||||
"en.osm.town",
|
||||
"gulp.cafe",
|
||||
"assemblag.es",
|
||||
"mstdn.kemono-friends.info",
|
||||
"tyrol.social",
|
||||
"social.seattle.wa.us",
|
||||
"toot.kif.rocks",
|
||||
"twiukraine.com",
|
||||
"social.politicaconciencia.org",
|
||||
"icosahedron.website",
|
||||
"toot.si",
|
||||
"mastodon.in.th",
|
||||
"norcal.social",
|
||||
"warhammer.social",
|
||||
"bookwor.ms",
|
||||
"kanoa.de",
|
||||
"furry.engineer",
|
||||
"rivals.space",
|
||||
"cupoftea.social",
|
||||
"qdon.space",
|
||||
"graphics.social",
|
||||
"veganism.social",
|
||||
"cryptodon.lol",
|
||||
"jasette.facil.services",
|
||||
"is.nota.live",
|
||||
"epicure.social",
|
||||
"sauropods.win",
|
||||
"kurry.social",
|
||||
"hometech.social",
|
||||
"kopiti.am",
|
||||
"biplus.date",
|
||||
"spacey.space",
|
||||
"photodn.net",
|
||||
"blabber.lu-rp.net",
|
||||
"im-in.space",
|
||||
"wargamers.social",
|
||||
"toot.berlin",
|
||||
"archaeo.social",
|
||||
"col.social",
|
||||
"h-net.social",
|
||||
"social.kyiv.dcomm.net.ua",
|
||||
"dobbs.town",
|
||||
"mastodon.com.br",
|
||||
"ludosphere.fr",
|
||||
"4bear.com",
|
||||
"famichiki.jp",
|
||||
"expressional.social",
|
||||
"convo.casa",
|
||||
"historians.social",
|
||||
"mastorol.es",
|
||||
"retro.pizza",
|
||||
"shelter.moe",
|
||||
"mast.dragon-fly.club",
|
||||
"sakurajima.moe",
|
||||
"mastodon.arch-linux.cz",
|
||||
"squawk.mytransponder.com",
|
||||
"mastodon.gal",
|
||||
"disabled.social",
|
||||
"vkl.world",
|
||||
"eupolicy.social",
|
||||
"fandom.ink",
|
||||
"toot.funami.tech",
|
||||
"nafo.uk",
|
||||
"arsenalfc.social",
|
||||
"social.edu.nl",
|
||||
"sunbeam.city",
|
||||
"federate.social",
|
||||
"hello.2heng.xin",
|
||||
"gensokyo.town",
|
||||
"mastodon.tetaneutral.net",
|
||||
"tablegame.mstdn.cloud",
|
||||
"elekk.xyz",
|
||||
"blacktwitter.io",
|
||||
"burma.social",
|
||||
"osna.social",
|
||||
"seocommunity.social",
|
||||
"otogamer.me",
|
||||
"mstdn.fr",
|
||||
"toki.social",
|
||||
"colearn.social",
|
||||
"cloud-native.social",
|
||||
"mstdn-bike.net",
|
||||
"mastodon.hypnoguys.com",
|
||||
"lounge.town",
|
||||
"guitar.rodeo",
|
||||
"mastodon.mit.edu",
|
||||
"hispagatos.space",
|
||||
"mstdn.id",
|
||||
"flower.afn.social",
|
||||
"parfait.day",
|
||||
"nederland.online",
|
||||
"ani.work",
|
||||
"mastodon.education",
|
||||
"mastodon.gougere.fr",
|
||||
"cztwitter.cz",
|
||||
"uwu.social",
|
||||
"mastodon.bayern",
|
||||
"gameliberty.club",
|
||||
"sukebe.hostdon.ne.jp",
|
||||
"social.veraciousnetwork.com",
|
||||
"mastodon.vlaanderen",
|
||||
"earthstream.social",
|
||||
"xn--lofll-1sat.is",
|
||||
"social.datalabour.com",
|
||||
"gametoots.de",
|
||||
"mastodon.com.py",
|
||||
"outdoors.lgbt",
|
||||
"arvr.social",
|
||||
"loðfíll.is",
|
||||
"social.yesterweb.org",
|
||||
"9kb.me",
|
||||
"mstdn.dk",
|
||||
"occitania.social",
|
||||
"apobangpo.space",
|
||||
"dingdash.com",
|
||||
"mastodon.chasem.dev",
|
||||
"oulipo.social",
|
||||
"digforfire.org",
|
||||
"mastodon.partipirate.org",
|
||||
"mastodon.hk",
|
||||
"mastoot.fr",
|
||||
"eigadon.net",
|
||||
"irsoluciones.social",
|
||||
"maly.io",
|
||||
"birds.town",
|
||||
"kfem.cat",
|
||||
"beekeeping.ninja",
|
||||
"mastodon.juggler.jp",
|
||||
"oransns.com",
|
||||
"anticapitalist.party",
|
||||
"deadinsi.de",
|
||||
"gardenstate.social",
|
||||
"mastodon.cc",
|
||||
"piano.masto.host",
|
||||
"eletusk.club",
|
||||
"lewacki.space",
|
||||
"mastodon.pirateparty.be",
|
||||
"anarchism.space",
|
||||
"mastodon.cisti.org",
|
||||
"metalverse.social",
|
||||
"truthsocial.co.in",
|
||||
"baraag.net",
|
||||
"yakyudon.net",
|
||||
"lou.lt",
|
||||
"social.slat.org",
|
||||
"gensokyo.social",
|
||||
"social.chinwag.org",
|
||||
"tribe.net",
|
||||
"lgbt.io",
|
||||
"toots.social",
|
||||
"pravda.me",
|
||||
"aleph.land",
|
||||
"poweredbygay.social",
|
||||
"masto.yttrx.com",
|
||||
"yttrx.com",
|
||||
"toot.pizza",
|
||||
"drumstodon.net",
|
||||
"acg.mn",
|
||||
"kpop.social",
|
||||
"toolboxtalk.tech",
|
||||
"bear.community",
|
||||
"otoya.space",
|
||||
"mastodon.triggerphra.se",
|
||||
"mastodon.free-solutions.org",
|
||||
"rcsocial.net",
|
||||
"kith.kitchen",
|
||||
"vocalounge.cafe",
|
||||
"pieville.net",
|
||||
"mstdn.osaka",
|
||||
"mastodon.mnetwork.co.kr",
|
||||
"mstdn.es",
|
||||
"seo.chat",
|
||||
"mastodol.jp",
|
||||
"renkontu.com",
|
||||
"mastodon.cipherbliss.com",
|
||||
"toot.turbo.chat",
|
||||
"catdon.life",
|
||||
"social.coletivos.org",
|
||||
"toot.thoughtworks.com",
|
||||
"mastodonbooks.net",
|
||||
"lgbtqia.space",
|
||||
"witter.cz",
|
||||
"planetearth.social",
|
||||
"oslo.town",
|
||||
"mastodon.com.pl",
|
||||
"pawb.fun",
|
||||
"darmstadt.social",
|
||||
"masto.nobigtech.es",
|
||||
"cr8r.gg",
|
||||
"pnw.zone",
|
||||
"hear-me.social",
|
||||
"furries.club",
|
||||
"gaygeek.social",
|
||||
"birdon.social",
|
||||
"mastodon.energy",
|
||||
"mastodon-swiss.org",
|
||||
"social.targaryen.house",
|
||||
"moe.cat",
|
||||
"bologna.one",
|
||||
"toot.site",
|
||||
"e.fo",
|
||||
"mastodon.holeyfox.co",
|
||||
"m.rthome.me",
|
||||
"stereodon.social",
|
||||
"social.opendesktop.org",
|
||||
"bgme.me",
|
||||
"social.caa-ins.org",
|
||||
"nojack.easydns.ca",
|
||||
"mastodon.oeru.org",
|
||||
"mastodon.elte.hu",
|
||||
"nasface.cz",
|
||||
"lilymagic.com",
|
||||
"mast.moe",
|
||||
"mastodon.librelabucm.org",
|
||||
"fetswing.org",
|
||||
"mastodon.cosmicanimal.jp",
|
||||
"todon.ploud.fr",
|
||||
"ephemeral.glitch.social",
|
||||
"mikumikudance.cloud",
|
||||
"summoners-riftodon.jp",
|
||||
"kinbaku.club",
|
||||
"www.mstddntfdn.online",
|
||||
"dev.brighteon.social",
|
||||
"jaxbeach.social",
|
||||
"animalliberation.social",
|
||||
"onmasto.com",
|
||||
"pet123.club",
|
||||
"ostatus.ikeji.ma",
|
||||
"counter.social",
|
||||
"the.resize.club",
|
||||
"social.outsourcedmath.com",
|
||||
"nerdculture.de",
|
||||
"pewtix.com",
|
||||
"med-mammoth.com",
|
||||
"ping-pong-sandbox.herokuapp.com",
|
||||
"id.cc",
|
||||
"freespeechextremist.com",
|
||||
"cawfee.club",
|
||||
"1234.as",
|
||||
"fedi.absturztau.be",
|
||||
"fsmi.social",
|
||||
"go5.dev",
|
||||
"poa.st",
|
||||
"patriot.online",
|
||||
"stereophonic.space",
|
||||
"kazv.moe",
|
||||
"seaofog.com",
|
||||
"libranet.de",
|
||||
"tea.codes",
|
||||
"pixelfed.social",
|
||||
"shitposter.club",
|
||||
"squeet.me",
|
||||
"shared.graphics",
|
||||
"glindr.org",
|
||||
"devs.live",
|
||||
"pxlmo.com",
|
||||
"pixel.tchncs.de",
|
||||
"pythondevs.social",
|
||||
"pleroma.pibvt.net",
|
||||
"books.theunseen.city",
|
||||
"love.alicecomplex.com",
|
||||
"dizl.de",
|
||||
"libretooth.gr",
|
||||
"mustard.blog",
|
||||
"machteburch.social",
|
||||
"fulda.social",
|
||||
"muri.network",
|
||||
"babka.social",
|
||||
"archaeo.social",
|
||||
"mastodon.uy",
|
||||
"xarxa.cloud",
|
||||
"corteximplant.com",
|
||||
"mastodon.london",
|
||||
"greenish.red",
|
||||
"pixelfed.sdf.org",
|
||||
"anar.chi.st",
|
||||
"friendica.eskimo.com",
|
||||
"meatbag.app",
|
||||
"dudu.best",
|
||||
"pix.diaspodon.fr",
|
||||
"shpposter.club",
|
||||
"pix.toot.wales",
|
||||
"pleroma.noellabo.jp",
|
||||
"fgc.network",
|
||||
"bookrastinating.com",
|
||||
"pixey.org",
|
||||
"fe.disroot.org",
|
||||
"pixelfed.tokyo",
|
||||
"urusai.social",
|
||||
"thecanadian.social",
|
||||
"federated.press",
|
||||
"kanoa.de",
|
||||
"opalstack.social",
|
||||
"bahn.social",
|
||||
"mograph.social",
|
||||
"dmv.community",
|
||||
"social.bau-ha.us",
|
||||
"mastodon.free-solutions.org",
|
||||
"masto.nyc",
|
||||
"tyrol.social",
|
||||
"burma.social",
|
||||
"toot.kif.rocks",
|
||||
"donphan.social",
|
||||
"mast.hpc.social",
|
||||
"musicians.today",
|
||||
"drupal.community",
|
||||
"hometech.social",
|
||||
"norcal.social",
|
||||
"social.politicaconciencia.org",
|
||||
"social.seattle.wa.us",
|
||||
"is.nota.live",
|
||||
"genealysis.social",
|
||||
"wargamers.social",
|
||||
"guitar.rodeo",
|
||||
"bookstodon.com",
|
||||
"mstdn.dk",
|
||||
"elizur.me",
|
||||
"irsoluciones.social",
|
||||
"h-net.social",
|
||||
"mastoot.fr",
|
||||
"qaf.men",
|
||||
"est.social",
|
||||
"kurry.social",
|
||||
"mastodon.pnpde.social",
|
||||
"ani.work",
|
||||
"nederland.online",
|
||||
"epicure.social",
|
||||
"occitania.social",
|
||||
"lgbt.io",
|
||||
"mountains.social",
|
||||
"persiansmastodon.com",
|
||||
"seocommunity.social",
|
||||
"cyberfurz.social",
|
||||
"fedi.at",
|
||||
"gamepad.club",
|
||||
"augsburg.social",
|
||||
"mastodon.education",
|
||||
"toot.re",
|
||||
"linux.social",
|
||||
"neovibe.app",
|
||||
"musician.social",
|
||||
"esq.social",
|
||||
"social.veraciousnetwork.com",
|
||||
"datasci.social",
|
||||
"tooters.org",
|
||||
"ciberlandia.pt",
|
||||
"cloud-native.social",
|
||||
"social.silicon.moe",
|
||||
"cosocial.ca",
|
||||
"arvr.social",
|
||||
"hispagatos.space",
|
||||
"friendsofdesoto.social",
|
||||
"musicworld.social",
|
||||
"aut.social",
|
||||
"masto.yttrx.com",
|
||||
"mastodon.wien",
|
||||
"448c.net",
|
||||
"freeframe.masto.host",
|
||||
"pixelfed.photos",
|
||||
"varishangout.net",
|
||||
"pixelfed.fr",
|
||||
"friendica.vrije-mens.org",
|
||||
"mastodon.tech",
|
||||
"bae.st",
|
||||
"brighteon.social",
|
||||
"pixelfed.nz",
|
||||
"hayu.sh",
|
||||
"pixelfed.uno",
|
||||
"pixelfed.au",
|
||||
"helladoge.com",
|
||||
"miniwa.moe",
|
||||
"genserver.social",
|
||||
"bookwyrm.social",
|
||||
"spinster.xyz",
|
||||
"pixelfed.de",
|
||||
"metapixl.com",
|
||||
"neenster.org",
|
||||
"venera.social",
|
||||
"outerheaven.club",
|
||||
"gleasonator.com",
|
||||
"pixelfed.fi",
|
||||
"blob.cat",
|
||||
"pxl.roflcopter.fr",
|
||||
"gc2.jp",
|
||||
"kids.0px.io"
|
||||
"colorid.es",
|
||||
"arsenalfc.social",
|
||||
"allthingstech.social",
|
||||
"mastodon.vlaanderen",
|
||||
"mastodon.com.py",
|
||||
"tooter.social",
|
||||
"lounge.town",
|
||||
"puntarella.party",
|
||||
"earthstream.social",
|
||||
"apobangpo.space",
|
||||
"opencoaster.net",
|
||||
"frikiverse.zone",
|
||||
"airwaves.social",
|
||||
"toot.garden",
|
||||
"lewacki.space",
|
||||
"gardenstate.social",
|
||||
"theatl.social",
|
||||
"maly.io",
|
||||
"library.love",
|
||||
"kfem.cat",
|
||||
"ruhrpott.social",
|
||||
"techtoots.com",
|
||||
"furry.energy",
|
||||
"mastodon.pirateparty.be",
|
||||
"metalverse.social",
|
||||
"indieauthors.social",
|
||||
"tuiter.rocks",
|
||||
"mastodon.africa",
|
||||
"jvm.social",
|
||||
"poweredbygay.social",
|
||||
"fikaverse.club",
|
||||
"gametoots.de",
|
||||
"mastodon.cr",
|
||||
"hoosier.social",
|
||||
"khiar.net",
|
||||
"seo.chat",
|
||||
"drumstodon.net",
|
||||
"raphus.social",
|
||||
"toots.nu",
|
||||
"k8s.social",
|
||||
"mastodon.holeyfox.co",
|
||||
"fribygda.no",
|
||||
"x0r.be",
|
||||
"fpl.social",
|
||||
"toot.pizza",
|
||||
"mastodon.cipherbliss.com",
|
||||
"burningboard.net",
|
||||
"synapse.cafe",
|
||||
"cultur.social",
|
||||
"vermont.masto.host",
|
||||
"mastodon.bot",
|
||||
"bologna.one",
|
||||
"mastodon.sg",
|
||||
"tchafia.be",
|
||||
"rail.chat",
|
||||
"mastodon.hosnet.fr",
|
||||
"leipzig.town",
|
||||
"wayne.social",
|
||||
"rheinhessen.social",
|
||||
"rap.social",
|
||||
"cwb.social",
|
||||
"mastodon.bachgau.social",
|
||||
"cville.online",
|
||||
"bzh.social",
|
||||
"mastodon.escepticos.es",
|
||||
"zenzone.social",
|
||||
"mastodon.ee",
|
||||
"lsbt.me",
|
||||
"neurodiversity-in.au",
|
||||
"fairmove.net",
|
||||
"stereodon.social",
|
||||
"mcr.wtf",
|
||||
"mastodon.frl",
|
||||
"mikumikudance.cloud",
|
||||
"okla.social",
|
||||
"camp.smolnet.org",
|
||||
"ailbhean.co-shaoghal.net",
|
||||
"clj.social",
|
||||
"tu.social",
|
||||
"nomanssky.social",
|
||||
"mastodon.iow.social",
|
||||
"frontrange.co",
|
||||
"episcodon.net",
|
||||
"devianze.city",
|
||||
"paktodon.asia",
|
||||
"travelpandas.fr",
|
||||
"silversword.online",
|
||||
"nwb.social",
|
||||
"skastodon.com",
|
||||
"kcmo.social",
|
||||
"balkan.fedive.rs",
|
||||
"openedtech.social",
|
||||
"mastodon.ph",
|
||||
"enshittification.social",
|
||||
"spojnik.works",
|
||||
"mastodon.conquestuniverse.com",
|
||||
"nutmeg.social",
|
||||
"social.sndevs.com",
|
||||
"social.diva.exchange",
|
||||
"growers.social",
|
||||
"pdx.sh",
|
||||
"nfld.me",
|
||||
"cartersville.social",
|
||||
"voi.social",
|
||||
"mastodon.babb.no",
|
||||
"kzoo.to",
|
||||
"mastodon.vanlife.is",
|
||||
"toot.works",
|
||||
"sanjuans.life",
|
||||
"dariox.club",
|
||||
"xreality.social",
|
||||
"social.ferrocarril.net",
|
||||
"pool.social",
|
||||
"polsci.social",
|
||||
"mastodon.mg",
|
||||
"23.illuminati.org",
|
||||
"apotheke.social",
|
||||
"jaxbeach.social",
|
||||
"ceilidh.online",
|
||||
"netsphere.one",
|
||||
"biplus.social",
|
||||
"bvb.social",
|
||||
"ms.maritime.social",
|
||||
"darticulate.com",
|
||||
"persia.social",
|
||||
"streamerchat.social",
|
||||
"troet.fediverse.at",
|
||||
"publishing.social",
|
||||
"finsup.social",
|
||||
"kjas.no",
|
||||
"wxw.moe",
|
||||
"learningdisability.social",
|
||||
"mastodon.bida.im",
|
||||
"computerfairi.es",
|
||||
"tea.codes"
|
||||
]
|
|
@ -909,11 +909,36 @@
|
|||
"Zulu",
|
||||
"isiZulu"
|
||||
],
|
||||
[
|
||||
"zh-CN",
|
||||
"Chinese (China)",
|
||||
"简体中文"
|
||||
],
|
||||
[
|
||||
"zh-HK",
|
||||
"Chinese (Hong Kong)",
|
||||
"繁體中文(香港)"
|
||||
],
|
||||
[
|
||||
"zh-TW",
|
||||
"Chinese (Taiwan)",
|
||||
"繁體中文(臺灣)"
|
||||
],
|
||||
[
|
||||
"zh-YUE",
|
||||
"Cantonese",
|
||||
"廣東話"
|
||||
],
|
||||
[
|
||||
"ast",
|
||||
"Asturian",
|
||||
"Asturianu"
|
||||
],
|
||||
[
|
||||
"chr",
|
||||
"Cherokee",
|
||||
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ"
|
||||
],
|
||||
[
|
||||
"ckb",
|
||||
"Sorani (Kurdish)",
|
||||
|
@ -934,11 +959,6 @@
|
|||
"Kabyle",
|
||||
"Taqbaylit"
|
||||
],
|
||||
[
|
||||
"kmr",
|
||||
"Kurmanji (Kurdish)",
|
||||
"Kurmancî"
|
||||
],
|
||||
[
|
||||
"ldn",
|
||||
"Láadan",
|
||||
|
@ -974,6 +994,11 @@
|
|||
"Toki Pona",
|
||||
"toki pona"
|
||||
],
|
||||
[
|
||||
"xal",
|
||||
"Kalmyk",
|
||||
"Хальмг келн"
|
||||
],
|
||||
[
|
||||
"zba",
|
||||
"Balaibalan",
|
||||
|
|
185
src/index.css
185
src/index.css
|
@ -8,15 +8,40 @@
|
|||
--sai-left: env(safe-area-inset-left);
|
||||
|
||||
--text-size: 16px;
|
||||
--main-width: 40em;
|
||||
--main-width: max(60dvw, 40em);
|
||||
text-size-adjust: none;
|
||||
--hairline-width: 1px;
|
||||
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
|
||||
Menlo, Courier, monospace;
|
||||
|
||||
--blue-color: royalblue;
|
||||
--purple-color: blueviolet;
|
||||
--purple-fg-color: color-mix(
|
||||
in srgb-linear,
|
||||
var(--purple-color) 60%,
|
||||
var(--text-color) 40%
|
||||
);
|
||||
--purple-bg-color: color-mix(in srgb, var(--purple-color) 10%, transparent);
|
||||
--green-color: darkgreen;
|
||||
--orange-color: darkorange;
|
||||
--orange-light-bg-color: color-mix(
|
||||
in srgb,
|
||||
var(--orange-color) 20%,
|
||||
transparent
|
||||
);
|
||||
--orange-fg-color: color-mix(
|
||||
in srgb-linear,
|
||||
var(--orange-color) 60%,
|
||||
var(--text-color) 40%
|
||||
);
|
||||
--orange-bg-color: color-mix(in srgb, var(--orange-color) 10%, transparent);
|
||||
--red-color: orangered;
|
||||
--red-text-color: color-mix(
|
||||
in srgb-linear,
|
||||
var(--red-color) 60%,
|
||||
var(--text-color) 40%
|
||||
);
|
||||
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
|
||||
--bg-color: #fff;
|
||||
--bg-faded-color: #f0f2f5;
|
||||
--bg-blur-color: #fff9;
|
||||
|
@ -24,10 +49,16 @@
|
|||
--text-color: #1c1e21;
|
||||
--text-insignificant-color: #1c1e2199;
|
||||
--link-color: var(--blue-color);
|
||||
--link-bg-color: #4169e122;
|
||||
--link-light-color: #4169e199;
|
||||
--link-faded-color: #4169e155;
|
||||
--link-bg-hover-color: #f0f2f599;
|
||||
--link-visited-color: mediumslateblue;
|
||||
--link-text-color: color-mix(
|
||||
in lch,
|
||||
var(--link-color) 60%,
|
||||
var(--text-color) 40%
|
||||
);
|
||||
--focus-ring-color: var(--link-color);
|
||||
--button-bg-color: var(--blue-color);
|
||||
--button-bg-blur-color: #4169e1aa;
|
||||
|
@ -35,15 +66,29 @@
|
|||
--button-plain-bg-hover-color: rgba(128, 128, 128, 0.1);
|
||||
--reblog-color: var(--purple-color);
|
||||
--reblog-faded-color: #892be220;
|
||||
--group-color: var(--green-color);
|
||||
--group-faded-color: #00640020;
|
||||
--reply-to-color: var(--orange-color);
|
||||
--reply-to-text-color: #b36200;
|
||||
--favourite-color: var(--red-color);
|
||||
--reply-to-faded-color: #ffa60030;
|
||||
--reply-to-faded-color: #ffa60020;
|
||||
--hashtag-color: LightSeaGreen;
|
||||
--hashtag-faded-color: color-mix(
|
||||
in srgb,
|
||||
var(--hashtag-color) 15%,
|
||||
transparent
|
||||
);
|
||||
--hashtag-text-color: color-mix(
|
||||
in lch,
|
||||
var(--hashtag-color) 40%,
|
||||
var(--text-color) 60%
|
||||
);
|
||||
--outline-color: rgba(128, 128, 128, 0.2);
|
||||
--outline-hover-color: rgba(128, 128, 128, 0.7);
|
||||
--divider-color: rgba(0, 0, 0, 0.1);
|
||||
--backdrop-color: rgba(0, 0, 0, 0.05);
|
||||
--backdrop-solid-color: #ccc;
|
||||
--backdrop-color: rgba(0, 0, 0, 0.1);
|
||||
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
|
||||
--backdrop-solid-color: #eee;
|
||||
--img-bg-color: rgba(128, 128, 128, 0.2);
|
||||
--loader-color: #1c1e2199;
|
||||
--comment-line-color: #e5e5e5;
|
||||
|
@ -52,8 +97,30 @@
|
|||
--close-button-bg-active-color: rgba(0, 0, 0, 0.2);
|
||||
--close-button-color: rgba(0, 0, 0, 0.5);
|
||||
--close-button-hover-color: rgba(0, 0, 0, 1);
|
||||
--private-note-text-color: var(--text-color);
|
||||
--private-note-bg-color: color-mix(in srgb, yellow 20%, var(--bg-color));
|
||||
--private-note-border-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Media colors won't change based on color scheme */
|
||||
--media-fg-color: #f0f2f5;
|
||||
--media-bg-color: #242526;
|
||||
--media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
|
||||
|
||||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
--min-dimension: 88px;
|
||||
|
||||
--forward: right;
|
||||
--backward: left;
|
||||
--to-forward: to right;
|
||||
--to-backward: to left;
|
||||
&:dir(rtl) {
|
||||
--forward: left;
|
||||
--backward: right;
|
||||
--to-forward: to left;
|
||||
--to-backward: to right;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
|
@ -77,13 +144,18 @@
|
|||
--link-light-color: #6494ed99;
|
||||
--link-faded-color: #6494ed88;
|
||||
--link-bg-hover-color: #34353799;
|
||||
--link-visited-color: color-mix(
|
||||
in lch,
|
||||
mediumslateblue 70%,
|
||||
var(--text-color) 30%
|
||||
);
|
||||
--reblog-faded-color: #b190f141;
|
||||
--reply-to-text-color: var(--reply-to-color);
|
||||
--reply-to-faded-color: #ffa60027;
|
||||
--reply-to-faded-color: #ffa60017;
|
||||
--divider-color: rgba(255, 255, 255, 0.1);
|
||||
--bg-blur-color: #24252699;
|
||||
--backdrop-color: rgba(0, 0, 0, 0.5);
|
||||
--backdrop-solid-color: #333;
|
||||
--backdrop-solid-color: #111;
|
||||
--loader-color: #f0f2f599;
|
||||
--comment-line-color: #565656;
|
||||
--drop-shadow-color: rgba(0, 0, 0, 0.5);
|
||||
|
@ -91,6 +163,7 @@
|
|||
--close-button-bg-active-color: rgba(255, 255, 255, 0.15);
|
||||
--close-button-color: rgba(255, 255, 255, 0.5);
|
||||
--close-button-hover-color: rgba(255, 255, 255, 1);
|
||||
--private-note-border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,12 +182,21 @@ html {
|
|||
}
|
||||
|
||||
body {
|
||||
font-family: ui-rounded, system-ui;
|
||||
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||
Ubuntu, Cantarell, Noto Sans, sans-serif;
|
||||
font-size: var(--text-size);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Prevent pull-to-refresh on Chrome PWA */
|
||||
@media (display-mode: standalone) {
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration-color: var(--link-faded-color);
|
||||
|
@ -166,13 +248,16 @@ button,
|
|||
text-decoration: none;
|
||||
user-select: none;
|
||||
}
|
||||
button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
:is(button, .button) > * {
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
|
||||
cursor: pointer;
|
||||
filter: brightness(1.2);
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
:is(button, .button):not(:disabled, .disabled):active {
|
||||
filter: brightness(0.8);
|
||||
|
@ -203,6 +288,23 @@ button,
|
|||
:is(button, .button).plain4:not(:disabled, .disabled):is(:hover, :focus) {
|
||||
color: var(--text-color);
|
||||
}
|
||||
:is(button, .button).plain5 {
|
||||
background-color: transparent;
|
||||
color: var(--link-color);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--link-faded-color);
|
||||
}
|
||||
:is(button, .button).plain5:not(:disabled, .disabled):is(:hover, :focus) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
:is(button, .button).plain6 {
|
||||
background-color: var(--bg-blur-color);
|
||||
color: var(--link-color);
|
||||
border: 1px solid var(--link-color);
|
||||
}
|
||||
:is(button, .button).plain6:not(:disabled, .disabled):is(:hover, :focus) {
|
||||
background-color: var(--link-bg-color);
|
||||
}
|
||||
:is(button, .button).light {
|
||||
background-color: var(--bg-faded-color);
|
||||
color: var(--text-color);
|
||||
|
@ -256,6 +358,7 @@ button,
|
|||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='search'],
|
||||
textarea,
|
||||
select {
|
||||
color: var(--text-color);
|
||||
|
@ -265,6 +368,7 @@ select {
|
|||
border-radius: 4px;
|
||||
}
|
||||
input[type='text']:focus,
|
||||
input[type='search']:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--outline-color);
|
||||
|
@ -276,12 +380,26 @@ button.large {
|
|||
font-size: 125%;
|
||||
padding: 12px;
|
||||
}
|
||||
textarea:disabled {
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
|
||||
button.small {
|
||||
:is(input[type='text'], input[type='search'], textarea, select).block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:is(button, .button).small {
|
||||
font-size: 90%;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.button.centered {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
select.plain {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
|
@ -291,10 +409,10 @@ pre {
|
|||
tab-size: 2;
|
||||
}
|
||||
pre code,
|
||||
code {
|
||||
code,
|
||||
kbd {
|
||||
font-size: 90%;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
|
||||
Menlo, Courier, monospace;
|
||||
font-family: var(--monospace-font);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
@ -335,6 +453,11 @@ code {
|
|||
display: initial;
|
||||
}
|
||||
|
||||
.bidi-isolate {
|
||||
direction: initial;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* KEYFRAMES */
|
||||
|
||||
@keyframes appear {
|
||||
|
@ -377,6 +500,17 @@ code {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up-smooth {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes position-object {
|
||||
0% {
|
||||
object-position: 50% 50%;
|
||||
|
@ -414,3 +548,30 @@ code {
|
|||
.shazam-container-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes shazam-horizontal {
|
||||
0% {
|
||||
grid-template-columns: 0fr;
|
||||
}
|
||||
100% {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.shazam-container-horizontal {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
transition: grid-template-columns 0.5s ease-in-out;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shazam-container-horizontal:not(.no-animation) {
|
||||
animation: shazam-horizontal 0.5s ease-in-out both !important;
|
||||
}
|
||||
.shazam-container-horizontal[hidden] {
|
||||
grid-template-columns: 0fr;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import './index.css';
|
||||
|
||||
import './cloak-mode.css';
|
||||
import './polyfills';
|
||||
|
||||
// Polyfill needed for Firefox < 122
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
||||
// import '@formatjs/intl-segmenter/polyfill';
|
||||
import { render } from 'preact';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue